diff --git a/package-lock.json b/package-lock.json index f63b3d84f..0189ca20b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@boneskull/typedoc-plugin-mermaid": "^0.2.0", "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/sdk": "1.30.0", "@modelcontextprotocol/server": "2.0.0", "@playwright/test": "1.57.0", "@types/bun": "^1.3.2", @@ -33,6 +34,7 @@ "electron-to-chromium": "1.5.267", "esbuild": "^0.25.12", "express": "^5.1.0", + "ext-apps-v1": "npm:@modelcontextprotocol/ext-apps@1.7.5", "husky": "^9.1.7", "nodemon": "^3.1.0", "playwright": "1.57.0", @@ -2756,6 +2758,47 @@ "resolved": "examples/quickstart", "link": true }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@modelcontextprotocol/server": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", @@ -4375,6 +4418,41 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -5937,6 +6015,81 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/ext-apps-v1": { + "name": "@modelcontextprotocol/ext-apps", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz", + "integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "dependencies": { + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -6262,7 +6415,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -6399,6 +6551,16 @@ "node": ">=12" } }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -6586,6 +6748,20 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -7578,6 +7754,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -9716,6 +9902,16 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index da82a665c..bcea6492d 100644 --- a/package.json +++ b/package.json @@ -76,10 +76,12 @@ "update-lock:docker": "rm -rf node_modules package-lock.json examples/*/node_modules && docker run --rm --platform linux/amd64 -v $(pwd):/work -w /work -e HOME=/tmp node:latest npm i --registry=https://registry.npmjs.org/ --ignore-scripts && rm -rf node_modules examples/*/node_modules && npm i --registry=https://registry.npmjs.org/" }, "author": "Olivier Chafik", + "//interop": "The @modelcontextprotocol/sdk 1.x and ext-apps-v1 devDependencies exist only for src/interop-v1.test.ts, which runs the published 1.7.5 package against this build. The library does not use them.", "devDependencies": { "@boneskull/typedoc-plugin-mermaid": "^0.2.0", "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/sdk": "1.30.0", "@modelcontextprotocol/server": "2.0.0", "@playwright/test": "1.57.0", "@types/bun": "^1.3.2", @@ -95,6 +97,7 @@ "electron-to-chromium": "1.5.267", "esbuild": "^0.25.12", "express": "^5.1.0", + "ext-apps-v1": "npm:@modelcontextprotocol/ext-apps@1.7.5", "husky": "^9.1.7", "nodemon": "^3.1.0", "playwright": "1.57.0", diff --git a/src/interop-v1.test.ts b/src/interop-v1.test.ts new file mode 100644 index 000000000..31dbfa1a6 --- /dev/null +++ b/src/interop-v1.test.ts @@ -0,0 +1,379 @@ +/** + * Cross-version interop against the *published* 1.x package. + * + * `ext-apps-v1` is an npm alias for `@modelcontextprotocol/ext-apps@1.7.5` + * (built on `@modelcontextprotocol/sdk` 1.x). Each pairing wires a real + * `App` to a real `AppBridge` of the other major over an in-memory duplex, + * with the bridge fronting a real MCP `Client`+`McpServer` of its own SDK + * generation, and asserts the observable outcomes. Complements + * `wire-compat.test.ts`, which replays hand-shaped 1.x JSON instead. + */ +import { describe, it, expect, beforeAll, afterAll } from "bun:test"; +import { readFileSync } from "node:fs"; +import { + Client as ClientV2, + InMemoryTransport as InMemoryTransportV2, + ProtocolError, + type Transport, +} from "@modelcontextprotocol/client"; +import { EmptyResultSchema as EmptyResultSchemaV2 } from "@modelcontextprotocol/core"; +import { McpServer as McpServerV2 } from "@modelcontextprotocol/server"; +import { Client as ClientV1 } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport as InMemoryTransportV1 } from "@modelcontextprotocol/sdk/inMemory.js"; +import { McpServer as McpServerV1 } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Protocol as ProtocolV1 } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import { + EmptyResultSchema as EmptyResultSchemaV1, + McpError, +} from "@modelcontextprotocol/sdk/types.js"; +import { App as AppV1 } from "ext-apps-v1"; +import { AppBridge as AppBridgeV1 } from "ext-apps-v1/app-bridge"; +import { z } from "zod/v4"; + +import { App as AppV2 } from "./app.js"; +import { AppBridge as AppBridgeV2 } from "./app-bridge.js"; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 5)); + +/** In-memory duplex. Structurally a `Transport` for both SDK generations. */ +function createDuplex(): [Transport, Transport] { + const make = () => { + let closed = false; + const t: Transport & { peer?: typeof t } = { + async start() {}, + async send(message) { + // Serialize like a real transport so each side parses plain JSON. + t.peer?.onmessage?.(JSON.parse(JSON.stringify(message))); + }, + async close() { + if (closed) return; + closed = true; + t.onclose?.(); + await t.peer?.close(); + }, + }; + return t; + }; + const [a, b] = [make(), make()]; + a.peer = b; + b.peer = a; + return [a, b]; +} + +/** One SDK generation's MCP pieces, as used by the App side or the host side. */ +const sdkV1 = { + Client: ClientV1, + McpServer: McpServerV1, + InMemoryTransport: InMemoryTransportV1, + EmptyResultSchema: EmptyResultSchemaV1, + error: (code: number, message: string): Error => new McpError(code, message), +}; +const sdkV2 = { + Client: ClientV2, + McpServer: McpServerV2, + InMemoryTransport: InMemoryTransportV2, + EmptyResultSchema: EmptyResultSchemaV2, + error: (code: number, message: string): Error => + new ProtocolError(code, message), +}; + +const pairings = [ + { + name: "2.x App with 1.x AppBridge (sdk 1.x host)", + App: AppV2 as any, + AppBridge: AppBridgeV1 as any, + view: sdkV2, + host: sdkV1, + // sdk 1.x reports a Zod params failure as InternalError. + invalidParamsCode: -32603, + // sdk 1.x forwards a handler-thrown -32002 unchanged. + handlerThrown32002Code: -32002, + // McpServer 1.x answers an unknown tool with an isError result. + unknownToolRejects: false, + }, + { + name: "1.x App with 2.x AppBridge (sdk 2.x host)", + App: AppV1 as any, + AppBridge: AppBridgeV2 as any, + view: sdkV1, + host: sdkV2, + invalidParamsCode: -32602, + // SDK 2.x never emits -32002 on the wire (see wire-compat.test.ts). + handlerThrown32002Code: -32602, + // McpServer 2.x answers an unknown tool with a JSON-RPC error. + unknownToolRejects: true, + }, +]; + +const hostInfo = { name: "TestHost", version: "9.9.9" }; +const appInfo = { name: "TestView", version: "1.2.3" }; +const hostCapabilities = { + openLinks: {}, + serverTools: {}, + serverResources: {}, + logging: {}, + updateModelContext: {}, +}; +const hostContext = { theme: "dark", displayMode: "inline", locale: "en-US" }; + +it("pits 2.x against the published 1.x package, not against itself", () => { + const v1PackageJson = JSON.parse( + readFileSync( + new URL("../../package.json", import.meta.resolve("ext-apps-v1")), + "utf8", + ), + ); + expect(v1PackageJson).toMatchObject({ + name: "@modelcontextprotocol/ext-apps", + version: "1.7.5", + }); + // 1.x classes extend the sdk-1 Protocol; 2.x classes do not. + expect(AppV1.prototype).toBeInstanceOf(ProtocolV1); + expect(AppBridgeV1.prototype).toBeInstanceOf(ProtocolV1); + expect(AppV2.prototype).not.toBeInstanceOf(ProtocolV1); + expect(AppBridgeV2.prototype).not.toBeInstanceOf(ProtocolV1); +}); + +// The tests in each pairing share one connected App/AppBridge fixture and run +// in file order (bun does not randomize); later tests assume earlier ones +// left the pair connected. +describe.each(pairings)("$name", (pairing) => { + const { view, host } = pairing; + let server: any, client: any, bridge: any, app: any; + const events: string[] = []; + const record = (name: string) => (params?: unknown) => { + events.push(`${name}:${JSON.stringify(params ?? null)}`); + }; + const drain = () => events.splice(0); + // Captured inside oninitialized: the bridge must have processed + // ui/initialize before ui/notifications/initialized arrives. + let appVersionAtInitialized: unknown = "not-fired"; + let hostAbortReason: unknown; + + beforeAll(async () => { + server = new host.McpServer({ name: "TestServer", version: "1.0.0" }); + server.registerTool( + "echo", + { inputSchema: z.object({ a: z.number() }) }, + async ({ a }: { a: number }) => ({ + content: [{ type: "text", text: `echo ${a}` }], + }), + ); + server.registerTool("boom", { inputSchema: z.object({}) }, async () => { + throw new Error("tool exploded"); + }); + server.registerResource( + "res", + "test://res", + { mimeType: "text/plain" }, + async (uri: URL) => ({ + contents: [{ uri: uri.href, mimeType: "text/plain", text: "hello" }], + }), + ); + const [clientTransport, serverTransport] = + host.InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + client = new host.Client({ name: "HostOuterClient", version: "1.0.0" }); + await client.connect(clientTransport); + + bridge = new pairing.AppBridge(client, hostInfo, hostCapabilities, { + hostContext, + }); + app = new pairing.App( + appInfo, + { tools: { listChanged: true } }, + { + autoResize: false, + }, + ); + bridge.oninitialized = () => { + appVersionAtInitialized = bridge.getAppVersion(); + }; + for (const name of ["onsizechange", "onloggingmessage"]) { + bridge[name] = record(`bridge.${name}`); + } + for (const name of ["onmessage", "onupdatemodelcontext"]) { + bridge[name] = async (params: unknown) => { + record(`bridge.${name}`)(params); + return {}; + }; + } + bridge.onopenlink = (params: { url: string }, extra: any) => { + record("bridge.onopenlink")(params); + if (params.url !== "https://slow") return {}; + // Hang until the view cancels; sdk 1.x passes `extra.signal`, 2.x `extra.mcpReq.signal`. + const signal: AbortSignal = extra.signal ?? extra.mcpReq.signal; + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + hostAbortReason = signal.reason; + reject(new Error("aborted")); + }); + }); + }; + for (const name of [ + "ontoolinput", + "ontoolresult", + "ontoolcancelled", + "onhostcontextchanged", + ]) { + app[name] = record(`app.${name}`); + } + app.onteardown = async (params: unknown) => { + record("app.onteardown")(params); + return {}; + }; + }); + + afterAll(async () => { + await app?.close().catch(() => {}); + await bridge?.close().catch(() => {}); + await client?.close().catch(() => {}); + await server?.close().catch(() => {}); + }); + + it("completes ui/initialize and delivers initialized in order", async () => { + const [appTransport, bridgeTransport] = createDuplex(); + // Tap the raw ui/initialize result so an additive key on the 2.x side + // (which the getters below would not surface) still fails here. + let initializeResult: unknown; + const send = bridgeTransport.send.bind(bridgeTransport); + bridgeTransport.send = async (message, options) => { + if ("result" in message && "id" in message && message.id === 0) { + initializeResult = message.result; + } + await send(message, options); + }; + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + await flush(); + + expect(app.getHostContext()).toEqual(hostContext); + expect(app.getHostCapabilities()).toEqual(hostCapabilities); + expect(app.getHostVersion()).toEqual(hostInfo); + expect(bridge.getAppCapabilities()).toEqual({ + tools: { listChanged: true }, + }); + expect(appVersionAtInitialized).toEqual(appInfo); + expect(initializeResult).toEqual({ + protocolVersion: "2026-01-26", + hostCapabilities, + hostInfo, + hostContext, + }); + }); + + it("delivers host-to-view notifications", async () => { + bridge.sendToolInput({ arguments: { location: "NYC" } }); + bridge.sendToolResult({ content: [{ type: "text", text: "sunny" }] }); + bridge.sendToolCancelled({ reason: "user" }); + bridge.setHostContext({ theme: "light" }); + await flush(); + + expect(drain()).toEqual([ + 'app.ontoolinput:{"arguments":{"location":"NYC"}}', + 'app.ontoolresult:{"content":[{"type":"text","text":"sunny"}]}', + 'app.ontoolcancelled:{"reason":"user"}', + 'app.onhostcontextchanged:{"theme":"light"}', + ]); + expect(app.getHostContext()).toEqual({ ...hostContext, theme: "light" }); + }); + + it("round-trips view-to-host requests and notifications", async () => { + const message = { role: "user", content: [{ type: "text", text: "hi" }] }; + expect(await app.sendMessage(message)).toEqual({}); + expect(await app.openLink({ url: "https://example.com" })).toEqual({}); + expect(await app.requestDisplayMode({ mode: "fullscreen" })).toEqual({ + mode: "inline", + }); + bridge.onrequestdisplaymode = async (params: { mode: string }) => params; + expect(await app.requestDisplayMode({ mode: "fullscreen" })).toEqual({ + mode: "fullscreen", + }); + const context = { content: [{ type: "text", text: "ctx" }] }; + expect(await app.updateModelContext(context)).toEqual({}); + await app.sendLog({ level: "info", data: { m: "hello" } }); + await app.sendSizeChanged({ width: 320, height: 240 }); + await flush(); + + expect(drain()).toEqual([ + `bridge.onmessage:${JSON.stringify(message)}`, + 'bridge.onopenlink:{"url":"https://example.com"}', + `bridge.onupdatemodelcontext:${JSON.stringify(context)}`, + 'bridge.onloggingmessage:{"level":"info","data":{"m":"hello"}}', + 'bridge.onsizechange:{"width":320,"height":240}', + ]); + }); + + it("proxies tools/call and resources/read to the real MCP server", async () => { + expect( + await app.callServerTool({ name: "echo", arguments: { a: 1 } }), + ).toEqual({ content: [{ type: "text", text: "echo 1" }] }); + expect( + await app.callServerTool({ name: "boom", arguments: {} }), + ).toMatchObject({ + isError: true, + content: [{ type: "text", text: "tool exploded" }], + }); + const unknown = app.callServerTool({ name: "nope", arguments: {} }); + if (pairing.unknownToolRejects) { + await expect(unknown).rejects.toMatchObject({ code: -32602 }); + } else { + expect(await unknown).toMatchObject({ isError: true }); + } + expect(await app.readServerResource({ uri: "test://res" })).toEqual({ + contents: [{ uri: "test://res", mimeType: "text/plain", text: "hello" }], + }); + }); + + it("propagates JSON-RPC errors with the expected codes", async () => { + const raw = (method: string, params: unknown) => + app.request({ method, params }, view.EmptyResultSchema); + await expect(raw("ui/does-not-exist", {})).rejects.toMatchObject({ + code: -32601, + }); + await expect(raw("ui/open-link", { url: 42 })).rejects.toMatchObject({ + code: pairing.invalidParamsCode, + }); + bridge.onreadresource = async () => { + throw host.error(-32002, "Resource not found (host-thrown -32002)"); + }; + const notFound = app.readServerResource({ uri: "test://missing" }); + await expect(notFound).rejects.toMatchObject({ + code: pairing.handlerThrown32002Code, + message: expect.stringContaining( + "Resource not found (host-thrown -32002)", + ), + }); + }); + + it("cancels an in-flight request via AbortSignal on both ends", async () => { + const controller = new AbortController(); + const pending = app.openLink( + { url: "https://slow" }, + { signal: controller.signal }, + ); + await flush(); + controller.abort("user-cancelled"); + await expect(pending).rejects.toMatchObject({ + message: expect.stringContaining("user-cancelled"), + }); + await flush(); + expect(String(hostAbortReason)).toContain("user-cancelled"); + drain(); + }); + + it("tears down, then closes both ends", async () => { + expect(await bridge.teardownResource({})).toEqual({}); + expect(drain()).toEqual(["app.onteardown:{}"]); + + const closed: string[] = []; + app.onclose = () => closed.push("app"); + bridge.onclose = () => closed.push("bridge"); + await app.close(); + await flush(); + expect(closed.sort()).toEqual(["app", "bridge"]); + await expect(app.openLink({ url: "https://after-close" })).rejects.toThrow( + "Not connected", + ); + }); +});