Add cross-version interop test against the published ext-apps 1.7.5 (#770)
Runs the published 1.7.5 App and AppBridge against the 2.x build over an in-memory transport in both pairings, via an npm alias devDependency and sdk 1.30. Complements wire-compat.test.ts: this one runs the real 1.x code and is the only test that catches a coordinated 2.x wire rename.
This commit is contained in:
commit
12aa50b623
3 changed files with 579 additions and 1 deletions
198
package-lock.json
generated
198
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
379
src/interop-v1.test.ts
Normal file
379
src/interop-v1.test.ts
Normal file
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue