Skip to content
325 changes: 324 additions & 1 deletion src/app-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js";
import { EmptyResultSchema } from "@modelcontextprotocol/sdk/types.js";
import {
EmptyResultSchema,
CallToolResultSchema,
ListToolsResultSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod/v4";

import { App } from "./app";
import { AppBridge, type McpUiHostCapabilities } from "./app-bridge";
Expand Down Expand Up @@ -294,4 +299,322 @@ describe("App <-> AppBridge integration", () => {
expect(result).toEqual({});
});
});

describe("App tool registration", () => {
beforeEach(async () => {
await bridge.connect(bridgeTransport);
});

it("registerTool creates a registered tool", async () => {
const InputSchema = z.object({ name: z.string() }) as any;
const OutputSchema = z.object({ greeting: z.string() }) as any;

const tool = app.registerTool(
"greet",
{
title: "Greet User",
description: "Greets a user by name",
inputSchema: InputSchema,
outputSchema: OutputSchema,
},
async (args: any) => ({
content: [{ type: "text" as const, text: `Hello, ${args.name}!` }],
structuredContent: { greeting: `Hello, ${args.name}!` },
}),
);

expect(tool.title).toBe("Greet User");
expect(tool.description).toBe("Greets a user by name");
expect(tool.enabled).toBe(true);
});

it("registered tool can be enabled and disabled", async () => {
await app.connect(appTransport);

const tool = app.registerTool(
"test-tool",
{
description: "Test tool",
},
async (_extra: any) => ({ content: [] }),
);

expect(tool.enabled).toBe(true);

tool.disable();
expect(tool.enabled).toBe(false);

tool.enable();
expect(tool.enabled).toBe(true);
});

it("registered tool can be updated", async () => {
await app.connect(appTransport);

const tool = app.registerTool(
"test-tool",
{
description: "Original description",
},
async (_extra: any) => ({ content: [] }),
);

expect(tool.description).toBe("Original description");

tool.update({ description: "Updated description" });
expect(tool.description).toBe("Updated description");
});

it("registered tool can be removed", async () => {
await app.connect(appTransport);

const tool = app.registerTool(
"test-tool",
{
description: "Test tool",
},
async (_extra: any) => ({ content: [] }),
);

tool.remove();
// Tool should no longer be registered (internal check)
});

it("tool throws error when disabled and called", async () => {
await app.connect(appTransport);

const tool = app.registerTool(
"test-tool",
{
description: "Test tool",
},
async (_extra: any) => ({ content: [] }),
);

tool.disable();

const mockExtra = {
signal: new AbortController().signal,
requestId: "test",
sendNotification: async () => {},
sendRequest: async () => ({}),
} as any;

await expect((tool.callback as any)(mockExtra)).rejects.toThrow(
"Tool test-tool is disabled",
);
});

it("tool validates input schema", async () => {
const InputSchema = z.object({ name: z.string() }) as any;

const tool = app.registerTool(
"greet",
{
inputSchema: InputSchema,
},
async (args: any) => ({
content: [{ type: "text" as const, text: `Hello, ${args.name}!` }],
}),
);

// Create a mock RequestHandlerExtra
const mockExtra = {
signal: new AbortController().signal,
requestId: "test",
sendNotification: async () => {},
sendRequest: async () => ({}),
} as any;

// Valid input should work
await expect(
(tool.callback as any)({ name: "Alice" }, mockExtra),
).resolves.toBeDefined();

// Invalid input should fail
await expect(
(tool.callback as any)({ invalid: "field" }, mockExtra),
).rejects.toThrow("Invalid input for tool greet");
});

it("tool validates output schema", async () => {
const OutputSchema = z.object({ greeting: z.string() }) as any;

const tool = app.registerTool(
"greet",
{
outputSchema: OutputSchema,
},
async (_extra: any) => ({
content: [{ type: "text" as const, text: "Hello!" }],
structuredContent: { greeting: "Hello!" },
}),
);

// Create a mock RequestHandlerExtra
const mockExtra = {
signal: new AbortController().signal,
requestId: "test",
sendNotification: async () => {},
sendRequest: async () => ({}),
} as any;

// Valid output should work
await expect((tool.callback as any)(mockExtra)).resolves.toBeDefined();
});

it("tool enable/disable/update/remove trigger sendToolListChanged", async () => {
await app.connect(appTransport);

const tool = app.registerTool(
"test-tool",
{
description: "Test tool",
},
async (_extra: any) => ({ content: [] }),
);

// The methods should not throw when connected
expect(() => tool.disable()).not.toThrow();
expect(() => tool.enable()).not.toThrow();
expect(() => tool.update({ description: "Updated" })).not.toThrow();
expect(() => tool.remove()).not.toThrow();
});
});

