@@ -2,7 +2,12 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
22import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" ;
33import type { Client } from "@modelcontextprotocol/sdk/client/index.js" ;
44import 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
712import { App } from "./app" ;
813import { 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} ) ;
0 commit comments