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