Skip to content
Source
Sign inSign up

mcpApp.test.ts

298 lines11.6 KB

1import * as NodeVM from "node:vm";
2import { describe, expect, it } from "vite-plus/test";
3​
4import {
5 injectMcpAppCsp,
6 injectMcpAppThemeBootstrap,
7 injectMcpAppCookieBootstrap,
8 MCP_APP_COLOR_SCHEME_PARAM,
9 mcpAppContentSecurityPolicy,
10 mcpAppToolCallableByApp,
11 mcpAppToolResourceUri,
12 readMcpAppCsp,
13 readMcpAppReference,
14} from "./mcpApp.ts";
15​
16describe("mcpAppContentSecurityPolicy", () => {
17 it("blocks the network and T3's own origin when the app declares nothing", () => {
18 const policy = mcpAppContentSecurityPolicy(undefined);
19 expect(policy).toContain("default-src 'none'");
20 expect(policy).toContain("connect-src 'none'");
21 expect(policy).toContain("frame-src 'none'");
22 expect(policy).not.toContain("'self'");
23 });
24​
25 it("allows exactly the declared origins", () => {
26 const policy = mcpAppContentSecurityPolicy({
27 connectDomains: ["https://api.weather.test"],
28 resourceDomains: ["https://*.cdn.test"],
29 });
30 expect(policy).toContain("connect-src https://api.weather.test");
31 expect(policy).toContain("script-src 'unsafe-inline' https://*.cdn.test");
32 expect(policy).toContain("img-src data: blob: https://*.cdn.test");
33 });
34});
35​
36describe("injectMcpAppCsp", () => {
37 it("puts the policy after the doctype and ahead of every script", () => {
38 const html = "<!DOCTYPE html><html><head><script>x()</script></head></html>";
39 const injected = injectMcpAppCsp(html, undefined);
40 expect(injected.startsWith('<!DOCTYPE html><meta http-equiv="Content-Security-Policy"')).toBe(
41 true,
42 );
43 expect(injected.indexOf("connect-src 'none'")).toBeLessThan(injected.indexOf("<script>"));
44 expect(injectMcpAppCsp("<p>hi</p>", undefined).startsWith("<!doctype html><meta")).toBe(true);
45 });
46});
47​
48describe("injectMcpAppThemeBootstrap", () => {
49 it("keeps stored CSP before the bootstrap and vendor scripts after it", () => {
50 const snapshot = injectMcpAppCsp(
51 "<html><head><script>vendor()</script></head></html>",
52 undefined,
53 );
54 const html = injectMcpAppThemeBootstrap(snapshot);
55 expect(html.indexOf("Content-Security-Policy")).toBeLessThan(html.indexOf("<script>"));
56 expect(html.indexOf("window.addEventListener")).toBeLessThan(html.indexOf("vendor()"));
57 expect(html.endsWith(snapshot.slice(snapshot.indexOf("<html>")))).toBe(true);
58 });
59​
60 it("applies parent initialize and theme changes while ignoring unrelated or invalid messages", () => {
61 const html = injectMcpAppThemeBootstrap(injectMcpAppCsp("<p>app</p>", undefined));
62 const script = html.slice(html.indexOf("<script>") + 8, html.indexOf("</script>"));
63 const parent = {};
64 const style = { colorScheme: "normal" };
65 let receive: ((event: { source: object; data: unknown }) => void) | undefined;
66 NodeVM.runInNewContext(script, {
67 window: {
68 parent,
69 addEventListener: (_type: string, callback: typeof receive) => {
70 receive = callback;
71 },
72 },
73 document: { documentElement: { style } },
74 });
75 const send = (data: unknown, source = parent) => receive?.({ source, data });
76 const initialized = { jsonrpc: "2.0", id: 1, result: { hostContext: { theme: "dark" } } };
77 send(initialized, {});
78 expect(style.colorScheme).toBe("normal");
79 send(initialized);
80 expect(style.colorScheme).toBe("dark");
81 for (const data of [
82 null,
83 "bad",
84 {},
85 { ...initialized, jsonrpc: "1.0" },
86 { jsonrpc: "2.0", method: "other", params: { theme: "light" } },
87 {
88 jsonrpc: "2.0",
89 method: "ui/notifications/host-context-changed",
90 params: { theme: "normal" },
91 },
92 { jsonrpc: "2.0", result: { hostContext: { theme: "light" } } },
93 ])
94 send(data);
95 expect(style.colorScheme).toBe("dark");
96 send({
97 jsonrpc: "2.0",
98 method: "ui/notifications/host-context-changed",
99 params: { theme: "light" },
100 });
101 expect(style.colorScheme).toBe("light");
102 send(initialized);
103 expect(style.colorScheme).toBe("dark");
104 });
105​
106 it("applies only a valid scheme from the frame URL before any message arrives", () => {
107 const html = injectMcpAppThemeBootstrap("<p>app</p>");
108 const script = html.slice(html.indexOf("<script>") + 8, html.indexOf("</script>"));
109 const initialScheme = (search: string) => {
110 const head: Array<{ name?: string; content?: string }> = [];
111 NodeVM.runInNewContext(script, {
112 URLSearchParams,
113 location: { search },
114 window: { parent: {}, addEventListener: () => {} },
115 document: {
116 createElement: () => ({}),
117 head: { append: (element: (typeof head)[number]) => head.push(element) },
118 },
119 });
120 return head.find((element) => element.name === "color-scheme")?.content ?? "normal";
121 };
122 expect(initialScheme(`?a=1&${MCP_APP_COLOR_SCHEME_PARAM}=dark`)).toBe("dark");
123 expect(initialScheme(`?${MCP_APP_COLOR_SCHEME_PARAM}=light`)).toBe("light");
124 expect(initialScheme(`?${MCP_APP_COLOR_SCHEME_PARAM}=normal`)).toBe("normal");
125 expect(initialScheme("")).toBe("normal");
126 });
127});
128​
129describe("readMcpAppCsp", () => {
130 it("drops entries that could widen or break the policy", () => {
131 expect(
132 readMcpAppCsp({
133 connectDomains: [
134 "https://ok.test",
135 "*",
136 "'self'",
137 "https://evil.test; script-src *",
138 "http://localhost:3000",
139 "javascript:alert(1)",
140 ],
141 resourceDomains: "https://not-an-array.test",
142 }),
143 ).toEqual({ connectDomains: ["https://ok.test", "http://localhost:3000"] });
144 expect(readMcpAppCsp({ connectDomains: ["*"] })).toBeUndefined();
145 });
146});
147​
148describe("readMcpAppReference", () => {
149 it("requires a ui:// resource and the owning server and tool", () => {
150 const reference = {
151 attachmentId: "thread-1-abc-html",
152 server: "weather",
153 tool: "get_weather",
154 resourceUri: "ui://weather/dashboard",
155 };
156 expect(readMcpAppReference(reference)).toEqual(reference);
157 expect(readMcpAppReference({ ...reference, resourceUri: "https://x.test" })).toBeUndefined();
158 expect(readMcpAppReference({ ...reference, server: "" })).toBeUndefined();
159 });
160});
161​
162describe("tool metadata", () => {
163 it("reads the resource URI from either spelling and honors app visibility", () => {
164 const uri = "ui://weather/card.html";
165 expect(mcpAppToolResourceUri({ ui: { resourceUri: uri } })).toBe(uri);
166 expect(
167 mcpAppToolResourceUri({ ui: { visibility: ["model", "app"] }, "ui/resourceUri": uri }),
168 ).toBe(uri);
169 expect(
170 mcpAppToolResourceUri({ ui: { resourceUri: "https://weather/card.html" } }),
171 ).toBeUndefined();
172 expect(mcpAppToolCallableByApp(undefined)).toBe(true);
173 expect(mcpAppToolCallableByApp({ ui: { visibility: ["model"] } })).toBe(false);
174 expect(mcpAppToolCallableByApp({ ui: { visibility: ["model", "app"] } })).toBe(true);
175 });
176});
177​
178describe("mcpAppFromToolItem", () => {
179 it("reads the app from both the stored output and its compact wire form", async () => {
180 const { compactDynamicToolOutput, mcpAppFromToolItem } = await import("./toolOutput.ts");
181 const app = {
182 attachmentId: "thread-1-abc-html",
183 server: "weather",
184 tool: "get_weather",
185 resourceUri: "ui://weather/dashboard",
186 };
187 const stored = { t3McpApp: app, result: { content: [] } };
188 const toolName = "weather.get_weather";
189 expect(mcpAppFromToolItem({ toolName, output: stored })).toEqual(app);
190 expect(mcpAppFromToolItem({ toolName, output: compactDynamicToolOutput(stored) })).toEqual(app);
191 });
192​
193 it("ignores a reference naming a server or tool other than the item's own", async () => {
194 const { mcpAppFromToolItem } = await import("./toolOutput.ts");
195 const forged = {
196 t3McpApp: {
197 attachmentId: "thread-1-abc-html",
198 server: "bank",
199 tool: "transfer",
200 resourceUri: "ui://bank/app",
201 },
202 };
203 expect(mcpAppFromToolItem({ toolName: "evil.lookup", output: forged })).toBeUndefined();
204 expect(mcpAppFromToolItem({ toolName: "bank.transfer", output: forged })).toBeDefined();
205 // Claude Code names the same tool `mcp__<server>__<tool>`.
206 expect(mcpAppFromToolItem({ toolName: "mcp__bank__transfer", output: forged })).toBeDefined();
207 expect(mcpAppFromToolItem({ toolName: "mcp__evil__transfer", output: forged })).toBeUndefined();
208 const connector = { t3McpApp: { ...forged.t3McpApp, server: "claude.ai Asana (Beta)" } };
209 expect(
210 mcpAppFromToolItem({ toolName: "mcp__claude_ai_Asana_Beta__transfer", output: connector }),
211 ).toBeDefined();
212 });
213​
214 it("keeps an app whose resource URI is longer than a name", async () => {
215 const { compactDynamicToolOutput } = await import("./toolOutput.ts");
216 const resourceUri = `ui://weather/${"segment/".repeat(60)}dashboard`;
217 const output = compactDynamicToolOutput({
218 t3McpApp: {
219 attachmentId: "thread-1-abc-html",
220 server: "weather",
221 tool: "get_weather",
222 resourceUri,
223 },
224 });
225 expect(output?.t3McpApp?.resourceUri).toBe(resourceUri);
226 });
227​
228 it("keeps the compact app reference within the wire budget", async () => {
229 const { compactDynamicToolOutput } = await import("./toolOutput.ts");
230 const domains = Array.from(
231 { length: 32 },
232 (_, index) => `https://${"a".repeat(200)}${index}.test`,
233 );
234 const output = compactDynamicToolOutput({
235 t3McpApp: {
236 attachmentId: "thread-1-abc-html",
237 server: "weather",
238 tool: "get_weather",
239 resourceUri: "ui://weather/dashboard",
240 csp: { connectDomains: domains, resourceDomains: domains },
241 },
242 });
243 expect(new TextEncoder().encode(JSON.stringify(output)).byteLength).toBeLessThanOrEqual(8_192);
244 expect(output?.t3McpApp?.server).toBe("weather");
245 });
246});
247​
248describe("MCP App cookie bootstrap", () => {
249 const vendor = `<script>document.cookie.split(";");document.cookie="analytics=1";document.root="rendered";</script>`;
250 const execute = (html: string, document: object) => {
251 for (const script of html.matchAll(/<script>([\s\S]*?)<\/script>/g)) {
252 NodeVM.runInNewContext(script[1]!, { document });
253 }
254 };
255​
256 it("lets cookie-reading startup code render in an opaque document without granting storage", () => {
257 const blocked = new DOMException("Opaque origin", "SecurityError");
258 const prototype = Object.defineProperty({}, "cookie", {
259 get() {
260 throw blocked;
261 },
262 set() {
263 throw blocked;
264 },
265 });
266 const document = Object.assign(
267 Object.create(prototype) as { cookie: string; root: string; indexedDB: unknown },
268 { root: "" },
269 );
270 Object.defineProperty(document, "indexedDB", {
271 get() {
272 throw blocked;
273 },
274 });
275 const saved = injectMcpAppCsp(vendor, { connectDomains: ["https://analytics.example"] });
276 const html = injectMcpAppCookieBootstrap(saved);
277 expect(html.indexOf("Content-Security-Policy")).toBeLessThan(html.indexOf("<script>"));
278 expect(html).toContain("connect-src https://analytics.example");
279 execute(html, document);
280 expect(document.root).toBe("rendered");
281 expect(document.cookie).toBe("");
282 document.cookie = "session=secret";
283 expect(document.cookie).toBe("");
284 expect(() => document.indexedDB).toThrow(blocked);
285 expect(() => Reflect.get(prototype, "cookie")).toThrow(blocked);
286 });
287​
288 it("leaves other cookie failures unchanged", () => {
289 const failure = new TypeError("Unexpected cookie failure");
290 const document = Object.defineProperty({}, "cookie", {
291 get() {
292 throw failure;
293 },
294 });
295 expect(() => execute(injectMcpAppCookieBootstrap(vendor), document)).toThrow(failure);
296 expect(() => Reflect.get(document, "cookie")).toThrow(failure);
297 });
298});