@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.
Files changed (159) hide show
  1. package/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
  2. package/dist/hooks/useToolCallElapsed.js +3 -2
  3. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  4. package/dist/index.d.ts +31 -5
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  7. package/dist/mcp-apps/McpAppRenderer.js +3 -2
  8. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  9. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  10. package/dist/mcp-apps/app-frame.js +58 -48
  11. package/dist/mcp-apps/app-frame.js.map +1 -1
  12. package/dist/mcp-apps/bridge.d.ts.map +1 -1
  13. package/dist/mcp-apps/bridge.js +28 -3
  14. package/dist/mcp-apps/bridge.js.map +1 -1
  15. package/dist/mcp-apps/csp.d.ts +4 -0
  16. package/dist/mcp-apps/csp.d.ts.map +1 -0
  17. package/dist/mcp-apps/csp.js +93 -0
  18. package/dist/mcp-apps/csp.js.map +1 -0
  19. package/dist/mcp-apps/index.d.ts +1 -1
  20. package/dist/mcp-apps/index.d.ts.map +1 -1
  21. package/dist/mcp-apps/types.d.ts +19 -1
  22. package/dist/mcp-apps/types.d.ts.map +1 -1
  23. package/dist/mcp-apps/types.js.map +1 -1
  24. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts +1 -0
  25. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  26. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  27. package/dist/primitives/attachment.d.ts +3 -1
  28. package/dist/primitives/attachment.d.ts.map +1 -1
  29. package/dist/primitives/attachment.js.map +1 -1
  30. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  31. package/dist/primitives/composer/ComposerAttachmentDropzone.js +14 -12
  32. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  33. package/dist/primitives/composer/ComposerCancel.d.ts +5 -1
  34. package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
  35. package/dist/primitives/composer/ComposerCancel.js +14 -1
  36. package/dist/primitives/composer/ComposerCancel.js.map +1 -1
  37. package/dist/primitives/composer/ComposerInput.d.ts +14 -0
  38. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  39. package/dist/primitives/composer/ComposerInput.js +4 -3
  40. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  41. package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
  42. package/dist/primitives/composer/ComposerRoot.js +24 -19
  43. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  44. package/dist/primitives/composer/composerInputSelector.d.ts +3 -0
  45. package/dist/primitives/composer/composerInputSelector.d.ts.map +1 -0
  46. package/dist/primitives/composer/composerInputSelector.js +7 -0
  47. package/dist/primitives/composer/composerInputSelector.js.map +1 -0
  48. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts +2 -0
  49. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts.map +1 -0
  50. package/dist/primitives/composer/useComposerCancelWithFocus.js +42 -0
  51. package/dist/primitives/composer/useComposerCancelWithFocus.js.map +1 -0
  52. package/dist/primitives/composer.d.ts +35 -14
  53. package/dist/primitives/composer.d.ts.map +1 -1
  54. package/dist/primitives/composer.js.map +1 -1
  55. package/dist/primitives/message/MessagePartsGrouped.d.ts +8 -1
  56. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  57. package/dist/primitives/message/MessagePartsGrouped.js +58 -278
  58. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  59. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
  60. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -2
  61. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  62. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  63. package/dist/primitives/thread/ThreadViewport.js +4 -3
  64. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  65. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +2 -2
  66. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
  67. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  68. package/dist/primitives/thread/useThreadViewportAutoScroll.js +90 -21
  69. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  70. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  71. package/dist/sandbox-host/SandboxHost.js +3 -2
  72. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  73. package/dist/unstable/useComposerInput.d.ts +9 -4
  74. package/dist/unstable/useComposerInput.d.ts.map +1 -1
  75. package/dist/unstable/useComposerInput.js +6 -4
  76. package/dist/unstable/useComposerInput.js.map +1 -1
  77. package/dist/unstable/useComposerInputHistory.d.ts +3 -2
  78. package/dist/unstable/useComposerInputHistory.d.ts.map +1 -1
  79. package/dist/unstable/useComposerInputHistory.js +2 -2
  80. package/dist/unstable/useComposerInputHistory.js.map +1 -1
  81. package/dist/unstable/useLiveCompletionAdapter.d.ts +3 -2
  82. package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
  83. package/dist/unstable/useLiveCompletionAdapter.js +8 -7
  84. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  85. package/dist/unstable/useMentionAdapter.d.ts +12 -3
  86. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  87. package/dist/unstable/useMentionAdapter.js +2 -2
  88. package/dist/unstable/useMentionAdapter.js.map +1 -1
  89. package/dist/unstable/useMessageStallDetection.d.ts +4 -2
  90. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  91. package/dist/unstable/useMessageStallDetection.js +2 -2
  92. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  93. package/dist/unstable/useSlashCommandAdapter.d.ts +5 -2
  94. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  95. package/dist/unstable/useSlashCommandAdapter.js +5 -4
  96. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  97. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +4 -0
  98. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  99. package/dist/unstable/webmcp/useWebMcpProvider.js +2 -0
  100. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  101. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  102. package/dist/utils/getSelectionMessageId.js +31 -8
  103. package/dist/utils/getSelectionMessageId.js.map +1 -1
  104. package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
  105. package/dist/utils/hooks/useOnResizeContent.js +2 -22
  106. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  107. package/dist/utils/useIsomorphicLayoutEffect.d.ts +3 -0
  108. package/dist/utils/useIsomorphicLayoutEffect.d.ts.map +1 -0
  109. package/dist/utils/useIsomorphicLayoutEffect.js +6 -0
  110. package/dist/utils/useIsomorphicLayoutEffect.js.map +1 -0
  111. package/package.json +10 -10
  112. package/src/hooks/useToolCallElapsed.test.tsx +4 -2
  113. package/src/hooks/useToolCallElapsed.ts +3 -2
  114. package/src/index.ts +20 -2
  115. package/src/mcp-apps/McpAppRenderer.test.tsx +7 -2
  116. package/src/mcp-apps/McpAppRenderer.tsx +3 -9
  117. package/src/mcp-apps/app-frame.test.tsx +102 -6
  118. package/src/mcp-apps/app-frame.tsx +7 -10
  119. package/src/mcp-apps/bridge.test.ts +154 -0
  120. package/src/mcp-apps/bridge.ts +51 -9
  121. package/src/mcp-apps/csp.test.ts +112 -0
  122. package/src/mcp-apps/csp.ts +124 -0
  123. package/src/mcp-apps/index.ts +1 -0
  124. package/src/mcp-apps/types.ts +16 -1
  125. package/src/model-context/frame/AssistantFrame.test.ts +45 -0
  126. package/src/model-context/frame/SPEC_AssistantFrame.md +1 -0
  127. package/src/primitives/assistantModal/AssistantModalRoot.tsx +1 -0
  128. package/src/primitives/attachment.ts +4 -1
  129. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +32 -1
  130. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +14 -12
  131. package/src/primitives/composer/ComposerCancel.focus.test.tsx +307 -0
  132. package/src/primitives/composer/ComposerCancel.ts +8 -1
  133. package/src/primitives/composer/ComposerDisabledThread.test.tsx +121 -0
  134. package/src/primitives/composer/ComposerInput.tsx +19 -1
  135. package/src/primitives/composer/ComposerRoot.tsx +6 -5
  136. package/src/primitives/composer/composerInputSelector.ts +4 -0
  137. package/src/primitives/composer/useComposerCancelWithFocus.ts +57 -0
  138. package/src/primitives/composer.ts +56 -21
  139. package/src/primitives/message/MessagePartsGrouped.test.tsx +106 -2
  140. package/src/primitives/message/MessagePartsGrouped.tsx +36 -134
  141. package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +20 -12
  142. package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +6 -2
  143. package/src/primitives/thread/ThreadViewport.tsx +3 -3
  144. package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +2 -2
  145. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +666 -63
  146. package/src/primitives/thread/useThreadViewportAutoScroll.ts +201 -73
  147. package/src/sandbox-host/SandboxHost.tsx +3 -8
  148. package/src/unstable/useComposerInput.ts +9 -4
  149. package/src/unstable/useComposerInputHistory.ts +3 -2
  150. package/src/unstable/useLiveCompletionAdapter.ts +9 -14
  151. package/src/unstable/useMentionAdapter.ts +12 -3
  152. package/src/unstable/useMessageStallDetection.ts +4 -2
  153. package/src/unstable/useSlashCommandAdapter.ts +8 -4
  154. package/src/unstable/webmcp/useWebMcpProvider.ts +4 -0
  155. package/src/utils/getSelectionMessageId.test.ts +231 -0
  156. package/src/utils/getSelectionMessageId.ts +45 -7
  157. package/src/utils/hooks/useOnResizeContent.ts +2 -29
  158. package/src/utils/useIsomorphicLayoutEffect.test.tsx +21 -0
  159. 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 { MCP_APP_MIME_TYPE, type McpAppHostContext } from "./types";
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
- type MutableRefObject,
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
- useLayoutEffect(() => {
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 });
@@ -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
- isRecord(params) && typeof params.protocolVersion === "string"
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 as McpAppDisplayMode,
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
+ });