@assistant-ui/react 0.14.0 → 0.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +58 -42
  2. package/dist/client/ExternalThread.d.ts +7 -0
  3. package/dist/client/ExternalThread.d.ts.map +1 -1
  4. package/dist/client/ExternalThread.js +24 -16
  5. package/dist/client/ExternalThread.js.map +1 -1
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/legacy-runtime/cloud/auiV0.d.ts +10 -1
  11. package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
  12. package/dist/legacy-runtime/cloud/auiV0.js +21 -3
  13. package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
  14. package/dist/mcp-apps/McpAppRenderer.d.ts +33 -0
  15. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -0
  16. package/dist/mcp-apps/McpAppRenderer.js +115 -0
  17. package/dist/mcp-apps/McpAppRenderer.js.map +1 -0
  18. package/dist/mcp-apps/McpAppsRemoteHost.d.ts +3 -0
  19. package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -0
  20. package/dist/mcp-apps/McpAppsRemoteHost.js +27 -0
  21. package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -0
  22. package/dist/mcp-apps/app-frame.d.ts +3 -0
  23. package/dist/mcp-apps/app-frame.d.ts.map +1 -0
  24. package/dist/mcp-apps/app-frame.js +218 -0
  25. package/dist/mcp-apps/app-frame.js.map +1 -0
  26. package/dist/mcp-apps/bridge.d.ts +18 -0
  27. package/dist/mcp-apps/bridge.d.ts.map +1 -0
  28. package/dist/mcp-apps/bridge.js +290 -0
  29. package/dist/mcp-apps/bridge.js.map +1 -0
  30. package/dist/mcp-apps/index.d.ts +5 -0
  31. package/dist/mcp-apps/index.d.ts.map +1 -0
  32. package/dist/mcp-apps/index.js +4 -0
  33. package/dist/mcp-apps/index.js.map +1 -0
  34. package/dist/mcp-apps/types.d.ts +149 -0
  35. package/dist/mcp-apps/types.d.ts.map +1 -0
  36. package/dist/mcp-apps/types.js +3 -0
  37. package/dist/mcp-apps/types.js.map +1 -0
  38. package/dist/mcp-apps/utils.d.ts +5 -0
  39. package/dist/mcp-apps/utils.d.ts.map +1 -0
  40. package/dist/mcp-apps/utils.js +10 -0
  41. package/dist/mcp-apps/utils.js.map +1 -0
  42. package/dist/primitives/composer/ComposerInput.d.ts +6 -0
  43. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  44. package/dist/primitives/composer/ComposerInput.js +19 -2
  45. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  46. package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
  47. package/dist/primitives/composer/trigger/TriggerPopover.js +17 -1
  48. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  49. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +33 -0
  50. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts.map +1 -1
  51. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +80 -11
  52. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
  53. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  54. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +2 -1
  55. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  56. package/dist/primitives/messagePart/useMessagePartSource.d.ts +22 -3
  57. package/dist/primitives/messagePart/useMessagePartSource.d.ts.map +1 -1
  58. package/package.json +6 -5
  59. package/src/client/ExternalThread.ts +32 -17
  60. package/src/index.ts +25 -0
  61. package/src/legacy-runtime/cloud/auiV0.ts +37 -4
  62. package/src/mcp-apps/McpAppRenderer.tsx +221 -0
  63. package/src/mcp-apps/McpAppsRemoteHost.ts +52 -0
  64. package/src/mcp-apps/app-frame.tsx +303 -0
  65. package/src/mcp-apps/bridge.test.ts +391 -0
  66. package/src/mcp-apps/bridge.ts +435 -0
  67. package/src/mcp-apps/index.ts +17 -0
  68. package/src/mcp-apps/types.ts +163 -0
  69. package/src/mcp-apps/utils.ts +16 -0
  70. package/src/primitives/composer/ComposerInput.test.tsx +48 -0
  71. package/src/primitives/composer/ComposerInput.tsx +20 -2
  72. package/src/primitives/composer/trigger/TriggerPopover.tsx +21 -1
  73. package/src/primitives/composer/trigger/TriggerPopoverRootContext.test.tsx +152 -0
  74. package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +134 -17
  75. package/src/primitives/composer/trigger/triggerKeyboardResource.test.ts +236 -0
  76. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +2 -1
  77. package/src/tests/BaseComposerRuntimeCore.test.ts +4 -0
  78. package/src/tests/auiV0Encode.test.ts +55 -0