describe("AppBridge -> App tool requests", () => {
beforeEach(async () => {
await bridge.connect(bridgeTransport);
});

it("bridge.sendCallTool calls app.oncalltool handler", async () => {
// App needs tool capabilities to handle tool calls
const appCapabilities = { tools: {} };
app = new App(testAppInfo, appCapabilities, { autoResize: false });

const receivedCalls: unknown[] = [];

app.oncalltool = async (params) => {
receivedCalls.push(params);
return {
content: [{ type: "text", text: `Executed: ${params.name}` }],
};
};

await app.connect(appTransport);

const result = await bridge.sendCallTool({
name: "test-tool",
arguments: { foo: "bar" },
});

expect(receivedCalls).toHaveLength(1);
expect(receivedCalls[0]).toMatchObject({
name: "test-tool",
arguments: { foo: "bar" },
});
expect(result.content).toEqual([
{ type: "text", text: "Executed: test-tool" },
]);
});

it("bridge.sendListTools calls app.onlisttools handler", async () => {
// App needs tool capabilities to handle tool list requests
const appCapabilities = { tools: {} };
app = new App(testAppInfo, appCapabilities, { autoResize: false });

const receivedCalls: unknown[] = [];

app.onlisttools = async (params, extra) => {
receivedCalls.push(params);
return {
tools: [
{
name: "tool1",
description: "First tool",
inputSchema: { type: "object", properties: {} },
},
{
name: "tool2",
description: "Second tool",
inputSchema: { type: "object", properties: {} },
},
{
name: "tool3",
description: "Third tool",
inputSchema: { type: "object", properties: {} },
},
],
};
};

await app.connect(appTransport);

const result = await bridge.sendListTools({});

expect(receivedCalls).toHaveLength(1);
expect(result.tools).toHaveLength(3);
expect(result.tools[0].name).toBe("tool1");
expect(result.tools[1].name).toBe("tool2");
expect(result.tools[2].name).toBe("tool3");
});
});

describe("App tool capabilities", () => {
it("App with tool capabilities can handle tool calls", async () => {
const appCapabilities = { tools: { listChanged: true } };
app = new App(testAppInfo, appCapabilities, { autoResize: false });

const receivedCalls: unknown[] = [];
app.oncalltool = async (params) => {
receivedCalls.push(params);
return {
content: [{ type: "text", text: "Success" }],
};
};

await bridge.connect(bridgeTransport);
await app.connect(appTransport);

await bridge.sendCallTool({
name: "test-tool",
arguments: {},
});

expect(receivedCalls).toHaveLength(1);
});

it("registered tool is invoked via oncalltool", async () => {
const appCapabilities = { tools: { listChanged: true } };
app = new App(testAppInfo, appCapabilities, { autoResize: false });

const tool = app.registerTool(
"greet",
{
description: "Greets user",
inputSchema: z.object({ name: z.string() }) as any,
},
async (args: any) => ({
content: [{ type: "text" as const, text: `Hello, ${args.name}!` }],
}),
);

app.oncalltool = async (params, extra) => {
if (params.name === "greet") {
return await (tool.callback as any)(params.arguments || {}, extra);
}
throw new Error(`Unknown tool: ${params.name}`);
};

await bridge.connect(bridgeTransport);
await app.connect(appTransport);

const result = await bridge.sendCallTool({
name: "greet",
arguments: { name: "Alice" },
});

expect(result.content).toEqual([{ type: "text", text: "Hello, Alice!" }]);
});
});
});
20 changes: 20 additions & 0 deletions src/app-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ZodLiteral, ZodObject } from "zod/v4";

import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import {
CallToolRequest,
CallToolRequestSchema,
CallToolResultSchema,
Implementation,
Expand All @@ -12,6 +13,9 @@ import {
ListResourcesResultSchema,
ListResourceTemplatesRequestSchema,
ListResourceTemplatesResultSchema,
ListToolsRequest,
ListToolsRequestSchema,
ListToolsResultSchema,
LoggingMessageNotification,
LoggingMessageNotificationSchema,
Notification,
Expand Down Expand Up @@ -796,6 +800,22 @@ export class AppBridge extends Protocol<Request, Notification, Result> {
});
}

sendCallTool(params: CallToolRequest["params"], options?: RequestOptions) {
return this.request(
{ method: "tools/call", params },
CallToolResultSchema,
options,
);
}

sendListTools(params: ListToolsRequest["params"], options?: RequestOptions) {
return this.request(
{ method: "tools/list", params },
ListToolsResultSchema,
options,
);
}

/**
* Connect to the Guest UI via transport and set up message forwarding.
*
Expand Down
Loading