Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/server/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { openMediaFile } from "./assets/MediaFile.ts";

import {
injectMcpAppCsp,
injectMcpAppCookieBootstrap,
injectMcpAppThemeBootstrap,
MCP_APP_MAX_HTML_BYTES,
} from "@t3tools/shared/mcpApp";
Expand Down Expand Up @@ -58,7 +59,7 @@ describe("MCP app asset responses", () => {
method,
),
);
const expected = injectMcpAppThemeBootstrap(html);
const expected = injectMcpAppCookieBootstrap(injectMcpAppThemeBootstrap(html));
expect(response.status).toBe(200);
expect(response.headers.get("content-length")).toBe(
String(new TextEncoder().encode(expected).byteLength),
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
EnvironmentHttpApi,
} from "@t3tools/contracts";
import { isDevProxiedPath } from "@t3tools/shared/devProxy";
import { injectMcpAppThemeBootstrap } from "@t3tools/shared/mcpApp";
import { injectMcpAppCookieBootstrap, injectMcpAppThemeBootstrap } from "@t3tools/shared/mcpApp";
import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -185,7 +185,7 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* (
return HttpServerResponse.text("MCP app is too large to preview.", { status: 413 });
}
const body = new TextEncoder().encode(
injectMcpAppThemeBootstrap(new TextDecoder().decode(bytes)),
injectMcpAppCookieBootstrap(injectMcpAppThemeBootstrap(new TextDecoder().decode(bytes))),
);
headers["Content-Length"] = String(body.byteLength);
return method === "HEAD"
Expand Down
59 changes: 59 additions & 0 deletions packages/shared/src/mcpApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
injectMcpAppCsp,
injectMcpAppThemeBootstrap,
injectMcpAppCookieBootstrap,
mcpAppContentSecurityPolicy,
mcpAppToolCallableByApp,
readMcpAppCsp,
Expand Down Expand Up @@ -209,3 +210,61 @@ describe("mcpAppFromToolItem", () => {
expect(output?.t3McpApp?.server).toBe("weather");
});
});

describe("MCP App cookie bootstrap", () => {
const vendor = `<script>document.cookie.split(";");document.cookie="analytics=1";document.root="rendered";</script>`;
const execute = (html: string, document: object) => {
for (const script of html.matchAll(/<script>([\s\S]*?)<\/script>/g)) {
NodeVM.runInNewContext(script[1]!, { document });
}
};

it("lets cookie-reading startup code render in an opaque document without granting storage", () => {
const blocked = new DOMException("Opaque origin", "SecurityError");
const prototype = Object.defineProperty({}, "cookie", {
get() {
throw blocked;
},
set() {
throw blocked;
},
});
const document = Object.assign(
Object.create(prototype) as { cookie: string; root: string; indexedDB: unknown },
{ root: "" },
);
Object.defineProperty(document, "indexedDB", {
get() {
throw blocked;
},
});
const saved = injectMcpAppCsp(vendor, { connectDomains: ["https://analytics.example"] });
const html = injectMcpAppCookieBootstrap(saved);
expect(html.indexOf("Content-Security-Policy")).toBeLessThan(html.indexOf("<script>"));
expect(html).toContain("connect-src https://analytics.example");
execute(html, document);
expect(document.root).toBe("rendered");
expect(document.cookie).toBe("");
document.cookie = "session=secret";
expect(document.cookie).toBe("");
expect(() => document.indexedDB).toThrow(blocked);
expect(() => Reflect.get(prototype, "cookie")).toThrow(blocked);
});

it("leaves functioning cookie reads and writes unchanged", () => {
const document = { cookie: "existing=1", root: "" };
execute(injectMcpAppCookieBootstrap(vendor), document);
expect(document).toEqual({ cookie: "analytics=1", root: "rendered" });
});

it("leaves other cookie failures unchanged", () => {
const failure = new TypeError("Unexpected cookie failure");
const document = Object.defineProperty({}, "cookie", {
get() {
throw failure;
},
});
expect(() => execute(injectMcpAppCookieBootstrap(vendor), document)).toThrow(failure);
expect(() => Reflect.get(document, "cookie")).toThrow(failure);
});
});
16 changes: 16 additions & 0 deletions packages/shared/src/mcpApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,22 @@ export function injectMcpAppThemeBootstrap(html: string): string {
: `<!doctype html>${script}${html}`;
}

/**
* Analytics libraries often read cookies during startup. An opaque document
* has none, but browsers throw instead of returning an empty cookie string.
* Keep the sandbox and make that document-local case behave like no cookies.
*/
export function injectMcpAppCookieBootstrap(html: string): string {
const bootstrap = `<script>(function(){try{void document.cookie;}catch(error){if(error&&error.name==="SecurityError"){Object.defineProperty(document,"cookie",{configurable:true,get:function(){return "";},set:function(){}});}}})();</script>`;
// Saved captures begin with the CSP inserted by injectMcpAppCsp. Keep it
// ahead of executable code, including this bootstrap, without rewriting it.
const prefix =
/^(?:\uFEFF?\s*<!doctype[^>]*>\s*)?(?:<meta http-equiv="Content-Security-Policy" content="[^"]*">)?/i.exec(
html,
)![0];
return `${prefix}${bootstrap}${html.slice(prefix.length)}`;
}

/** The iframe `allow` attribute for the permissions an app declared. */
export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string {
const features: Array<string> = [];
Expand Down
Loading