@@ -0,0 +1,221 @@
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type MutableRefObject,
9
+ type ReactNode,
10
+ } from "react";
11
+ import type { McpAppMetadata } from "@assistant-ui/core";
12
+ import type {
13
+ ToolCallMessagePartComponent,
14
+ ToolCallMessagePartProps,
15
+ } from "@assistant-ui/core/react";
16
+ import { useAui } from "@assistant-ui/store";
17
+ import {
18
+ resource,
19
+ tapConst,
20
+ tapRef,
21
+ tapResource,
22
+ type ResourceElement,
23
+ } from "@assistant-ui/tap";
24
+ import { McpAppFrame } from "./app-frame";
25
+ import type {
26
+ McpAppBridgeHandlers,
27
+ McpAppHostContext,
28
+ McpAppHostInfo,
29
+ McpAppResource,
30
+ McpAppSandboxConfig,
31
+ McpAppsHost,
32
+ } from "./types";
33
+ import { getMcpAppFromToolPart } from "./utils";
34
+
35
+ export type McpAppRendererOptions = {
36
+ /**
37
+ * Provides the data-plane operations the widget can request
38
+ * (`loadResource`, `callTool`, `readResource`, `listResources`). Use
39
+ * `McpAppsRemoteHost({ url })` for the default HTTP-route convention.
40
+ */
41
+ host: ResourceElement<McpAppsHost>;
42
+ /** Sandbox + container styling. Passes through to SafeContentFrame. */
43
+ sandbox?: McpAppSandboxConfig;
44
+ /**
45
+ * Upper bound (in pixels) applied to the widget-driven auto-resize height.
46
+ * Defaults to 800.
47
+ */
48
+ maxHeight?: number;
49
+ /** Identifies the host to the widget in the `ui/initialize` response. */
50
+ hostInfo?: McpAppHostInfo;
51
+ /** Delivered to the widget on initialize and pushed via `notifications/host_context/changed` on change. */
52
+ hostContext?: McpAppHostContext;
53
+ /** Rendered when no MCP app is on the part, or while load is in flight / failed (unless overridden). */
54
+ fallback?: ReactNode;
55
+ /** Rendered while the resource is loading. Defaults to `fallback`. */
56
+ loadingFallback?: ReactNode;
57
+ /** Rendered when the resource load rejects. Defaults to `fallback`. */
58
+ errorFallback?: ReactNode | ((error: Error) => ReactNode);
59
+ };
60
+
61
+ type LoadedResourceState = {
62
+ resourceUri: string;
63
+ resource?: McpAppResource;
64
+ error?: Error;
65
+ };
66
+
67
+ function getInput(part: {
68
+ status: { type: string };
69
+ argsText: string;
70
+ args: unknown;
71
+ }): unknown {
72
+ if (
73
+ part.status.type === "running" &&
74
+ (part.argsText === "" || part.argsText === "{}")
75
+ ) {
76
+ return undefined;
77
+ }
78
+ return part.args;
79
+ }
80
+
81
+ const defaultOpenLink = ({ url }: { url: string }) => {
82
+ window.open(url, "_blank", "noopener,noreferrer");
83
+ };
84
+
85
+ function extractSendMessageText(params: unknown): string | undefined {
86
+ if (typeof params === "string") return params;
87
+ if (!params || typeof params !== "object") return undefined;
88
+ const obj = params as Record<string, unknown>;
89
+ if (typeof obj["prompt"] === "string") return obj["prompt"];
90
+ if (typeof obj["text"] === "string") return obj["text"];
91
+ if (typeof obj["message"] === "string") return obj["message"];
92
+ return undefined;
93
+ }
94
+
95
+ function InlineRenderer({
96
+ part,
97
+ internalsRef,
98
+ optionsRef,
99
+ }: {
100
+ part: ToolCallMessagePartProps;
101
+ internalsRef: MutableRefObject<{ host: McpAppsHost }>;
102
+ optionsRef: MutableRefObject<McpAppRendererOptions>;
103
+ }) {
104
+ const opts = optionsRef.current;
105
+ const aui = useAui();
106
+ const app = getMcpAppFromToolPart(part);
107
+ const cachedAppRef = useRef<McpAppMetadata | undefined>(undefined);
108
+ if (app != null && cachedAppRef.current?.resourceUri !== app.resourceUri) {
109
+ cachedAppRef.current = app;
110
+ }
111
+ const appForRender = app ?? cachedAppRef.current;
112
+
113
+ const [loadedResource, setLoadedResource] = useState<LoadedResourceState>();
114
+
115
+ const resourceUri = appForRender?.resourceUri;
116
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-fetches only when URI changes; mcp.app object identity is unstable across renders
117
+ useEffect(() => {
118
+ if (appForRender == null || resourceUri == null) return;
119
+ let cancelled = false;
120
+ const targetUri = resourceUri;
121
+
122
+ internalsRef.current.host
123
+ .loadResource({ uri: targetUri })
124
+ .then((res) => {
125
+ if (!cancelled)
126
+ setLoadedResource({ resourceUri: targetUri, resource: res });
127
+ })
128
+ .catch((error: unknown) => {
129
+ if (!cancelled) {
130
+ setLoadedResource({
131
+ resourceUri: targetUri,
132
+ error: error instanceof Error ? error : new Error(String(error)),
133
+ });
134
+ }
135
+ });
136
+
137
+ return () => {
138
+ cancelled = true;
139
+ };
140
+ }, [resourceUri]);
141
+
142
+ const bridgeHandlers = useMemo<McpAppBridgeHandlers>(
143
+ () => ({
144
+ openLink: defaultOpenLink,
145
+ sendMessage: (params) => {
146
+ const text = extractSendMessageText(params);
147
+ if (!text) return { ok: false, reason: "unrecognised params shape" };
148
+ aui.thread().append({ content: [{ type: "text", text }] });
149
+ return { ok: true };
150
+ },
151
+ callTool: (params) => internalsRef.current.host.callTool(params),
152
+ readResource: (params) => internalsRef.current.host.readResource(params),
153
+ listResources: (params) =>
154
+ internalsRef.current.host.listResources(params),
155
+ }),
156
+ [aui, internalsRef],
157
+ );
158
+
159
+ const loadedResourceForApp =
160
+ loadedResource?.resourceUri === appForRender?.resourceUri
161
+ ? loadedResource
162
+ : undefined;
163
+ const appResource = loadedResourceForApp?.resource;
164
+ const error = loadedResourceForApp?.error;
165
+
166
+ const fallback = opts.fallback ?? null;
167
+ if (appForRender == null) {
168
+ return <>{fallback}</>;
169
+ }
170
+ if (error != null) {
171
+ const errorFallback = opts.errorFallback;
172
+ if (errorFallback === undefined) return <>{fallback}</>;
173
+ if (typeof errorFallback === "function") return <>{errorFallback(error)}</>;
174
+ return <>{errorFallback}</>;
175
+ }
176
+ if (appResource == null) {
177
+ return <>{opts.loadingFallback ?? fallback}</>;
178
+ }
179
+
180
+ return (
181
+ <McpAppFrame
182
+ app={appForRender}
183
+ resource={appResource}
184
+ input={getInput(part)}
185
+ output={part.result}
186
+ sandbox={opts.sandbox}
187
+ handlers={bridgeHandlers}
188
+ hostInfo={opts.hostInfo}
189
+ hostContext={opts.hostContext}
190
+ maxHeight={opts.maxHeight}
191
+ />
192
+ );
193
+ }
194
+
195
+ export const McpAppRenderer = resource(
196
+ (
197
+ options: McpAppRendererOptions,
198
+ ): { readonly render: ToolCallMessagePartComponent } => {
199
+ const host = tapResource(options.host);
200
+
201
+ const optionsRef = tapRef<McpAppRendererOptions>(options);
202
+ optionsRef.current = options;
203
+
204
+ const internalsRef = tapRef<{ host: McpAppsHost }>({ host });
205
+ internalsRef.current = { host };
206
+
207
+ const render = tapConst((): ToolCallMessagePartComponent => {
208
+ const Render: ToolCallMessagePartComponent = (props) => (
209
+ <InlineRenderer
210
+ part={props}
211
+ internalsRef={internalsRef}
212
+ optionsRef={optionsRef}
213
+ />
214
+ );
215
+ Render.displayName = "McpAppRenderer";
216
+ return Render;
217
+ }, []);
218
+
219
+ return { render };
220
+ },
221
+ );
@@ -0,0 +1,52 @@
1
+ import { resource, tapConst, tapRef } from "@assistant-ui/tap";
2
+ import type {
3
+ McpAppResource,
4
+ McpAppsHost,
5
+ McpAppsRemoteHostOptions,
6
+ } from "./types";
7
+
8
+ async function postToHost(
9
+ options: McpAppsRemoteHostOptions,
10
+ method: string,
11
+ params: unknown,
12
+ ): Promise<unknown> {
13
+ const doFetch = options.fetch ?? fetch;
14
+ const extraHeaders =
15
+ typeof options.headers === "function"
16
+ ? await options.headers()
17
+ : (options.headers ?? {});
18
+ const res = await doFetch(options.url, {
19
+ method: "POST",
20
+ headers: { "content-type": "application/json", ...extraHeaders },
21
+ body: JSON.stringify({ method, params }),
22
+ });
23
+ if (!res.ok) {
24
+ throw new Error(`MCP App host request failed: ${res.status}`);
25
+ }
26
+ return res.json();
27
+ }
28
+
29
+ export const McpAppsRemoteHost = resource(
30
+ (options: McpAppsRemoteHostOptions): McpAppsHost => {
31
+ const optionsRef = tapRef(options);
32
+ optionsRef.current = options;
33
+
34
+ return tapConst(
35
+ (): McpAppsHost => ({
36
+ loadResource: (params) =>
37
+ postToHost(
38
+ optionsRef.current,
39
+ "mcp-apps/read-resource",
40
+ params,
41
+ ) as Promise<McpAppResource>,
42
+ callTool: (params) =>
43
+ postToHost(optionsRef.current, "tools/call", params),
44
+ readResource: (params) =>
45
+ postToHost(optionsRef.current, "resources/read", params),
46
+ listResources: (params) =>
47
+ postToHost(optionsRef.current, "resources/list", params),
48
+ }),
49
+ [],
50
+ );
51
+ },
52
+ );
@@ -0,0 +1,303 @@
1
+ "use client";
2
+
3
+ import { type MutableRefObject, useEffect, useRef, useState } from "react";
4
+ import { type RenderedFrame, SafeContentFrame } from "safe-content-frame";
5
+ import { type McpAppBridge, createMcpAppBridge } from "./bridge";
6
+ import type {
7
+ McpAppBridgeHandlers,
8
+ McpAppFrameProps,
9
+ McpAppHostContext,
10
+ } from "./types";
11
+
12
+ const DEFAULT_PRODUCT = "assistant-ui-mcp-app";
13
+ const INIT_TIMEOUT_MS = 5000;
14
+ const DEFAULT_MAX_HEIGHT = 800;
15
+
16
+ function useBridgeNotify<T>(
17
+ value: T | undefined,
18
+ bridgeRef: MutableRefObject<McpAppBridge | null>,
19
+ widgetReadyRef: MutableRefObject<boolean>,
20
+ pendingRef: MutableRefObject<T | undefined>,
21
+ lastSentRef: MutableRefObject<T | undefined>,
22
+ notify: (bridge: McpAppBridge, v: T) => void,
23
+ ) {
24
+ // biome-ignore lint/correctness/useExhaustiveDependencies: refs and notify are stable; we re-run only when value changes.
25
+ useEffect(() => {
26
+ if (!bridgeRef.current) return;
27
+ if (value === undefined) return;
28
+ if (lastSentRef.current === value) return;
29
+ if (!widgetReadyRef.current) {
30
+ pendingRef.current = value;
31
+ return;
32
+ }
33
+ notify(bridgeRef.current, value);
34
+ lastSentRef.current = value;
35
+ }, [value]);
36
+ }
37
+
38
+ type LiveSnapshot = {
39
+ handlers: McpAppBridgeHandlers | undefined;
40
+ hostInfo: McpAppFrameProps["hostInfo"];
41
+ hostContext: McpAppFrameProps["hostContext"];
42
+ input: unknown;
43
+ output: unknown;
44
+ };
45
+
46
+ // Proxy each per-call handler through liveRef so the bridge always dispatches
47
+ // to the latest handler reference (e.g. inline callbacks closing over state).
48
+ // Capability presence is snapshot at mount: a handler added later requires a
49
+ // remount (keyed on resource URI) to expose the capability to the widget.
50
+ function buildLiveHandlers(
51
+ initial: McpAppBridgeHandlers | undefined,
52
+ liveRef: { readonly current: LiveSnapshot },
53
+ ): McpAppBridgeHandlers {
54
+ const live = () => liveRef.current.handlers;
55
+ const has = <K extends keyof McpAppBridgeHandlers>(key: K) =>
56
+ initial?.[key] !== undefined;
57
+ const out: McpAppBridgeHandlers = {};
58
+ if (has("allowedTools")) {
59
+ Object.defineProperty(out, "allowedTools", {
60
+ get: () => live()?.allowedTools,
61
+ enumerable: true,
62
+ configurable: true,
63
+ });
64
+ }
65
+ const liveCall = <K extends keyof McpAppBridgeHandlers>(
66
+ key: K,
67
+ ): NonNullable<McpAppBridgeHandlers[K]> =>
68
+ ((p: unknown) => {
69
+ const fn = live()?.[key] as ((p: unknown) => unknown) | undefined;
70
+ if (!fn) {
71
+ throw new Error(`${key} handler is no longer available`);
72
+ }
73
+ return fn(p);
74
+ }) as NonNullable<McpAppBridgeHandlers[K]>;
75
+ if (has("callTool")) out.callTool = liveCall("callTool");
76
+ if (has("readResource")) out.readResource = liveCall("readResource");
77
+ if (has("listResources")) out.listResources = liveCall("listResources");
78
+ if (has("openLink")) out.openLink = liveCall("openLink");
79
+ if (has("sendMessage")) out.sendMessage = liveCall("sendMessage");
80
+ if (has("updateModelContext"))
81
+ out.updateModelContext = liveCall("updateModelContext");
82
+ if (has("requestDisplayMode"))
83
+ out.requestDisplayMode = liveCall("requestDisplayMode");
84
+ out.onSizeChange = (p) => live()?.onSizeChange?.(p);
85
+ out.onInitialized = () => live()?.onInitialized?.();
86
+ out.onRequestTeardown = (p) => live()?.onRequestTeardown?.(p);
87
+ out.onLog = (p) => live()?.onLog?.(p);
88
+ out.onError = (e) => live()?.onError?.(e);
89
+ return out;
90
+ }
91
+
92
+ export function McpAppFrame({
93
+ app,
94
+ resource,
95
+ input,
96
+ output,
97
+ sandbox,
98
+ handlers,
99
+ hostInfo,
100
+ hostContext,
101
+ maxHeight = DEFAULT_MAX_HEIGHT,
102
+ }: McpAppFrameProps) {
103
+ const containerRef = useRef<HTMLDivElement>(null);
104
+ const [contentHeight, setContentHeight] = useState<number | undefined>(
105
+ undefined,
106
+ );
107
+ const bridgeRef = useRef<McpAppBridge | null>(null);
108
+ const lastSentInputRef = useRef<unknown>(undefined);
109
+ const lastSentOutputRef = useRef<unknown>(undefined);
110
+ const lastSentHostContextRef = useRef<McpAppHostContext | undefined>(
111
+ undefined,
112
+ );
113
+ // Per MCP Apps spec, the host should defer notifications until the widget
114
+ // signals readiness via `notifications/initialized`. Until then, we record
115
+ // pending values and flush them on init.
116
+ const widgetReadyRef = useRef(false);
117
+ const pendingInputRef = useRef<unknown>(undefined);
118
+ const pendingOutputRef = useRef<unknown>(undefined);
119
+ const pendingHostContextRef = useRef<McpAppHostContext | undefined>(
120
+ undefined,
121
+ );
122
+
123
+ const liveRef = useRef<LiveSnapshot>(null!);
124
+ liveRef.current = {
125
+ handlers,
126
+ hostInfo,
127
+ hostContext,
128
+ input,
129
+ output,
130
+ };
131
+
132
+ const resourceUri = resource.uri;
133
+
134
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-mounts only on resource URI; live values flow through liveRef
135
+ useEffect(() => {
136
+ const container = containerRef.current;
137
+ if (!container) return;
138
+
139
+ let cancelled = false;
140
+ let initTimeoutId: ReturnType<typeof setTimeout> | null = null;
141
+ let frame: RenderedFrame | null = null;
142
+ const sb = sandbox;
143
+ const html = resource.html;
144
+
145
+ const scf = new SafeContentFrame(sb?.product ?? DEFAULT_PRODUCT, {
146
+ ...(sb?.sandbox !== undefined && { sandbox: sb.sandbox }),
147
+ ...(sb?.useShadowDom !== undefined && { useShadowDom: sb.useShadowDom }),
148
+ ...(sb?.enableBrowserCaching !== undefined && {
149
+ enableBrowserCaching: sb.enableBrowserCaching,
150
+ }),
151
+ ...(sb?.salt !== undefined && { salt: sb.salt }),
152
+ });
153
+
154
+ const renderOpts =
155
+ sb?.unsafeDocumentWrite !== undefined
156
+ ? { unsafeDocumentWrite: sb.unsafeDocumentWrite }
157
+ : undefined;
158
+
159
+ scf
160
+ .renderHtml(html, container, renderOpts)
161
+ .then((rendered) => {
162
+ if (cancelled) {
163
+ rendered.dispose();
164
+ return;
165
+ }
166
+ frame = rendered;
167
+ const current = liveRef.current;
168
+ const liveHandlers = buildLiveHandlers(current.handlers, liveRef);
169
+ const liveOnInitialized = liveHandlers.onInitialized;
170
+ const flushPending = () => {
171
+ if (widgetReadyRef.current) return;
172
+ widgetReadyRef.current = true;
173
+ const b = bridgeRef.current;
174
+ if (!b) return;
175
+ if (pendingInputRef.current !== undefined) {
176
+ b.notifyToolInput(pendingInputRef.current);
177
+ lastSentInputRef.current = pendingInputRef.current;
178
+ pendingInputRef.current = undefined;
179
+ }
180
+ if (pendingOutputRef.current !== undefined) {
181
+ b.notifyToolResult(pendingOutputRef.current);
182
+ lastSentOutputRef.current = pendingOutputRef.current;
183
+ pendingOutputRef.current = undefined;
184
+ }
185
+ if (pendingHostContextRef.current !== undefined) {
186
+ b.notifyHostContextChanged(pendingHostContextRef.current);
187
+ lastSentHostContextRef.current = pendingHostContextRef.current;
188
+ pendingHostContextRef.current = undefined;
189
+ }
190
+ };
191
+ const wrappedHandlers: McpAppBridgeHandlers = {
192
+ ...liveHandlers,
193
+ onInitialized: () => {
194
+ if (initTimeoutId !== null) {
195
+ clearTimeout(initTimeoutId);
196
+ initTimeoutId = null;
197
+ }
198
+ flushPending();
199
+ liveOnInitialized?.();
200
+ },
201
+ onSizeChange: (p) => {
202
+ if (
203
+ typeof p.height === "number" &&
204
+ Number.isFinite(p.height) &&
205
+ p.height > 0
206
+ ) {
207
+ setContentHeight(p.height);
208
+ }
209
+ liveHandlers.onSizeChange?.(p);
210
+ },
211
+ };
212
+ // Safety net: if the widget never sends notifications/initialized
213
+ // (broken or non-spec-compliant), flush the queue anyway so the host
214
+ // doesn't appear hung.
215
+ initTimeoutId = setTimeout(() => {
216
+ initTimeoutId = null;
217
+ flushPending();
218
+ }, INIT_TIMEOUT_MS);
219
+ bridgeRef.current = createMcpAppBridge({
220
+ frame: rendered,
221
+ handlers: wrappedHandlers,
222
+ hostInfo: current.hostInfo,
223
+ hostContext: current.hostContext,
224
+ });
225
+
226
+ if (current.input !== undefined)
227
+ pendingInputRef.current = current.input;
228
+ if (current.output !== undefined)
229
+ pendingOutputRef.current = current.output;
230
+ // hostContext is delivered inside the ui/initialize response; subsequent
231
+ // changes flow through useBridgeNotify's pending path.
232
+ })
233
+ .catch((err) => {
234
+ liveRef.current.handlers?.onError?.(
235
+ err instanceof Error ? err : new Error(String(err)),
236
+ );
237
+ });
238
+
239
+ return () => {
240
+ cancelled = true;
241
+ if (initTimeoutId !== null) {
242
+ clearTimeout(initTimeoutId);
243
+ initTimeoutId = null;
244
+ }
245
+ bridgeRef.current?.dispose();
246
+ bridgeRef.current = null;
247
+ frame?.dispose();
248
+ frame = null;
249
+ lastSentInputRef.current = undefined;
250
+ lastSentOutputRef.current = undefined;
251
+ lastSentHostContextRef.current = undefined;
252
+ widgetReadyRef.current = false;
253
+ pendingInputRef.current = undefined;
254
+ pendingOutputRef.current = undefined;
255
+ pendingHostContextRef.current = undefined;
256
+ setContentHeight(undefined);
257
+ };
258
+ }, [resourceUri]);
259
+
260
+ useBridgeNotify(
261
+ input,
262
+ bridgeRef,
263
+ widgetReadyRef,
264
+ pendingInputRef,
265
+ lastSentInputRef,
266
+ (b, v) => b.notifyToolInput(v),
267
+ );
268
+ useBridgeNotify(
269
+ output,
270
+ bridgeRef,
271
+ widgetReadyRef,
272
+ pendingOutputRef,
273
+ lastSentOutputRef,
274
+ (b, v) => b.notifyToolResult(v),
275
+ );
276
+ useBridgeNotify(
277
+ hostContext,
278
+ bridgeRef,
279
+ widgetReadyRef,
280
+ pendingHostContextRef,
281
+ lastSentHostContextRef,
282
+ (b, v) => b.notifyHostContextChanged(v),
283
+ );
284
+
285
+ const resolvedHeight =
286
+ contentHeight != null ? Math.min(contentHeight, maxHeight) : undefined;
287
+ const mergedStyle =
288
+ resolvedHeight != null
289
+ ? { ...sandbox?.style, height: resolvedHeight }
290
+ : sandbox?.style;
291
+
292
+ return (
293
+ <div
294
+ ref={containerRef}
295
+ className={sandbox?.className}
296
+ style={mergedStyle}
297
+ data-mcp-app-resource={app.resourceUri}
298
+ data-mcp-app-prefers-border={
299
+ resource.meta?.prefersBorder ? "" : undefined
300
+ }
301
+ />
302
+ );
303
+ }