Add wire compatibility tests against captured 1.x traffic
Replay JSON-RPC messages recorded from ext-apps 1.7.x (sdk 1.x key order and ids) through the 2.x App and AppBridge over a raw in-memory transport and assert exactly what 2.x emits: the ui/initialize handshake, params-less ui/notifications/initialized, tools/call in both directions, and the -32002 to -32602 re-encoding on the host side. Also check that AppBridge never sends an MCP initialize toward the View and that the outer-client proxy works with a default (non-legacy) 2.x Client/Server pair.
This commit is contained in:
parent
c5b2543115
commit
ea6b89c9e2
1 changed files with 576 additions and 0 deletions
576
src/wire-compat.test.ts
Normal file
576
src/wire-compat.test.ts
Normal file
|
|
@ -0,0 +1,576 @@
|
|||
/**
|
||||
* Cross-version wire compatibility.
|
||||
*
|
||||
* Replays JSON-RPC messages captured from ext-apps 1.7.x (built on
|
||||
* `@modelcontextprotocol/sdk` 1.x) through the 2.x `App` and `AppBridge` over
|
||||
* a raw in-memory transport, and asserts what 2.x emits back. The MCP Apps
|
||||
* wire protocol is meant to be unchanged across the major bump; the few
|
||||
* host-side error deltas are pinned here so they stay deliberate.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from "bun:test";
|
||||
import {
|
||||
Client,
|
||||
InMemoryTransport,
|
||||
ProtocolError,
|
||||
type JSONRPCMessage,
|
||||
type Transport,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { McpServer } from "@modelcontextprotocol/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { App } from "./app.js";
|
||||
import {
|
||||
AppBridge,
|
||||
type McpUiHostCapabilities,
|
||||
type McpUiHostContext,
|
||||
} from "./app-bridge.js";
|
||||
import { LATEST_PROTOCOL_VERSION } from "./types.js";
|
||||
|
||||
/** Wait for pending microtasks/timers to complete. */
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
/**
|
||||
* One end of a raw in-memory channel. The 2.x side gets `transport`; the test
|
||||
* plays the role of the 1.x peer by calling `inject()` with captured JSON and
|
||||
* reading `sent` to see exactly what 2.x put on the wire.
|
||||
*/
|
||||
function createRawChannel() {
|
||||
const sent: JSONRPCMessage[] = [];
|
||||
let closed = false;
|
||||
const transport: Transport = {
|
||||
async start() {},
|
||||
async send(message) {
|
||||
// Serialize like a real transport would so we assert on plain JSON.
|
||||
sent.push(JSON.parse(JSON.stringify(message)));
|
||||
},
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
transport.onclose?.();
|
||||
},
|
||||
};
|
||||
return {
|
||||
transport,
|
||||
sent,
|
||||
/** Deliver a raw message (as the 1.x peer would have sent it). */
|
||||
inject(message: unknown) {
|
||||
transport.onmessage?.(message as JSONRPCMessage);
|
||||
},
|
||||
/** Messages sent after the given index. */
|
||||
since(index: number) {
|
||||
return sent.slice(index);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const hostInfo = { name: "TestHost", version: "9.9.9" };
|
||||
const hostCapabilities: McpUiHostCapabilities = {
|
||||
openLinks: {},
|
||||
serverTools: {},
|
||||
serverResources: {},
|
||||
logging: {},
|
||||
updateModelContext: {},
|
||||
};
|
||||
const hostContext: McpUiHostContext = {
|
||||
theme: "dark",
|
||||
displayMode: "inline",
|
||||
locale: "en-US",
|
||||
};
|
||||
|
||||
/** Captured from ext-apps 1.7.x View (sdk 1.x key order, numeric ids from 0). */
|
||||
const v1View = {
|
||||
initialize: {
|
||||
method: "ui/initialize",
|
||||
params: {
|
||||
appCapabilities: { tools: { listChanged: true } },
|
||||
appInfo: { name: "TestView", version: "1.2.3" },
|
||||
protocolVersion: "2026-01-26",
|
||||
},
|
||||
jsonrpc: "2.0",
|
||||
id: 0,
|
||||
},
|
||||
initialized: { method: "ui/notifications/initialized", jsonrpc: "2.0" },
|
||||
callTool: {
|
||||
method: "tools/call",
|
||||
params: { name: "echo", arguments: { a: 1 }, _meta: { progressToken: 6 } },
|
||||
jsonrpc: "2.0",
|
||||
id: 6,
|
||||
},
|
||||
readResource: {
|
||||
method: "resources/read",
|
||||
params: { uri: "test://x" },
|
||||
jsonrpc: "2.0",
|
||||
id: 12,
|
||||
},
|
||||
toolsListResult: (id: number) => ({
|
||||
result: {
|
||||
tools: [
|
||||
{
|
||||
name: "viewtool",
|
||||
description: "view tool",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
}),
|
||||
};
|
||||
|
||||
/** Captured from ext-apps 1.7.x AppBridge (sdk 1.x). */
|
||||
const v1Host = {
|
||||
initializeResult: (id: number) => ({
|
||||
result: {
|
||||
protocolVersion: "2026-01-26",
|
||||
hostCapabilities,
|
||||
hostInfo,
|
||||
hostContext,
|
||||
},
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
}),
|
||||
toolInput: {
|
||||
method: "ui/notifications/tool-input",
|
||||
params: { arguments: { location: "NYC" } },
|
||||
jsonrpc: "2.0",
|
||||
},
|
||||
callToolResult: (id: number) => ({
|
||||
result: { content: [{ type: "text", text: "echo 1" }] },
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
}),
|
||||
/** McpServer 1.x reports an unknown tool as an isError result, not an error. */
|
||||
callToolUnknownResult: (id: number) => ({
|
||||
result: {
|
||||
content: [
|
||||
{ type: "text", text: "MCP error -32602: Tool nope not found" },
|
||||
],
|
||||
isError: true,
|
||||
},
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
}),
|
||||
resourceNotFoundError: (id: number) => ({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: {
|
||||
code: -32002,
|
||||
message: "MCP error -32002: Resource not found (host-thrown -32002)",
|
||||
},
|
||||
}),
|
||||
toolsList: { method: "tools/list", jsonrpc: "2.0", id: 0 },
|
||||
callViewTool: {
|
||||
method: "tools/call",
|
||||
params: { name: "viewtool", arguments: { x: 7 } },
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
},
|
||||
};
|
||||
|
||||
describe("wire compatibility: 2.x AppBridge with a 1.x View", () => {
|
||||
let bridge: AppBridge;
|
||||
|
||||
afterEach(async () => {
|
||||
await bridge?.close().catch(() => {});
|
||||
});
|
||||
|
||||
async function connectBridge() {
|
||||
const channel = createRawChannel();
|
||||
bridge = new AppBridge(null, hostInfo, hostCapabilities, { hostContext });
|
||||
await bridge.connect(channel.transport);
|
||||
return channel;
|
||||
}
|
||||
|
||||
async function handshake() {
|
||||
const channel = await connectBridge();
|
||||
let initialized = 0;
|
||||
bridge.oninitialized = () => {
|
||||
initialized++;
|
||||
};
|
||||
channel.inject(v1View.initialize);
|
||||
await flush();
|
||||
channel.inject(v1View.initialized);
|
||||
await flush();
|
||||
return { channel, initialized: () => initialized };
|
||||
}
|
||||
|
||||
it("sends nothing on connect: no MCP initialize toward the View", async () => {
|
||||
const channel = await connectBridge();
|
||||
await flush();
|
||||
expect(channel.sent).toEqual([]);
|
||||
});
|
||||
|
||||
it("answers a 1.x ui/initialize with the 1.x result shape and same id", async () => {
|
||||
const channel = await connectBridge();
|
||||
channel.inject(v1View.initialize);
|
||||
await flush();
|
||||
|
||||
expect(channel.sent).toEqual([
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 0,
|
||||
result: {
|
||||
protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
hostCapabilities,
|
||||
hostInfo,
|
||||
hostContext,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(bridge.getAppCapabilities()).toEqual({
|
||||
tools: { listChanged: true },
|
||||
});
|
||||
expect(bridge.getAppVersion()).toEqual({
|
||||
name: "TestView",
|
||||
version: "1.2.3",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts ui/notifications/initialized without params", async () => {
|
||||
const { channel, initialized } = await handshake();
|
||||
expect(initialized()).toBe(1);
|
||||
// Only the initialize response went out; the notification gets no reply
|
||||
// and the bridge never starts an MCP handshake of its own.
|
||||
expect(channel.sent).toHaveLength(1);
|
||||
expect(channel.sent.some((m) => "method" in m)).toBe(false);
|
||||
});
|
||||
|
||||
it("answers a 1.x tools/call request through oncalltool", async () => {
|
||||
const { channel } = await handshake();
|
||||
bridge.oncalltool = async (params) => ({
|
||||
content: [{ type: "text", text: `echo ${params.arguments?.a}` }],
|
||||
});
|
||||
const mark = channel.sent.length;
|
||||
channel.inject(v1View.callTool);
|
||||
await flush();
|
||||
|
||||
expect(channel.since(mark)).toEqual([
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 6,
|
||||
result: { content: [{ type: "text", text: "echo 1" }] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-encodes a handler-thrown -32002 as -32602 without the MCP error prefix", async () => {
|
||||
// Documented host-side delta: SDK 2.x never emits -32002 on the wire.
|
||||
const { channel } = await handshake();
|
||||
bridge.onreadresource = async () => {
|
||||
throw new ProtocolError(
|
||||
-32002,
|
||||
"Resource not found (host-thrown -32002)",
|
||||
);
|
||||
};
|
||||
const mark = channel.sent.length;
|
||||
channel.inject(v1View.readResource);
|
||||
await flush();
|
||||
|
||||
const [reply] = channel.since(mark);
|
||||
expect(reply).toMatchObject({
|
||||
jsonrpc: "2.0",
|
||||
id: 12,
|
||||
error: { code: -32602 },
|
||||
});
|
||||
const message = (reply as { error: { message: string } }).error.message;
|
||||
expect(message).toBe("Resource not found (host-thrown -32002)");
|
||||
expect(message).not.toMatch(/^MCP error/);
|
||||
});
|
||||
|
||||
it("rejects invalid params on a ui/* request with -32602", async () => {
|
||||
const { channel } = await handshake();
|
||||
bridge.onopenlink = async () => ({});
|
||||
const mark = channel.sent.length;
|
||||
channel.inject({
|
||||
method: "ui/open-link",
|
||||
params: { url: 42 },
|
||||
jsonrpc: "2.0",
|
||||
id: 14,
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(channel.since(mark)).toHaveLength(1);
|
||||
expect(channel.since(mark)[0]).toMatchObject({
|
||||
jsonrpc: "2.0",
|
||||
id: 14,
|
||||
error: { code: -32602 },
|
||||
});
|
||||
});
|
||||
|
||||
it("lists and calls View tools with 1.x-shaped responses", async () => {
|
||||
const { channel } = await handshake();
|
||||
const mark = channel.sent.length;
|
||||
const listPromise = bridge.listTools({});
|
||||
await flush();
|
||||
|
||||
const [listRequest] = channel.since(mark) as Array<{
|
||||
id: number;
|
||||
method: string;
|
||||
}>;
|
||||
expect(listRequest).toMatchObject({ jsonrpc: "2.0", method: "tools/list" });
|
||||
channel.inject(v1View.toolsListResult(listRequest.id));
|
||||
const list = await listPromise;
|
||||
expect(list.tools.map((t) => t.name)).toEqual(["viewtool"]);
|
||||
|
||||
const callPromise = bridge.callTool({
|
||||
name: "viewtool",
|
||||
arguments: { x: 7 },
|
||||
});
|
||||
await flush();
|
||||
const [callRequest] = channel.since(mark + 1) as Array<{ id: number }>;
|
||||
expect(callRequest).toMatchObject({
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
params: { name: "viewtool", arguments: { x: 7 } },
|
||||
});
|
||||
channel.inject({
|
||||
result: { content: [{ type: "text", text: "view got 7" }] },
|
||||
jsonrpc: "2.0",
|
||||
id: callRequest.id,
|
||||
});
|
||||
expect(await callPromise).toEqual({
|
||||
content: [{ type: "text", text: "view got 7" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("only ever emits jsonrpc 2.0 messages, never an initialize request", async () => {
|
||||
const { channel } = await handshake();
|
||||
bridge.oncalltool = async () => ({ content: [] });
|
||||
channel.inject(v1View.callTool);
|
||||
bridge.sendToolInput({ arguments: { q: "x" } });
|
||||
bridge.sendToolResult({ content: [] });
|
||||
await flush();
|
||||
|
||||
for (const message of channel.sent) {
|
||||
expect(message.jsonrpc).toBe("2.0");
|
||||
}
|
||||
const methods = channel.sent
|
||||
.filter((m): m is JSONRPCMessage & { method: string } => "method" in m)
|
||||
.map((m) => m.method);
|
||||
expect(methods).toEqual([
|
||||
"ui/notifications/tool-input",
|
||||
"ui/notifications/tool-result",
|
||||
]);
|
||||
expect(methods).not.toContain("initialize");
|
||||
expect(methods).not.toContain("notifications/initialized");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wire compatibility: 2.x App with a 1.x host", () => {
|
||||
let app: App;
|
||||
|
||||
afterEach(async () => {
|
||||
await app?.close().catch(() => {});
|
||||
});
|
||||
|
||||
/** Connect the App and answer its ui/initialize the way a 1.x host did. */
|
||||
async function handshake(
|
||||
capabilities: ConstructorParameters<typeof App>[1] = {},
|
||||
setup: (app: App) => void = () => {},
|
||||
) {
|
||||
const channel = createRawChannel();
|
||||
app = new App({ name: "TestView", version: "1.2.3" }, capabilities, {
|
||||
autoResize: false,
|
||||
});
|
||||
setup(app);
|
||||
const connected = app.connect(channel.transport);
|
||||
await flush();
|
||||
|
||||
const [initialize] = channel.sent as Array<{ id: number }>;
|
||||
channel.inject(v1Host.initializeResult(initialize.id));
|
||||
await connected;
|
||||
await flush();
|
||||
return { channel, initialize };
|
||||
}
|
||||
|
||||
it("sends a 1.x-compatible ui/initialize and ui/notifications/initialized", async () => {
|
||||
const { channel } = await handshake({ tools: { listChanged: true } });
|
||||
|
||||
expect(channel.sent).toEqual([
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 0,
|
||||
method: "ui/initialize",
|
||||
params: {
|
||||
protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
appInfo: { name: "TestView", version: "1.2.3" },
|
||||
appCapabilities: { tools: { listChanged: true } },
|
||||
},
|
||||
},
|
||||
{ jsonrpc: "2.0", method: "ui/notifications/initialized" },
|
||||
]);
|
||||
expect(app.getHostContext()).toEqual(hostContext);
|
||||
expect(app.getHostCapabilities()).toEqual(hostCapabilities);
|
||||
expect(app.getHostVersion()).toEqual(hostInfo);
|
||||
});
|
||||
|
||||
it("delivers a 1.x notification with sdk-1 key order to ontoolinput", async () => {
|
||||
const received: unknown[] = [];
|
||||
const { channel } = await handshake({}, (app) => {
|
||||
app.ontoolinput = (params) => {
|
||||
received.push(params);
|
||||
};
|
||||
});
|
||||
channel.inject(v1Host.toolInput);
|
||||
await flush();
|
||||
expect(received).toEqual([{ arguments: { location: "NYC" } }]);
|
||||
});
|
||||
|
||||
it("resolves callServerTool from a 1.x tools/call response", async () => {
|
||||
const { channel } = await handshake();
|
||||
const mark = channel.sent.length;
|
||||
const promise = app.callServerTool({ name: "echo", arguments: { a: 1 } });
|
||||
await flush();
|
||||
|
||||
const [request] = channel.since(mark) as Array<{ id: number }>;
|
||||
expect(request).toMatchObject({
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "echo",
|
||||
arguments: { a: 1 },
|
||||
_meta: { progressToken: request.id },
|
||||
},
|
||||
});
|
||||
channel.inject(v1Host.callToolResult(request.id));
|
||||
expect(await promise).toEqual({
|
||||
content: [{ type: "text", text: "echo 1" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through a 1.x isError tool result unchanged", async () => {
|
||||
const { channel } = await handshake();
|
||||
const mark = channel.sent.length;
|
||||
const promise = app.callServerTool({ name: "nope", arguments: {} });
|
||||
await flush();
|
||||
const [request] = channel.since(mark) as Array<{ id: number }>;
|
||||
channel.inject(v1Host.callToolUnknownResult(request.id));
|
||||
expect(await promise).toEqual({
|
||||
content: [
|
||||
{ type: "text", text: "MCP error -32602: Tool nope not found" },
|
||||
],
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a 1.x -32002 error as a ProtocolError with code -32002", async () => {
|
||||
const { channel } = await handshake();
|
||||
const mark = channel.sent.length;
|
||||
const promise = app.readServerResource({ uri: "test://x" });
|
||||
await flush();
|
||||
const [request] = channel.since(mark) as Array<{ id: number }>;
|
||||
channel.inject(v1Host.resourceNotFoundError(request.id));
|
||||
|
||||
const error = await promise.then(
|
||||
() => undefined,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(error).toBeInstanceOf(ProtocolError);
|
||||
expect((error as ProtocolError).code).toBe(-32002);
|
||||
expect((error as ProtocolError).message).toContain(
|
||||
"Resource not found (host-thrown -32002)",
|
||||
);
|
||||
});
|
||||
|
||||
it("answers 1.x tools/list (no params) and tools/call requests from the host", async () => {
|
||||
const { channel } = await handshake({ tools: { listChanged: true } });
|
||||
app.registerTool(
|
||||
"viewtool",
|
||||
{ description: "view tool", inputSchema: z.object({ x: z.number() }) },
|
||||
async ({ x }) => ({
|
||||
content: [{ type: "text", text: `view got ${x}` }],
|
||||
}),
|
||||
);
|
||||
await flush();
|
||||
const mark = channel.sent.length;
|
||||
|
||||
channel.inject(v1Host.toolsList);
|
||||
await flush();
|
||||
const [listReply] = channel.since(mark) as unknown as Array<{
|
||||
result: { tools: Array<{ name: string; inputSchema: unknown }> };
|
||||
}>;
|
||||
expect(listReply).toMatchObject({ jsonrpc: "2.0", id: 0 });
|
||||
expect(listReply.result.tools).toHaveLength(1);
|
||||
expect(listReply.result.tools[0]).toMatchObject({
|
||||
name: "viewtool",
|
||||
description: "view tool",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" } },
|
||||
required: ["x"],
|
||||
},
|
||||
});
|
||||
|
||||
channel.inject(v1Host.callViewTool);
|
||||
await flush();
|
||||
expect(channel.since(mark + 1)).toEqual([
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: { content: [{ type: "text", text: "view got 7" }] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppBridge proxy over a default (modern era) 2.x Client/Server", () => {
|
||||
let client: Client;
|
||||
let server: McpServer;
|
||||
let app: App;
|
||||
let bridge: AppBridge;
|
||||
|
||||
afterEach(async () => {
|
||||
await app?.close().catch(() => {});
|
||||
await bridge?.close().catch(() => {});
|
||||
await client?.close().catch(() => {});
|
||||
await server?.close().catch(() => {});
|
||||
});
|
||||
|
||||
it("forwards tools/call and tools/list_changed with default negotiation", async () => {
|
||||
server = new McpServer({ name: "ActualServer", version: "1.0.0" });
|
||||
server.registerTool(
|
||||
"echo",
|
||||
{ inputSchema: z.object({ a: z.number() }) },
|
||||
async ({ a }) => ({ content: [{ type: "text", text: `echo ${a}` }] }),
|
||||
);
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
|
||||
client = new Client({ name: "HostOuterClient", version: "1.0.0" });
|
||||
await client.connect(clientTransport);
|
||||
expect(client.getServerCapabilities()?.tools).toEqual({
|
||||
listChanged: true,
|
||||
});
|
||||
|
||||
bridge = new AppBridge(client, hostInfo, hostCapabilities, {
|
||||
hostContext,
|
||||
});
|
||||
app = new App(
|
||||
{ name: "TestView", version: "1.2.3" },
|
||||
{},
|
||||
{
|
||||
autoResize: false,
|
||||
},
|
||||
);
|
||||
const listChanged: string[] = [];
|
||||
app.setNotificationHandler("notifications/tools/list_changed", () => {
|
||||
listChanged.push("tools");
|
||||
});
|
||||
const [appTransport, bridgeTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await bridge.connect(bridgeTransport);
|
||||
await app.connect(appTransport);
|
||||
|
||||
expect(
|
||||
await app.callServerTool({ name: "echo", arguments: { a: 1 } }),
|
||||
).toMatchObject({ content: [{ type: "text", text: "echo 1" }] });
|
||||
|
||||
server.sendToolListChanged();
|
||||
await flush();
|
||||
expect(listChanged).toEqual(["tools"]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue