Skip to content

Commit 53ce846

Browse files
ochafikclaude
andcommitted
feat: Add tool registration and bidirectional tool support
This PR adds comprehensive tool support for MCP Apps, enabling apps to register their own tools and handle tool calls from the host. ## Changes ### App (Guest UI) side: - Add `registerTool()` method for registering tools with input/output schemas - Add `oncalltool` setter for handling tool call requests from host - Add `onlisttools` setter for handling tool list requests from host - Add `sendToolListChanged()` for notifying host of tool updates - Registered tools support enable/disable/update/remove operations ### AppBridge (Host) side: - Add `sendCallTool()` method for calling tools on the app - Add `sendListTools()` method for listing available app tools - Fix: Use correct ListToolsResultSchema (was ListToolsRequestSchema) ### Tests: - Add comprehensive tests for tool registration lifecycle - Add tests for input/output schema validation - Add tests for bidirectional tool call communication - Add tests for tool list change notifications - All 27 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent bb24318 commit 53ce846

File tree

3 files changed

+467
-3
lines changed

3 files changed

+467
-3
lines changed

src/app-bridge.test.ts

Lines changed: 328 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
22
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
33
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
44
import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js";
5-
import { EmptyResultSchema } from "@modelcontextprotocol/sdk/types.js";
5+
import {
6+
EmptyResultSchema,
7+
CallToolResultSchema,
8+
ListToolsResultSchema,
9+
} from "@modelcontextprotocol/sdk/types.js";
10+
import { z } from "zod/v4";
611

