| 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,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"; |
| 11 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; | 11 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; |
| 12 | import { MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp"; | ||
| 12 | import { describe, expect, it } from "@effect/vitest"; | 13 | import { describe, expect, it } from "@effect/vitest"; |
| 13 | import * as Crypto from "effect/Crypto"; | 14 | import * as Crypto from "effect/Crypto"; |
| 14 | import * as DateTime from "effect/DateTime"; | 15 | import * 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"; |
| 30 | import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; | 31 | import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; |
| 31 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 32 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 32 | import { assetFileResponse } from "../http.ts"; | 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 | import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; | 35 | import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; |
| 35 | import { openMediaFile } from "./MediaFile.ts"; | 36 | import { openMediaFile } from "./MediaFile.ts"; |
| 36 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; | 37 | import { 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)); |
| 122 | 123 | ||
| 123 | describe("AssetAccess", () => { | 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 | 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 | ); |
| 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 | 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"; |
| 33 | import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia"; | 33 | import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia"; |
| 34 | import { MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp"; | ||
| 34 | import { | 35 | import { |
| 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"; |
| 45 | import * as Path from "effect/Path"; | 46 | import * as Path from "effect/Path"; |
| 46 | import * as PlatformError from "effect/PlatformError"; | 47 | import * as PlatformError from "effect/PlatformError"; |
| 47 | import * as Schema from "effect/Schema"; | 48 | import * as Schema from "effect/Schema"; |
| 49 | import * as Stream from "effect/Stream"; | ||
| 48 | 50 | ||
| 49 | import { | 51 | import { |
| 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 | ); |
| 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 | * Decodes one image a tool returned inline; null when the stored item has no | 248 | * 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 !== undefined | 644 | : 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"; |
| 16 | import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/http"; | 16 | import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/http"; |
| 17 | import { openMediaFile } from "./assets/MediaFile.ts"; | 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 | import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts"; | 24 | import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts"; |
| 20 | 25 | ||
| 21 | import * as ServerConfig from "./config.ts"; | 26 | import * as ServerConfig from "./config.ts"; |
| ... | @@ -30,6 +35,70 @@ import { | ... | @@ -30,6 +35,70 @@ import { |
| 30 | } from "./http.ts"; | 35 | } from "./http.ts"; |
| 31 | import * as ServerHttp from "./http.ts"; | 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 | describe("untraced requests", () => { | 102 | describe("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"; |
| 7 | import { isDevProxiedPath } from "@t3tools/shared/devProxy"; | 7 | import { isDevProxiedPath } from "@t3tools/shared/devProxy"; |
| 8 | import { injectMcpAppThemeBootstrap } from "@t3tools/shared/mcpApp"; | ||
| 8 | import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; | 9 | import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; |
| 9 | import * as Data from "effect/Data"; | 10 | import * as Data from "effect/Data"; |
| 10 | import * as Effect from "effect/Effect"; | 11 | import * 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"; |
| 28 | import { OtlpTracer, OtlpSerialization } from "effect/observability"; | 29 | import { OtlpTracer, OtlpSerialization } from "effect/observability"; |
| 29 | 30 | ||
| 30 | import * as ServerConfig from "./config.ts"; | 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 | import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; | 33 | import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; |
| 33 | import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; | 34 | import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; |
| 34 | import { | 35 | import { |
| ... | @@ -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 an | 40 | /** 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 order | 46 | // 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 @@ |
| 1 | import * as NodeVM from "node:vm"; | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| 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 | }); |
| 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 | describe("readMcpAppCsp", () => { | 105 | describe("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 | } |
| 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 | /** The iframe `allow` attribute for the permissions an app declared. */ | 252 | /** The iframe `allow` attribute for the permissions an app declared. */ |
| 219 | export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string { | 253 | export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string { |
| 220 | const features: Array<string> = []; | 254 | const features: Array<string> = []; |