Skip to content

Commit 140d308

Browse files
committed
feat: implement subscribe/unsubscribe methods for multiple event handlers
1 parent 704b6e0 commit 140d308

4 files changed

Lines changed: 399 additions & 35 deletions

File tree

src/app-bridge.test.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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";
1717
import {
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

9471085
describe("getToolUiResourceUri", () => {

src/app.examples.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
RESOURCE_URI_META_KEY,
1919
McpUiToolMeta,
2020
} from "./app.js";
21+
import type { McpUiHostContextChangedNotification } from "./types.js";
2122
import { registerAppTool } from "./server/index.js";
2223

2324
/**
@@ -263,6 +264,55 @@ function App_onhostcontextchanged_respondToDisplayMode(app: App) {
263264
//#endregion App_onhostcontextchanged_respondToDisplayMode
264265
}
265266

267+
/**
268+
* Example: Subscribe to the same event from multiple places without conflict.
269+
*/
270+
async function App_subscribe_multipleHandlers(app: App) {
271+
//#region App_subscribe_multipleHandlers
272+
// Both handlers receive every notification — neither overrides the other
273+
app.subscribe("hostcontextchanged", (ctx) => {
274+
if (ctx.theme) applyTheme(ctx.theme);
275+
});
276+
app.subscribe("hostcontextchanged", (ctx) => {
277+
if (ctx.styles?.css?.fonts) applyFonts(ctx.styles.css.fonts);
278+
});
279+
await app.connect();
280+
//#endregion App_subscribe_multipleHandlers
281+
}
282+
283+
/**
284+
* Example: Unsubscribe using the function returned by subscribe.
285+
*/
286+
function App_subscribe_cleanup(app: App) {
287+
//#region App_subscribe_cleanup
288+
const unsubscribe = app.subscribe("toolinput", (params) => {
289+
console.log("Tool input received:", params.arguments);
290+
});
291+
292+
// Later, when cleanup is needed:
293+
unsubscribe();
294+
//#endregion App_subscribe_cleanup
295+
}
296+
297+
/**
298+
* Example: Unsubscribe by passing the original handler reference.
299+
*/
300+
function App_unsubscribe_explicit(app: App) {
301+
//#region App_unsubscribe_explicit
302+
const handler = (ctx: McpUiHostContextChangedNotification["params"]) => {
303+
applyTheme(ctx.theme);
304+
};
305+
app.subscribe("hostcontextchanged", handler);
306+
307+
// Later, remove only this handler:
308+
app.unsubscribe("hostcontextchanged", handler);
309+
//#endregion App_unsubscribe_explicit
310+
}
311+
312+
// Stubs for subscribe examples
313+
declare function applyTheme(theme: string | undefined): void;
314+
declare function applyFonts(fonts: string): void;
315+
266316
/**
267317
* Example: Perform cleanup before teardown.
268318
*/

0 commit comments

Comments
 (0)