712
import { App } from "./app";
813
import { AppBridge, type McpUiHostCapabilities } from "./app-bridge";
@@ -294,4 +299,326 @@ describe("App <-> AppBridge integration", () => {
294299
expect(result).toEqual({});
295300
});
296301
});
302+
303+
describe("App tool registration", () => {
304+
beforeEach(async () => {
305+
await bridge.connect(bridgeTransport);
306+
});
307+
308+
it("registerTool creates a registered tool", async () => {
309+
const InputSchema = z.object({ name: z.string() }) as any;
310+
const OutputSchema = z.object({ greeting: z.string() }) as any;
311+
312+
const tool = app.registerTool(
313+
"greet",
314+
{
315+
title: "Greet User",
316+
description: "Greets a user by name",
317+
inputSchema: InputSchema,
318+
outputSchema: OutputSchema,
319+
},
320+
async (args: any) => ({
321+
content: [{ type: "text" as const, text: `Hello, ${args.name}!` }],
322+
structuredContent: { greeting: `Hello, ${args.name}!` },
323+
}),
324+
);
325+
326+
expect(tool.title).toBe("Greet User");
327+
expect(tool.description).toBe("Greets a user by name");
328+
expect(tool.enabled).toBe(true);
329+
});
330+
331+
it("registered tool can be enabled and disabled", async () => {
332+
await app.connect(appTransport);
333+
334+
const tool = app.registerTool(
335+
"test-tool",
336+
{
337+
description: "Test tool",
338+
},
339+
async (_extra: any) => ({ content: [] }),
340+
);
341+
342+
expect(tool.enabled).toBe(true);
343+
344+
tool.disable();
345+
expect(tool.enabled).toBe(false);
346+
347+
tool.enable();
348+
expect(tool.enabled).toBe(true);
349+
});
350+
351+
it("registered tool can be updated", async () => {
352+
await app.connect(appTransport);
353+
354+
const tool = app.registerTool(
355+
"test-tool",
356+
{
357+
description: "Original description",
358+
},
359+
async (_extra: any) => ({ content: [] }),
360+
);
361+
362+
expect(tool.description).toBe("Original description");
363+
364+
tool.update({ description: "Updated description" });
365+
expect(tool.description).toBe("Updated description");
366+
});
367+
368+
it("registered tool can be removed", async () => {
369+
await app.connect(appTransport);
370+
371+
const tool = app.registerTool(
372+
"test-tool",
373+
{
374+
description: "Test tool",
375+
},
376+
async (_extra: any) => ({ content: [] }),
377+
);
378+
379+
tool.remove();
380+
// Tool should no longer be registered (internal check)
381+
});
382+
383+
it("tool throws error when disabled and called", async () => {
384+
await app.connect(appTransport);
385+
386+
const tool = app.registerTool(
387+
"test-tool",
388+
{
389+
description: "Test tool",
390+
},
391+
async (_extra: any) => ({ content: [] }),
392+
);
393+
394+
tool.disable();
395+
396+
const mockExtra = {
397+
signal: new AbortController().signal,
398+
requestId: "test",
399+
sendNotification: async () => {},
400+
sendRequest: async () => ({}),
401+
} as any;
402+
403+
await expect(
404+
(tool.callback as any)(mockExtra),
405+
).rejects.toThrow("Tool test-tool is disabled");
406+
});
407+
408+
it("tool validates input schema", async () => {
409+
const InputSchema = z.object({ name: z.string() }) as any;
410+
411+
const tool = app.registerTool(
412+
"greet",
413+
{
414+
inputSchema: InputSchema,
415+
},
416+
async (args: any) => ({
417+
content: [{ type: "text" as const, text: `Hello, ${args.name}!` }],
418+
}),
419+
);
420+
421+
// Create a mock RequestHandlerExtra
422+
const mockExtra = {
423+
signal: new AbortController().signal,
424+
requestId: "test",
425+
sendNotification: async () => {},
426+
sendRequest: async () => ({}),
427+
} as any;
428+
429+
// Valid input should work
430+
await expect(
431+
(tool.callback as any)({ name: "Alice" }, mockExtra),
432+
).resolves.toBeDefined();
433+
434+
// Invalid input should fail
435+
await expect(
436+
(tool.callback as any)({ invalid: "field" }, mockExtra),
437+
).rejects.toThrow("Invalid input for tool greet");
438+
});
439+
440+
it("tool validates output schema", async () => {
441+
const OutputSchema = z.object({ greeting: z.string() }) as any;
442+
443+
const tool = app.registerTool(
444+
"greet",
445+
{
446+
outputSchema: OutputSchema,
447+
},
448+
async (_extra: any) => ({
449+
content: [{ type: "text" as const, text: "Hello!" }],
450+
structuredContent: { greeting: "Hello!" },
451+
}),
452+
);
453+
454+
// Create a mock RequestHandlerExtra
455+
const mockExtra = {
456+
signal: new AbortController().signal,
457+
requestId: "test",
458+
sendNotification: async () => {},
459+
sendRequest: async () => ({}),
460+
} as any;
461+
462+
// Valid output should work
463+
await expect(
464+
(tool.callback as any)(mockExtra),
465+
).resolves.toBeDefined();
466+
});
467+
468+
it("tool enable/disable/update/remove trigger sendToolListChanged", async () => {
469+
await app.connect(appTransport);
470+
471+
const tool = app.registerTool(
472+
"test-tool",
473+
{
474+
description: "Test tool",
475+
},
476+
async (_extra: any) => ({ content: [] }),
477+
);
478+
479+
// The methods should not throw when connected
480+
expect(() => tool.disable()).not.toThrow();
481+
expect(() => tool.enable()).not.toThrow();
482+
expect(() => tool.update({ description: "Updated" })).not.toThrow();
483+
expect(() => tool.remove()).not.toThrow();
484+
});
485+
});
486+
487+
describe("AppBridge -> App tool requests", () => {
488+
beforeEach(async () => {
489+
await bridge.connect(bridgeTransport);
490+
});
491+
492+
it("bridge.sendCallTool calls app.oncalltool handler", async () => {
493+
// App needs tool capabilities to handle tool calls
494+
const appCapabilities = { tools: {} };
495+
app = new App(testAppInfo, appCapabilities, { autoResize: false });
496+
497+
const receivedCalls: unknown[] = [];
498+
499+
app.oncalltool = async (params) => {
500+
receivedCalls.push(params);
501+
return {
502+
content: [{ type: "text", text: `Executed: ${params.name}` }],
503+
};
504+
};
505+
506+
await app.connect(appTransport);
507+
508+
const result = await bridge.sendCallTool({
509+
name: "test-tool",
510+
arguments: { foo: "bar" },
511+
});
512+
513+
expect(receivedCalls).toHaveLength(1);
514+
expect(receivedCalls[0]).toMatchObject({
515+
name: "test-tool",
516+
arguments: { foo: "bar" },
517+
});
518+
expect(result.content).toEqual([
519+
{ type: "text", text: "Executed: test-tool" },
520+
]);
521+
});
522+
523+
it("bridge.sendListTools calls app.onlisttools handler", async () => {
524+
// App needs tool capabilities to handle tool list requests
525+
const appCapabilities = { tools: {} };
526+
app = new App(testAppInfo, appCapabilities, { autoResize: false });
527+
528+
const receivedCalls: unknown[] = [];
529+
530+
app.onlisttools = async (params, extra) => {
531+
receivedCalls.push(params);
532+
return {
533+
tools: [
534+
{
535+
name: "tool1",
536+
description: "First tool",
537+
inputSchema: { type: "object", properties: {} },
538+
},
539+
{
540+
name: "tool2",
541+
description: "Second tool",
542+
inputSchema: { type: "object", properties: {} },
543+
},
544+
{
545+
name: "tool3",
546+
description: "Third tool",
547+
inputSchema: { type: "object", properties: {} },
548+
},
549+
],
550+
};
551+
};
552+
553+
await app.connect(appTransport);
554+
555+
const result = await bridge.sendListTools({});
556+
557+
expect(receivedCalls).toHaveLength(1);
558+
expect(result.tools).toHaveLength(3);
559+
expect(result.tools[0].name).toBe("tool1");
560+
expect(result.tools[1].name).toBe("tool2");
561+
expect(result.tools[2].name).toBe("tool3");
562+
});
563+
});
564+
565+
describe("App tool capabilities", () => {
566+
it("App with tool capabilities can handle tool calls", async () => {
567+
const appCapabilities = { tools: { listChanged: true } };
568+
app = new App(testAppInfo, appCapabilities, { autoResize: false });
569+
570+
const receivedCalls: unknown[] = [];
571+
app.oncalltool = async (params) => {
572+
receivedCalls.push(params);
573+
return {
574+
content: [{ type: "text", text: "Success" }],
575+
};
576+
};
577+
578+
await bridge.connect(bridgeTransport);
579+
await app.connect(appTransport);
580+
581+
await bridge.sendCallTool({
582+
name: "test-tool",
583+
arguments: {},
584+
});
585+
586+
expect(receivedCalls).toHaveLength(1);
587+
});
588+
589+
it("registered tool is invoked via oncalltool", async () => {
590+
const appCapabilities = { tools: { listChanged: true } };
591+
app = new App(testAppInfo, appCapabilities, { autoResize: false });
592+
593+
const tool = app.registerTool(
594+
"greet",
595+
{
596+
description: "Greets user",
597+
inputSchema: z.object({ name: z.string() }) as any,
598+
},
599+
async (args: any) => ({
600+
content: [{ type: "text" as const, text: `Hello, ${args.name}!` }],
601+
}),
602+
);
603+
604+
app.oncalltool = async (params, extra) => {
605+
if (params.name === "greet") {
606+
return await (tool.callback as any)(params.arguments || {}, extra);
607+
}
608+
throw new Error(`Unknown tool: ${params.name}`);
609+
};
610+
611+
await bridge.connect(bridgeTransport);
612+
await app.connect(appTransport);
613+
614+
const result = await bridge.sendCallTool({
615+
name: "greet",
616+
arguments: { name: "Alice" },
617+
});
618+
619+
expect(result.content).toEqual([
620+
{ type: "text", text: "Hello, Alice!" },
621+
]);
622+
});
623+
});
297624
});

