@assistant-ui/react 0.15.25 → 0.15.27
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/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
- package/dist/hooks/useToolCallElapsed.js +3 -2
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/index.d.ts +31 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +3 -2
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/mcp-apps/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +58 -48
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.d.ts.map +1 -1
- package/dist/mcp-apps/bridge.js +28 -3
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/mcp-apps/csp.d.ts +4 -0
- package/dist/mcp-apps/csp.d.ts.map +1 -0
- package/dist/mcp-apps/csp.js +93 -0
- package/dist/mcp-apps/csp.js.map +1 -0
- package/dist/mcp-apps/index.d.ts +1 -1
- package/dist/mcp-apps/index.d.ts.map +1 -1
- package/dist/mcp-apps/types.d.ts +19 -1
- package/dist/mcp-apps/types.d.ts.map +1 -1
- package/dist/mcp-apps/types.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts +1 -0
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
- package/dist/primitives/attachment.d.ts +3 -1
- package/dist/primitives/attachment.d.ts.map +1 -1
- package/dist/primitives/attachment.js.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +14 -12
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/primitives/composer/ComposerCancel.d.ts +5 -1
- package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerCancel.js +14 -1
- package/dist/primitives/composer/ComposerCancel.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts +14 -0
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +4 -3
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerRoot.js +24 -19
- package/dist/primitives/composer/ComposerRoot.js.map +1 -1
- package/dist/primitives/composer/composerInputSelector.d.ts +3 -0
- package/dist/primitives/composer/composerInputSelector.d.ts.map +1 -0
- package/dist/primitives/composer/composerInputSelector.js +7 -0
- package/dist/primitives/composer/composerInputSelector.js.map +1 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.d.ts +2 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.d.ts.map +1 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.js +42 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.js.map +1 -0
- package/dist/primitives/composer.d.ts +35 -14
- package/dist/primitives/composer.d.ts.map +1 -1
- package/dist/primitives/composer.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +8 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js +58 -278
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -2
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
- package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadViewport.js +4 -3
- package/dist/primitives/thread/ThreadViewport.js.map +1 -1
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +2 -2
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +90 -21
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +3 -2
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useComposerInput.d.ts +9 -4
- package/dist/unstable/useComposerInput.d.ts.map +1 -1
- package/dist/unstable/useComposerInput.js +6 -4
- package/dist/unstable/useComposerInput.js.map +1 -1
- package/dist/unstable/useComposerInputHistory.d.ts +3 -2
- package/dist/unstable/useComposerInputHistory.d.ts.map +1 -1
- package/dist/unstable/useComposerInputHistory.js +2 -2
- package/dist/unstable/useComposerInputHistory.js.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.d.ts +3 -2
- package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.js +8 -7
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
- package/dist/unstable/useMentionAdapter.d.ts +12 -3
- package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
- package/dist/unstable/useMentionAdapter.js +2 -2
- package/dist/unstable/useMentionAdapter.js.map +1 -1
- package/dist/unstable/useMessageStallDetection.d.ts +4 -2
- package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
- package/dist/unstable/useMessageStallDetection.js +2 -2
- package/dist/unstable/useMessageStallDetection.js.map +1 -1
- package/dist/unstable/useSlashCommandAdapter.d.ts +5 -2
- package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
- package/dist/unstable/useSlashCommandAdapter.js +5 -4
- package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts +4 -0
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js +2 -0
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
- package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
- package/dist/utils/getSelectionMessageId.js +31 -8
- package/dist/utils/getSelectionMessageId.js.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.js +2 -22
- package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
- package/dist/utils/useIsomorphicLayoutEffect.d.ts +3 -0
- package/dist/utils/useIsomorphicLayoutEffect.d.ts.map +1 -0
- package/dist/utils/useIsomorphicLayoutEffect.js +6 -0
- package/dist/utils/useIsomorphicLayoutEffect.js.map +1 -0
- package/package.json +10 -10
- package/src/hooks/useToolCallElapsed.test.tsx +4 -2
- package/src/hooks/useToolCallElapsed.ts +3 -2
- package/src/index.ts +20 -2
- package/src/mcp-apps/McpAppRenderer.test.tsx +7 -2
- package/src/mcp-apps/McpAppRenderer.tsx +3 -9
- package/src/mcp-apps/app-frame.test.tsx +102 -6
- package/src/mcp-apps/app-frame.tsx +7 -10
- package/src/mcp-apps/bridge.test.ts +154 -0
- package/src/mcp-apps/bridge.ts +51 -9
- package/src/mcp-apps/csp.test.ts +112 -0
- package/src/mcp-apps/csp.ts +124 -0
- package/src/mcp-apps/index.ts +1 -0
- package/src/mcp-apps/types.ts +16 -1
- package/src/model-context/frame/AssistantFrame.test.ts +45 -0
- package/src/model-context/frame/SPEC_AssistantFrame.md +1 -0
- package/src/primitives/assistantModal/AssistantModalRoot.tsx +1 -0
- package/src/primitives/attachment.ts +4 -1
- package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +32 -1
- package/src/primitives/composer/ComposerAttachmentDropzone.tsx +14 -12
- package/src/primitives/composer/ComposerCancel.focus.test.tsx +307 -0
- package/src/primitives/composer/ComposerCancel.ts +8 -1
- package/src/primitives/composer/ComposerDisabledThread.test.tsx +121 -0
- package/src/primitives/composer/ComposerInput.tsx +19 -1
- package/src/primitives/composer/ComposerRoot.tsx +6 -5
- package/src/primitives/composer/composerInputSelector.ts +4 -0
- package/src/primitives/composer/useComposerCancelWithFocus.ts +57 -0
- package/src/primitives/composer.ts +56 -21
- package/src/primitives/message/MessagePartsGrouped.test.tsx +106 -2
- package/src/primitives/message/MessagePartsGrouped.tsx +36 -134
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +20 -12
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +6 -2
- package/src/primitives/thread/ThreadViewport.tsx +3 -3
- package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +2 -2
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +666 -63
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +201 -73
- package/src/sandbox-host/SandboxHost.tsx +3 -8
- package/src/unstable/useComposerInput.ts +9 -4
- package/src/unstable/useComposerInputHistory.ts +3 -2
- package/src/unstable/useLiveCompletionAdapter.ts +9 -14
- package/src/unstable/useMentionAdapter.ts +12 -3
- package/src/unstable/useMessageStallDetection.ts +4 -2
- package/src/unstable/useSlashCommandAdapter.ts +8 -4
- package/src/unstable/webmcp/useWebMcpProvider.ts +4 -0
- package/src/utils/getSelectionMessageId.test.ts +231 -0
- package/src/utils/getSelectionMessageId.ts +45 -7
- package/src/utils/hooks/useOnResizeContent.ts +2 -29
- package/src/utils/useIsomorphicLayoutEffect.test.tsx +21 -0
- package/src/utils/useIsomorphicLayoutEffect.ts +9 -0
|
@@ -8,7 +8,11 @@ import type {
|
|
|
8
8
|
} from "../sandbox-host/SandboxHost";
|
|
9
9
|
import type { CreateMcpAppBridgeOptions, McpAppBridge } from "./bridge";
|
|
10
10
|
import type * as BridgeModule from "./bridge";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
MCP_APP_MIME_TYPE,
|
|
13
|
+
type McpAppHostContext,
|
|
14
|
+
type McpAppInitializeParams,
|
|
15
|
+
} from "./types";
|
|
12
16
|
|
|
13
17
|
const { sandboxHostMock, createMcpAppBridgeMock } = vi.hoisted(() => ({
|
|
14
18
|
sandboxHostMock: vi.fn(),
|
|
@@ -31,6 +35,44 @@ describe("McpAppFrame", () => {
|
|
|
31
35
|
cleanup();
|
|
32
36
|
});
|
|
33
37
|
|
|
38
|
+
it("enforces the resource CSP before rendering widget HTML", () => {
|
|
39
|
+
let sandboxProps: SandboxHostProps | undefined;
|
|
40
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
41
|
+
sandboxProps = props;
|
|
42
|
+
return null;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
render(
|
|
46
|
+
<McpAppFrame
|
|
47
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
48
|
+
resource={{
|
|
49
|
+
uri: "ui://example/widget",
|
|
50
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
51
|
+
html: '<!doctype html><script src="https://cdn.example/app.js"></script>',
|
|
52
|
+
meta: {
|
|
53
|
+
csp: {
|
|
54
|
+
connectDomains: ["https://api.example"],
|
|
55
|
+
resourceDomains: ["https://cdn.example"],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}}
|
|
59
|
+
/>,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(sandboxProps?.content.html).toContain(
|
|
63
|
+
'http-equiv="Content-Security-Policy"',
|
|
64
|
+
);
|
|
65
|
+
expect(sandboxProps?.content.html).toContain(
|
|
66
|
+
"connect-src 'self' https://api.example",
|
|
67
|
+
);
|
|
68
|
+
expect(sandboxProps?.content.html).toContain(
|
|
69
|
+
"script-src 'self' 'unsafe-inline' https://cdn.example",
|
|
70
|
+
);
|
|
71
|
+
expect(
|
|
72
|
+
sandboxProps?.content.html.indexOf("Content-Security-Policy"),
|
|
73
|
+
).toBeLessThan(sandboxProps?.content.html.indexOf("<script") ?? 0);
|
|
74
|
+
});
|
|
75
|
+
|
|
34
76
|
it("keeps bridge options scoped to committed renders", async () => {
|
|
35
77
|
let committedCreateBridge: SandboxHostProps["createBridge"] | null = null;
|
|
36
78
|
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
@@ -234,6 +276,60 @@ describe("McpAppFrame", () => {
|
|
|
234
276
|
}
|
|
235
277
|
});
|
|
236
278
|
|
|
279
|
+
it("forwards initialization metadata to the live handler", () => {
|
|
280
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
281
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
282
|
+
createBridge ??= props.createBridge;
|
|
283
|
+
return null;
|
|
284
|
+
});
|
|
285
|
+
const bridge: McpAppBridge = {
|
|
286
|
+
onMessage: vi.fn(),
|
|
287
|
+
dispose: vi.fn(),
|
|
288
|
+
notifyToolInput: vi.fn(),
|
|
289
|
+
notifyToolResult: vi.fn(),
|
|
290
|
+
notifyHostContextChanged: vi.fn(),
|
|
291
|
+
};
|
|
292
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
293
|
+
const onInitialized = vi.fn();
|
|
294
|
+
const view = (handler: typeof onInitialized) => (
|
|
295
|
+
<McpAppFrame
|
|
296
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
297
|
+
resource={{
|
|
298
|
+
uri: "ui://example/widget",
|
|
299
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
300
|
+
html: "",
|
|
301
|
+
}}
|
|
302
|
+
handlers={{ onInitialized: handler }}
|
|
303
|
+
/>
|
|
304
|
+
);
|
|
305
|
+
const rendered = render(view(onInitialized));
|
|
306
|
+
|
|
307
|
+
const sandboxBridge = createBridge!(
|
|
308
|
+
{
|
|
309
|
+
iframe: document.createElement("iframe"),
|
|
310
|
+
origin: "https://widget.example",
|
|
311
|
+
sendMessage: vi.fn(),
|
|
312
|
+
},
|
|
313
|
+
{ setHeight: vi.fn() },
|
|
314
|
+
);
|
|
315
|
+
const options = createMcpAppBridgeMock.mock
|
|
316
|
+
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
317
|
+
const replacementOnInitialized = vi.fn();
|
|
318
|
+
const params: McpAppInitializeParams = {
|
|
319
|
+
protocolVersion: "2026-01-26",
|
|
320
|
+
appInfo: { name: "example-app", version: "1.2.3" },
|
|
321
|
+
appCapabilities: { availableDisplayModes: ["inline", "pip"] },
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
rendered.rerender(view(replacementOnInitialized));
|
|
325
|
+
options.handlers?.onInitialized?.(params);
|
|
326
|
+
|
|
327
|
+
expect(onInitialized).not.toHaveBeenCalled();
|
|
328
|
+
expect(replacementOnInitialized).toHaveBeenCalledOnce();
|
|
329
|
+
expect(replacementOnInitialized).toHaveBeenCalledWith(params);
|
|
330
|
+
sandboxBridge.dispose();
|
|
331
|
+
});
|
|
332
|
+
|
|
237
333
|
it("disposes the underlying bridge with the sandbox bridge", () => {
|
|
238
334
|
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
239
335
|
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
@@ -312,7 +408,7 @@ describe("McpAppFrame", () => {
|
|
|
312
408
|
);
|
|
313
409
|
const options = createMcpAppBridgeMock.mock
|
|
314
410
|
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
315
|
-
options.handlers?.onInitialized?.();
|
|
411
|
+
options.handlers?.onInitialized?.({});
|
|
316
412
|
|
|
317
413
|
rendered.rerender(
|
|
318
414
|
view({ displayMode: "inline", availableDisplayModes: ["inline", "pip"] }),
|
|
@@ -391,7 +487,7 @@ describe("McpAppFrame", () => {
|
|
|
391
487
|
|
|
392
488
|
const options = createMcpAppBridgeMock.mock
|
|
393
489
|
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
394
|
-
options.handlers?.onInitialized?.();
|
|
490
|
+
options.handlers?.onInitialized?.({});
|
|
395
491
|
|
|
396
492
|
expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
|
|
397
493
|
|
|
@@ -436,7 +532,7 @@ describe("McpAppFrame", () => {
|
|
|
436
532
|
);
|
|
437
533
|
const options = createMcpAppBridgeMock.mock
|
|
438
534
|
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
439
|
-
options.handlers?.onInitialized?.();
|
|
535
|
+
options.handlers?.onInitialized?.({});
|
|
440
536
|
|
|
441
537
|
rendered.rerender(view());
|
|
442
538
|
expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
|
|
@@ -481,7 +577,7 @@ describe("McpAppFrame", () => {
|
|
|
481
577
|
);
|
|
482
578
|
const options = createMcpAppBridgeMock.mock
|
|
483
579
|
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
484
|
-
options.handlers?.onInitialized?.();
|
|
580
|
+
options.handlers?.onInitialized?.({});
|
|
485
581
|
|
|
486
582
|
rendered.rerender(view(new Date(2)));
|
|
487
583
|
expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(1);
|
|
@@ -527,7 +623,7 @@ describe("McpAppFrame", () => {
|
|
|
527
623
|
);
|
|
528
624
|
const options = createMcpAppBridgeMock.mock
|
|
529
625
|
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
530
|
-
options.handlers?.onInitialized?.();
|
|
626
|
+
options.handlers?.onInitialized?.({});
|
|
531
627
|
|
|
532
628
|
rendered.rerender(view({}));
|
|
533
629
|
expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
useEffect,
|
|
6
|
-
useLayoutEffect,
|
|
7
|
-
useRef,
|
|
8
|
-
} from "react";
|
|
3
|
+
import { type MutableRefObject, useEffect, useRef } from "react";
|
|
4
|
+
import { useIsomorphicLayoutEffect } from "../utils/useIsomorphicLayoutEffect";
|
|
9
5
|
import { type McpAppBridge, createMcpAppBridge } from "./bridge";
|
|
10
6
|
import {
|
|
11
7
|
SandboxHost,
|
|
@@ -19,6 +15,7 @@ import type {
|
|
|
19
15
|
McpAppHostContext,
|
|
20
16
|
} from "./types";
|
|
21
17
|
import { isRecord } from "@assistant-ui/core/internal";
|
|
18
|
+
import { applyMcpAppCsp } from "./csp";
|
|
22
19
|
|
|
23
20
|
const DEFAULT_PRODUCT = "assistant-ui-mcp-app";
|
|
24
21
|
const INIT_TIMEOUT_MS = 5000;
|
|
@@ -146,9 +143,9 @@ function buildLiveHandlers(
|
|
|
146
143
|
lifecycle.onSizeChange(p);
|
|
147
144
|
live()?.onSizeChange?.(p);
|
|
148
145
|
};
|
|
149
|
-
out.onInitialized = () => {
|
|
146
|
+
out.onInitialized = (params) => {
|
|
150
147
|
lifecycle.onInitialized();
|
|
151
|
-
live()?.onInitialized?.();
|
|
148
|
+
live()?.onInitialized?.(params);
|
|
152
149
|
};
|
|
153
150
|
out.onRequestTeardown = (p) => live()?.onRequestTeardown?.(p);
|
|
154
151
|
out.onLog = (p) => live()?.onLog?.(p);
|
|
@@ -190,7 +187,7 @@ export function McpAppFrame({
|
|
|
190
187
|
input,
|
|
191
188
|
output,
|
|
192
189
|
});
|
|
193
|
-
|
|
190
|
+
useIsomorphicLayoutEffect(() => {
|
|
194
191
|
liveRef.current = {
|
|
195
192
|
handlers,
|
|
196
193
|
hostInfo,
|
|
@@ -313,7 +310,7 @@ export function McpAppFrame({
|
|
|
313
310
|
|
|
314
311
|
return (
|
|
315
312
|
<SandboxHost
|
|
316
|
-
content={{ html: resource.html }}
|
|
313
|
+
content={{ html: applyMcpAppCsp(resource.html, resource.meta?.csp) }}
|
|
317
314
|
contentKey={
|
|
318
315
|
app.serverId ? `${app.serverId} ${resource.uri}` : resource.uri
|
|
319
316
|
}
|
|
@@ -197,6 +197,160 @@ describe("createMcpAppBridge", () => {
|
|
|
197
197
|
bridge.dispose();
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
+
it("passes view initialization metadata to onInitialized", () => {
|
|
201
|
+
const { frame } = makeFrame();
|
|
202
|
+
const onInitialized = vi.fn();
|
|
203
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
204
|
+
const params = {
|
|
205
|
+
protocolVersion: "2026-01-26",
|
|
206
|
+
appInfo: { name: "example-app", version: "1.2.3" },
|
|
207
|
+
appCapabilities: {
|
|
208
|
+
availableDisplayModes: ["inline", "fullscreen"],
|
|
209
|
+
tools: { listChanged: true },
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
deliver(bridge, {
|
|
214
|
+
jsonrpc: "2.0",
|
|
215
|
+
id: 1,
|
|
216
|
+
method: "ui/initialize",
|
|
217
|
+
params,
|
|
218
|
+
});
|
|
219
|
+
expect(onInitialized).not.toHaveBeenCalled();
|
|
220
|
+
deliver(bridge, {
|
|
221
|
+
jsonrpc: "2.0",
|
|
222
|
+
method: "notifications/initialized",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(onInitialized).toHaveBeenCalledOnce();
|
|
226
|
+
expect(onInitialized).toHaveBeenCalledWith(params);
|
|
227
|
+
bridge.dispose();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("omits malformed initialization fields and keeps the protocol fallback", async () => {
|
|
231
|
+
const { frame, captured } = makeFrame();
|
|
232
|
+
const onInitialized = vi.fn();
|
|
233
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
234
|
+
|
|
235
|
+
deliver(bridge, {
|
|
236
|
+
jsonrpc: "2.0",
|
|
237
|
+
id: 1,
|
|
238
|
+
method: "ui/initialize",
|
|
239
|
+
params: {
|
|
240
|
+
protocolVersion: 1,
|
|
241
|
+
appInfo: { name: "malformed" },
|
|
242
|
+
appCapabilities: { availableDisplayModes: "fullscreen" },
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
deliver(bridge, {
|
|
246
|
+
jsonrpc: "2.0",
|
|
247
|
+
method: "notifications/initialized",
|
|
248
|
+
});
|
|
249
|
+
await flush();
|
|
250
|
+
|
|
251
|
+
expect(onInitialized).toHaveBeenCalledOnce();
|
|
252
|
+
expect(onInitialized).toHaveBeenCalledWith({
|
|
253
|
+
appCapabilities: {},
|
|
254
|
+
});
|
|
255
|
+
expect((captured[0] as McpAppJsonRpcResponse).result).toMatchObject({
|
|
256
|
+
protocolVersion: MCP_APP_PROTOCOL_VERSION,
|
|
257
|
+
});
|
|
258
|
+
bridge.dispose();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("keeps only the display modes the host recognizes", () => {
|
|
262
|
+
const { frame } = makeFrame();
|
|
263
|
+
const onInitialized = vi.fn();
|
|
264
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
265
|
+
|
|
266
|
+
deliver(bridge, {
|
|
267
|
+
jsonrpc: "2.0",
|
|
268
|
+
id: 1,
|
|
269
|
+
method: "ui/initialize",
|
|
270
|
+
params: {
|
|
271
|
+
appCapabilities: { availableDisplayModes: ["inline", "sidebar"] },
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
deliver(bridge, {
|
|
275
|
+
jsonrpc: "2.0",
|
|
276
|
+
method: "notifications/initialized",
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(onInitialized).toHaveBeenCalledWith({
|
|
280
|
+
appCapabilities: { availableDisplayModes: ["inline"] },
|
|
281
|
+
});
|
|
282
|
+
bridge.dispose();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("reads a sparse display mode list without walking its length", () => {
|
|
286
|
+
const { frame } = makeFrame();
|
|
287
|
+
const onInitialized = vi.fn();
|
|
288
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
289
|
+
const availableDisplayModes = ["inline"];
|
|
290
|
+
availableDisplayModes.length = 2 ** 32 - 1;
|
|
291
|
+
|
|
292
|
+
deliver(bridge, {
|
|
293
|
+
jsonrpc: "2.0",
|
|
294
|
+
id: 1,
|
|
295
|
+
method: "ui/initialize",
|
|
296
|
+
params: { appCapabilities: { availableDisplayModes } },
|
|
297
|
+
});
|
|
298
|
+
deliver(bridge, {
|
|
299
|
+
jsonrpc: "2.0",
|
|
300
|
+
method: "notifications/initialized",
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(onInitialized).toHaveBeenCalledWith({
|
|
304
|
+
appCapabilities: { availableDisplayModes: ["inline"] },
|
|
305
|
+
});
|
|
306
|
+
bridge.dispose();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("initializes when the display mode list carries its own methods", async () => {
|
|
310
|
+
const { frame, captured } = makeFrame();
|
|
311
|
+
const onInitialized = vi.fn();
|
|
312
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
313
|
+
|
|
314
|
+
deliver(bridge, {
|
|
315
|
+
jsonrpc: "2.0",
|
|
316
|
+
id: 1,
|
|
317
|
+
method: "ui/initialize",
|
|
318
|
+
params: {
|
|
319
|
+
appCapabilities: {
|
|
320
|
+
availableDisplayModes: Object.assign(["inline", "pip"], {
|
|
321
|
+
filter: 0,
|
|
322
|
+
constructor: 0,
|
|
323
|
+
}),
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
deliver(bridge, {
|
|
328
|
+
jsonrpc: "2.0",
|
|
329
|
+
method: "notifications/initialized",
|
|
330
|
+
});
|
|
331
|
+
await flush();
|
|
332
|
+
|
|
333
|
+
expect((captured[0] as McpAppJsonRpcResponse).error).toBeUndefined();
|
|
334
|
+
expect(onInitialized).toHaveBeenCalledWith({
|
|
335
|
+
appCapabilities: { availableDisplayModes: ["inline", "pip"] },
|
|
336
|
+
});
|
|
337
|
+
bridge.dispose();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("calls onInitialized with empty params when ui/initialize never arrived", () => {
|
|
341
|
+
const { frame } = makeFrame();
|
|
342
|
+
const onInitialized = vi.fn();
|
|
343
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
344
|
+
|
|
345
|
+
deliver(bridge, {
|
|
346
|
+
jsonrpc: "2.0",
|
|
347
|
+
method: "notifications/initialized",
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
expect(onInitialized).toHaveBeenCalledWith({});
|
|
351
|
+
bridge.dispose();
|
|
352
|
+
});
|
|
353
|
+
|
|
200
354
|
it("routes tools/call to handler", async () => {
|
|
201
355
|
const { frame, captured } = makeFrame();
|
|
202
356
|
const callTool = vi.fn().mockResolvedValue({ ok: true });
|
package/src/mcp-apps/bridge.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type McpAppDisplayMode,
|
|
7
7
|
type McpAppHostContext,
|
|
8
8
|
type McpAppHostInfo,
|
|
9
|
+
type McpAppInitializeParams,
|
|
9
10
|
type McpAppJsonRpcMessage,
|
|
10
11
|
type McpAppJsonRpcNotification,
|
|
11
12
|
type McpAppJsonRpcRequest,
|
|
@@ -19,6 +20,50 @@ const VALID_DISPLAY_MODES = [
|
|
|
19
20
|
"pip",
|
|
20
21
|
] as const satisfies readonly McpAppDisplayMode[];
|
|
21
22
|
|
|
23
|
+
function isMcpAppDisplayMode(value: unknown): value is McpAppDisplayMode {
|
|
24
|
+
return VALID_DISPLAY_MODES.some((mode) => mode === value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isAppInfo(
|
|
28
|
+
value: unknown,
|
|
29
|
+
): value is NonNullable<McpAppInitializeParams["appInfo"]> {
|
|
30
|
+
return (
|
|
31
|
+
isRecord(value) &&
|
|
32
|
+
typeof value.name === "string" &&
|
|
33
|
+
typeof value.version === "string"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getAppCapabilities(
|
|
38
|
+
value: unknown,
|
|
39
|
+
): McpAppInitializeParams["appCapabilities"] {
|
|
40
|
+
if (!isRecord(value)) return undefined;
|
|
41
|
+
const { availableDisplayModes, ...rest } = value;
|
|
42
|
+
return {
|
|
43
|
+
...rest,
|
|
44
|
+
// Object.values reads only the entries present, so a sparse list claiming a huge length stays cheap, and the list's own methods are never called.
|
|
45
|
+
...(Array.isArray(availableDisplayModes)
|
|
46
|
+
? {
|
|
47
|
+
availableDisplayModes: Object.values(availableDisplayModes).filter(
|
|
48
|
+
isMcpAppDisplayMode,
|
|
49
|
+
),
|
|
50
|
+
}
|
|
51
|
+
: {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getInitializeParams(params: unknown): McpAppInitializeParams {
|
|
56
|
+
if (!isRecord(params)) return {};
|
|
57
|
+
const appCapabilities = getAppCapabilities(params.appCapabilities);
|
|
58
|
+
return {
|
|
59
|
+
...(isAppInfo(params.appInfo) ? { appInfo: params.appInfo } : {}),
|
|
60
|
+
...(appCapabilities ? { appCapabilities } : {}),
|
|
61
|
+
...(typeof params.protocolVersion === "string"
|
|
62
|
+
? { protocolVersion: params.protocolVersion }
|
|
63
|
+
: {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
22
67
|
export type McpAppBridgeFrame = SandboxHostFrame;
|
|
23
68
|
|
|
24
69
|
export type CreateMcpAppBridgeOptions = {
|
|
@@ -91,6 +136,7 @@ export function createMcpAppBridge(
|
|
|
91
136
|
hostContext = {},
|
|
92
137
|
} = opts;
|
|
93
138
|
let disposed = false;
|
|
139
|
+
let initializeParams: McpAppInitializeParams = {};
|
|
94
140
|
|
|
95
141
|
const post = (msg: McpAppJsonRpcMessage) => {
|
|
96
142
|
if (disposed) return;
|
|
@@ -142,10 +188,9 @@ export function createMcpAppBridge(
|
|
|
142
188
|
|
|
143
189
|
switch (normalizeMethod(req.method)) {
|
|
144
190
|
case "ui/initialize": {
|
|
191
|
+
initializeParams = getInitializeParams(params);
|
|
145
192
|
const requestedProtocolVersion =
|
|
146
|
-
|
|
147
|
-
? params.protocolVersion
|
|
148
|
-
: MCP_APP_PROTOCOL_VERSION;
|
|
193
|
+
initializeParams.protocolVersion ?? MCP_APP_PROTOCOL_VERSION;
|
|
149
194
|
respond(req.id, {
|
|
150
195
|
result: {
|
|
151
196
|
protocolVersion: requestedProtocolVersion,
|
|
@@ -360,10 +405,7 @@ export function createMcpAppBridge(
|
|
|
360
405
|
return;
|
|
361
406
|
}
|
|
362
407
|
const modeParams = (params ?? {}) as { mode?: unknown };
|
|
363
|
-
if (
|
|
364
|
-
typeof modeParams.mode !== "string" ||
|
|
365
|
-
!VALID_DISPLAY_MODES.includes(modeParams.mode as McpAppDisplayMode)
|
|
366
|
-
) {
|
|
408
|
+
if (!isMcpAppDisplayMode(modeParams.mode)) {
|
|
367
409
|
errorResponse(
|
|
368
410
|
req.id,
|
|
369
411
|
JSONRPC_ERROR.invalidParams,
|
|
@@ -373,7 +415,7 @@ export function createMcpAppBridge(
|
|
|
373
415
|
}
|
|
374
416
|
respond(req.id, {
|
|
375
417
|
result: await handlers.requestDisplayMode({
|
|
376
|
-
mode: modeParams.mode
|
|
418
|
+
mode: modeParams.mode,
|
|
377
419
|
}),
|
|
378
420
|
});
|
|
379
421
|
return;
|
|
@@ -398,7 +440,7 @@ export function createMcpAppBridge(
|
|
|
398
440
|
try {
|
|
399
441
|
switch (normalizeMethod(note.method)) {
|
|
400
442
|
case "notifications/initialized": {
|
|
401
|
-
handlers.onInitialized?.();
|
|
443
|
+
handlers.onInitialized?.(initializeParams);
|
|
402
444
|
return;
|
|
403
445
|
}
|
|
404
446
|
case "notifications/size_changed": {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { applyMcpAppCsp, buildMcpAppCsp } from "./csp";
|
|
5
|
+
|
|
6
|
+
const getPolicyMeta = (html: string) =>
|
|
7
|
+
new DOMParser()
|
|
8
|
+
.parseFromString(applyMcpAppCsp(html), "text/html")
|
|
9
|
+
.head.querySelector('meta[http-equiv="Content-Security-Policy"]');
|
|
10
|
+
|
|
11
|
+
const expectPolicyBeforeScript = (html: string) => {
|
|
12
|
+
const policyIndex = html.indexOf("Content-Security-Policy");
|
|
13
|
+
const scriptIndex = html.indexOf("<script");
|
|
14
|
+
expect(policyIndex).toBeGreaterThanOrEqual(0);
|
|
15
|
+
expect(scriptIndex).toBeGreaterThanOrEqual(0);
|
|
16
|
+
expect(policyIndex).toBeLessThan(scriptIndex);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("MCP App CSP", () => {
|
|
20
|
+
it("uses restrictive defaults when the resource omits CSP metadata", () => {
|
|
21
|
+
expect(buildMcpAppCsp()).toBe(
|
|
22
|
+
"default-src 'none'; " +
|
|
23
|
+
"script-src 'self' 'unsafe-inline'; " +
|
|
24
|
+
"style-src 'self' 'unsafe-inline'; " +
|
|
25
|
+
"connect-src 'none'; " +
|
|
26
|
+
"img-src 'self' data:; " +
|
|
27
|
+
"font-src 'self'; " +
|
|
28
|
+
"media-src 'self' data:; " +
|
|
29
|
+
"frame-src 'none'; " +
|
|
30
|
+
"base-uri 'self'; " +
|
|
31
|
+
"object-src 'none'; " +
|
|
32
|
+
"form-action 'none'",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("allows only valid declared origins", () => {
|
|
37
|
+
const policy = buildMcpAppCsp({
|
|
38
|
+
connectDomains: [
|
|
39
|
+
"https://api.example",
|
|
40
|
+
"wss://events.example:8443",
|
|
41
|
+
"https://api.example; default-src *",
|
|
42
|
+
],
|
|
43
|
+
resourceDomains: ["https://*.cdn.example", "wss://assets.example"],
|
|
44
|
+
frameDomains: ["https://video.example/path"],
|
|
45
|
+
baseUriDomains: ["https://base.example"],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(policy.split("; ")).toContain(
|
|
49
|
+
"connect-src 'self' https://api.example wss://events.example:8443",
|
|
50
|
+
);
|
|
51
|
+
expect(
|
|
52
|
+
buildMcpAppCsp({
|
|
53
|
+
resourceDomains: ["https://*.cdn.example", "wss://assets.example"],
|
|
54
|
+
}),
|
|
55
|
+
).toContain("script-src 'self' 'unsafe-inline' https://*.cdn.example");
|
|
56
|
+
expect(
|
|
57
|
+
buildMcpAppCsp({ resourceDomains: ["wss://assets.example"] }),
|
|
58
|
+
).toContain("script-src 'self' 'unsafe-inline';");
|
|
59
|
+
expect(
|
|
60
|
+
buildMcpAppCsp({ frameDomains: ["https://video.example/path"] }),
|
|
61
|
+
).toContain("frame-src 'none'");
|
|
62
|
+
expect(
|
|
63
|
+
buildMcpAppCsp({ baseUriDomains: ["https://base.example"] }),
|
|
64
|
+
).toContain("base-uri https://base.example");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("falls back to the restrictive policy for malformed metadata", () => {
|
|
68
|
+
expect(
|
|
69
|
+
buildMcpAppCsp({
|
|
70
|
+
connectDomains: "https://api.example" as unknown as string[],
|
|
71
|
+
}),
|
|
72
|
+
).toContain("connect-src 'none'");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("places the policy after a doctype and before executable content", () => {
|
|
76
|
+
const html =
|
|
77
|
+
'\uFEFF<!-- license --><!doctype html><script src="https://cdn.example/app.js"></script>';
|
|
78
|
+
const secured = applyMcpAppCsp(html);
|
|
79
|
+
|
|
80
|
+
expect(secured.startsWith("<!-- license --><!doctype html><meta ")).toBe(
|
|
81
|
+
true,
|
|
82
|
+
);
|
|
83
|
+
expectPolicyBeforeScript(secured);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it.each([
|
|
87
|
+
"<!--><script>run()</script>-->",
|
|
88
|
+
"<!---><script>run()</script>",
|
|
89
|
+
"<!-- license --!><script>run()</script>",
|
|
90
|
+
])("places the policy before scripts after HTML comment closers", (html) => {
|
|
91
|
+
expectPolicyBeforeScript(applyMcpAppCsp(html));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it.each([
|
|
95
|
+
'<?xml version="1.0"?><!doctype html><script>run()</script>',
|
|
96
|
+
"<!foo><!doctype html><script>run()</script>",
|
|
97
|
+
])("preserves standards mode after leading bogus comments", (html) => {
|
|
98
|
+
const secured = applyMcpAppCsp(html);
|
|
99
|
+
const document = new DOMParser().parseFromString(secured, "text/html");
|
|
100
|
+
|
|
101
|
+
expect(secured.indexOf("<!doctype html><meta ")).toBeGreaterThan(0);
|
|
102
|
+
expect(document.compatMode).toBe("CSS1Compat");
|
|
103
|
+
expectPolicyBeforeScript(secured);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it.each([
|
|
107
|
+
"\u00a0<script>run()</script>",
|
|
108
|
+
"\uFEFF\uFEFF<script>run()</script>",
|
|
109
|
+
])("keeps the policy in head before non-HTML whitespace", (html) => {
|
|
110
|
+
expect(getPolicyMeta(html)).not.toBeNull();
|
|
111
|
+
});
|
|
112
|
+
});
|