@@ -13,7 +13,7 @@ import {
1313 ToolListChangedNotificationSchema ,
1414} from "@modelcontextprotocol/sdk/types.js" ;
1515
16- import { App } from "./app" ;
16+ import { App , type NotificationEventMap } from "./app" ;
1717import {
1818 AppBridge ,
1919 getToolUiResourceUri ,
@@ -942,6 +942,144 @@ describe("App <-> AppBridge integration", () => {
942942 expect ( receivedNotifications ) . toHaveLength ( 1 ) ;
943943 } ) ;
944944 } ) ;
945+
946+ describe ( "App.subscribe and App.unsubscribe" , ( ) => {
947+ it ( "multiple subscribers all receive the same notification" , async ( ) => {
948+ const received1 : unknown [ ] = [ ] ;
949+ const received2 : unknown [ ] = [ ] ;
950+
951+ app . subscribe ( "hostcontextchanged" , ( params ) => received1 . push ( params ) ) ;
952+ app . subscribe ( "hostcontextchanged" , ( params ) => received2 . push ( params ) ) ;
953+
954+ await bridge . connect ( bridgeTransport ) ;
955+ await app . connect ( appTransport ) ;
956+
957+ bridge . setHostContext ( { theme : "dark" } ) ;
958+ await flush ( ) ;
959+
960+ expect ( received1 ) . toEqual ( [ { theme : "dark" } ] ) ;
961+ expect ( received2 ) . toEqual ( [ { theme : "dark" } ] ) ;
962+ } ) ;
963+
964+ it ( "unsubscribe removes only the targeted subscriber" , async ( ) => {
965+ const received1 : unknown [ ] = [ ] ;
966+ const received2 : unknown [ ] = [ ] ;
967+
968+ const handler1 = ( params : NotificationEventMap [ "hostcontextchanged" ] ) =>
969+ received1 . push ( params ) ;
970+ const handler2 = ( params : NotificationEventMap [ "hostcontextchanged" ] ) =>
971+ received2 . push ( params ) ;
972+
973+ app . subscribe ( "hostcontextchanged" , handler1 ) ;
974+ app . subscribe ( "hostcontextchanged" , handler2 ) ;
975+
976+ await bridge . connect ( bridgeTransport ) ;
977+ await app . connect ( appTransport ) ;
978+
979+ bridge . setHostContext ( { theme : "dark" } ) ;
980+ await flush ( ) ;
981+
982+ // Both fired
983+ expect ( received1 ) . toHaveLength ( 1 ) ;
984+ expect ( received2 ) . toHaveLength ( 1 ) ;
985+
986+ app . unsubscribe ( "hostcontextchanged" , handler1 ) ;
987+
988+ bridge . setHostContext ( { theme : "light" } ) ;
989+ await flush ( ) ;
990+
991+ // Only handler2 fired for the second update
992+ expect ( received1 ) . toHaveLength ( 1 ) ;
993+ expect ( received2 ) . toHaveLength ( 2 ) ;
994+ } ) ;
995+
996+ it ( "subscribe returns an unsubscribe function that works correctly" , async ( ) => {
997+ const received : unknown [ ] = [ ] ;
998+
999+ const unsubscribe = app . subscribe ( "toolinput" , ( params ) =>
1000+ received . push ( params ) ,
1001+ ) ;
1002+
1003+ await bridge . connect ( bridgeTransport ) ;
1004+ await app . connect ( appTransport ) ;
1005+
1006+ bridge . sendToolInput ( { arguments : { x : 1 } } ) ;
1007+ await flush ( ) ;
1008+ expect ( received ) . toHaveLength ( 1 ) ;
1009+
1010+ unsubscribe ( ) ;
1011+
1012+ bridge . sendToolInput ( { arguments : { x : 2 } } ) ;
1013+ await flush ( ) ;
1014+ // No new events after unsubscribing
1015+ expect ( received ) . toHaveLength ( 1 ) ;
1016+ } ) ;
1017+
1018+ it ( "setter and subscribers fire independently" , async ( ) => {
1019+ const setterReceived : unknown [ ] = [ ] ;
1020+ const subscriberReceived : unknown [ ] = [ ] ;
1021+
1022+ app . onhostcontextchanged = ( params ) => setterReceived . push ( params ) ;
1023+ app . subscribe ( "hostcontextchanged" , ( params ) =>
1024+ subscriberReceived . push ( params ) ,
1025+ ) ;
1026+
1027+ await bridge . connect ( bridgeTransport ) ;
1028+ await app . connect ( appTransport ) ;
1029+
1030+ bridge . setHostContext ( { theme : "dark" } ) ;
1031+ await flush ( ) ;
1032+
1033+ expect ( setterReceived ) . toEqual ( [ { theme : "dark" } ] ) ;
1034+ expect ( subscriberReceived ) . toEqual ( [ { theme : "dark" } ] ) ;
1035+ } ) ;
1036+
1037+ it ( "context merge runs before setter and subscriber callbacks" , async ( ) => {
1038+ let contextInSetter : unknown ;
1039+ let contextInSubscriber : unknown ;
1040+
1041+ app . onhostcontextchanged = ( ) => {
1042+ contextInSetter = app . getHostContext ( ) ;
1043+ } ;
1044+ app . subscribe ( "hostcontextchanged" , ( ) => {
1045+ contextInSubscriber = app . getHostContext ( ) ;
1046+ } ) ;
1047+
1048+ await bridge . connect ( bridgeTransport ) ;
1049+ await app . connect ( appTransport ) ;
1050+
1051+ bridge . setHostContext ( { theme : "dark" } ) ;
1052+ await flush ( ) ;
1053+
1054+ expect ( ( contextInSetter as { theme : string } ) ?. theme ) . toBe ( "dark" ) ;
1055+ expect ( ( contextInSubscriber as { theme : string } ) ?. theme ) . toBe ( "dark" ) ;
1056+ } ) ;
1057+
1058+ it ( "unsubscribing all subscribers does not affect the setter callback" , async ( ) => {
1059+ const setterReceived : unknown [ ] = [ ] ;
1060+ const subscriberReceived : unknown [ ] = [ ] ;
1061+
1062+ app . onhostcontextchanged = ( params ) => setterReceived . push ( params ) ;
1063+ const unsub = app . subscribe ( "hostcontextchanged" , ( params ) =>
1064+ subscriberReceived . push ( params ) ,
1065+ ) ;
1066+
1067+ await bridge . connect ( bridgeTransport ) ;
1068+ await app . connect ( appTransport ) ;
1069+
1070+ bridge . setHostContext ( { theme : "dark" } ) ;
1071+ await flush ( ) ;
1072+
1073+ unsub ( ) ;
1074+
1075+ bridge . setHostContext ( { theme : "light" } ) ;
1076+ await flush ( ) ;
1077+
1078+ // Setter still fires after subscriber is removed
1079+ expect ( setterReceived ) . toHaveLength ( 2 ) ;
1080+ expect ( subscriberReceived ) . toHaveLength ( 1 ) ;
1081+ } ) ;
1082+ } ) ;
9451083} ) ;
9461084
9471085describe ( "getToolUiResourceUri" , ( ) => {
0 commit comments