src/app-bridge.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ZodLiteral, ZodObject } from "zod/v4";
33

44
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
55
import {
6+
CallToolRequest,
67
CallToolRequestSchema,
78
CallToolResultSchema,
89
Implementation,
@@ -12,6 +13,9 @@ import {
1213
ListResourcesResultSchema,
1314
ListResourceTemplatesRequestSchema,
1415
ListResourceTemplatesResultSchema,
16+
ListToolsRequest,
17+
ListToolsRequestSchema,
18+
ListToolsResultSchema,
1519
LoggingMessageNotification,
1620
LoggingMessageNotificationSchema,
1721
Notification,
@@ -796,6 +800,22 @@ export class AppBridge extends Protocol<Request, Notification, Result> {
796800
});
797801
}
798802

803+
sendCallTool(params: CallToolRequest["params"], options?: RequestOptions) {
804+
return this.request(
805+
{ method: "tools/call", params },
806+
CallToolResultSchema,
807+
options,
808+
);
809+
}
810+
811+
sendListTools(params: ListToolsRequest["params"], options?: RequestOptions) {
812+
return this.request(
813+
{ method: "tools/list", params },
814+
ListToolsResultSchema,
815+
options,
816+
);
817+
}
818+
799819
/**
800820
* Connect to the Guest UI via transport and set up message forwarding.
801821
*

0 commit comments

Comments
 (0)