| 1 | // Run: tsx src/__tests__/mcp-app-protocol.test.ts |
| 2 | import { readFileSync } from "node:fs"; |
| 3 | import { |
| 4 | normalizeMCPAppResult, |
| 5 | parseMCPAppArguments, |
| 6 | parseMCPAppCallResult, |
| 7 | validatedMCPAppLinkOrigin, |
| 8 | } from "../lib/mcpAppProtocol"; |
| 9 | |
| 10 | let passed = 0; |
| 11 | let failed = 0; |
| 12 | |
| 13 | function ok(value: boolean, label: string) { |
| 14 | if (value) { |
| 15 | process.stdout.write(` PASS ${label}\n`); |
| 16 | passed += 1; |
| 17 | } else { |
| 18 | process.stdout.write(` FAIL ${label}\n`); |
| 19 | failed += 1; |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | const args = parseMCPAppArguments(`{"city":"Singapore","days":3}`); |
| 24 | ok(args.city === "Singapore" && args.days === 3, "complete tool arguments are parsed"); |
| 25 | ok(Object.keys(parseMCPAppArguments("not-json")).length === 0, "invalid arguments degrade to an empty object"); |
| 26 | ok(Object.keys(parseMCPAppArguments("[1,2]")).length === 0, "non-object arguments are refused"); |
| 27 | |
| 28 | const richResult = normalizeMCPAppResult({ |
| 29 | server: "weather", |
| 30 | tool: "forecast", |
| 31 | generation: 4, |
| 32 | rawResult: { |
| 33 | content: [{ type: "text", text: "rain" }], |
| 34 | structuredContent: { chance: 80 }, |
| 35 | isError: false, |
| 36 | _meta: { local: true }, |
| 37 | }, |
| 38 | structured: { chance: 10 }, |
| 39 | }, "fallback"); |
| 40 | ok(Array.isArray(richResult.content) && richResult.content[0]?.type === "text", "raw MCP result content is preserved"); |
| 41 | ok((richResult.structuredContent as { chance?: number }).chance === 80, "raw structuredContent wins over duplicate presentation data"); |
| 42 | ok(richResult.isError === false && richResult._meta !== undefined, "standard result fields are preserved"); |
| 43 | |
| 44 | const fallbackResult = normalizeMCPAppResult({ |
| 45 | server: "weather", |
| 46 | tool: "forecast", |
| 47 | generation: 4, |
| 48 | structured: { chance: 25 }, |
| 49 | }, "fallback text"); |
| 50 | ok(fallbackResult.content[0]?.type === "text" && fallbackResult.content[0]?.text === "fallback text", "text fallback becomes a CallToolResult content block"); |
| 51 | ok((fallbackResult.structuredContent as { chance?: number }).chance === 25, "bounded structured fallback is delivered"); |
| 52 | |
| 53 | const nestedCallResult = parseMCPAppCallResult(JSON.stringify({ |
| 54 | content: [{ |
| 55 | type: "resource", |
| 56 | resource: { uri: "https://example.test/result", mimeType: "application/json", text: "{}", _meta: { etag: "v1" } }, |
| 57 | _meta: { audience: "app" }, |
| 58 | }], |
| 59 | structuredContent: { ok: false, details: [1, 2, 3] }, |
| 60 | isError: true, |
| 61 | _meta: { trace: "nested" }, |
| 62 | })); |
| 63 | ok(nestedCallResult.isError === true, "App-initiated call preserves isError"); |
| 64 | ok((nestedCallResult.structuredContent as { details?: number[] }).details?.length === 3, "App-initiated call preserves structuredContent"); |
| 65 | const nestedResource = nestedCallResult.content[0] as unknown as { resource?: { _meta?: { etag?: string } }; _meta?: { audience?: string } }; |
| 66 | ok(nestedResource.resource?._meta?.etag === "v1" && nestedResource._meta?.audience === "app", "App-initiated call preserves embedded resource metadata"); |
| 67 | ok((nestedCallResult._meta as { trace?: string }).trace === "nested", "App-initiated call preserves result metadata"); |
| 68 | let invalidNestedResultRejected = false; |
| 69 | try { |
| 70 | parseMCPAppCallResult(`{"structuredContent":{}}`); |
| 71 | } catch { |
| 72 | invalidNestedResultRejected = true; |
| 73 | } |
| 74 | ok(invalidNestedResultRejected, "invalid App-initiated CallToolResult is rejected"); |
| 75 | |
| 76 | ok(validatedMCPAppLinkOrigin("https://docs.example.test/path") === "https://docs.example.test", "https link origin is normalized"); |
| 77 | ok(validatedMCPAppLinkOrigin("http://localhost:8080/path") === "http://localhost:8080", "http loopback-style origin is allowed"); |
| 78 | for (const unsafe of [ |
| 79 | "javascript:alert(1)", |
| 80 | "file:///tmp/secret", |
| 81 | "https://user:pass@example.test/private", |
| 82 | "//example.test/no-scheme", |
| 83 | ]) { |
| 84 | ok(validatedMCPAppLinkOrigin(unsafe) === null, `unsafe link is refused: ${unsafe}`); |
| 85 | } |
| 86 | |
| 87 | const cardSource = readFileSync(new URL("../components/MCPAppCard.tsx", import.meta.url), "utf8"); |
| 88 | const inputAt = cardSource.indexOf("bridge.sendToolInput"); |
| 89 | const resultAt = cardSource.indexOf("bridge.sendToolResult"); |
| 90 | ok(inputAt >= 0 && resultAt > inputAt, "AppBridge sends complete input before the tool result"); |
| 91 | ok(cardSource.includes("bridge.teardownResource({})"), "AppBridge teardown is attempted before unmount cleanup"); |
| 92 | ok(cardSource.includes("MCPAppCallToolForTab") && cardSource.includes("MCPOpenAppLinkForTab"), "privileged App callbacks use tab-bound host APIs"); |
| 93 | |
| 94 | process.stdout.write(`\n${passed} passed, ${failed} failed\n`); |
| 95 | if (failed > 0) process.exit(1); |
| 96 |