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",
|
"@boneskull/typedoc-plugin-mermaid": "^0.2.0",
|
||||||
"@modelcontextprotocol/client": "2.0.0",
|
"@modelcontextprotocol/client": "2.0.0",
|
||||||
"@modelcontextprotocol/core": "2.0.0",
|
"@modelcontextprotocol/core": "2.0.0",
|
||||||
|
"@modelcontextprotocol/sdk": "1.30.0",
|
||||||
"@modelcontextprotocol/server": "2.0.0",
|
"@modelcontextprotocol/server": "2.0.0",
|
||||||
"@playwright/test": "1.57.0",
|
"@playwright/test": "1.57.0",
|
||||||
"@types/bun": "^1.3.2",
|
"@types/bun": "^1.3.2",
|
||||||
|
|
@ -33,6 +34,7 @@
|
||||||
"electron-to-chromium": "1.5.267",
|
"electron-to-chromium": "1.5.267",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"ext-apps-v1": "npm:@modelcontextprotocol/ext-apps@1.7.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"nodemon": "^3.1.0",
|
"nodemon": "^3.1.0",
|
||||||
"playwright": "1.57.0",
|
"playwright": "1.57.0",
|
||||||
|
|
@ -2756,6 +2758,47 @@
|
||||||
"resolved": "examples/quickstart",
|
"resolved": "examples/quickstart",
|
||||||
"link": true
|
"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": {
|
"node_modules/@modelcontextprotocol/server": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz",
|
||||||
|
|
@ -4375,6 +4418,41 @@
|
||||||
"node": ">=0.4.0"
|
"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": {
|
"node_modules/ansi-escapes": {
|
||||||
"version": "4.3.2",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
||||||
|
|
@ -5937,6 +6015,81 @@
|
||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/fflate": {
|
||||||
"version": "0.8.2",
|
"version": "0.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz",
|
||||||
"integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==",
|
"integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
|
|
@ -6399,6 +6551,16 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
|
@ -6586,6 +6748,20 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
|
|
@ -7578,6 +7754,16 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
|
@ -9716,6 +9902,16 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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/"
|
"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",
|
"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": {
|
"devDependencies": {
|
||||||
"@boneskull/typedoc-plugin-mermaid": "^0.2.0",
|
"@boneskull/typedoc-plugin-mermaid": "^0.2.0",
|
||||||
"@modelcontextprotocol/client": "2.0.0",
|
"@modelcontextprotocol/client": "2.0.0",
|
||||||
"@modelcontextprotocol/core": "2.0.0",
|
"@modelcontextprotocol/core": "2.0.0",
|
||||||
|
"@modelcontextprotocol/sdk": "1.30.0",
|
||||||
"@modelcontextprotocol/server": "2.0.0",
|
"@modelcontextprotocol/server": "2.0.0",
|
||||||
"@playwright/test": "1.57.0",
|
"@playwright/test": "1.57.0",
|
||||||
"@types/bun": "^1.3.2",
|
"@types/bun": "^1.3.2",
|
||||||
|
|
@ -95,6 +97,7 @@
|
||||||
"electron-to-chromium": "1.5.267",
|
"electron-to-chromium": "1.5.267",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"ext-apps-v1": "npm:@modelcontextprotocol/ext-apps@1.7.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"nodemon": "^3.1.0",
|
"nodemon": "^3.1.0",
|
||||||
"playwright": "1.57.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