@assistant-ui/react 0.15.25 → 0.15.26

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 (144) 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 +30 -4
  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 +56 -46
  11. package/dist/mcp-apps/app-frame.js.map +1 -1
  12. package/dist/mcp-apps/csp.d.ts +4 -0
  13. package/dist/mcp-apps/csp.d.ts.map +1 -0
  14. package/dist/mcp-apps/csp.js +93 -0
  15. package/dist/mcp-apps/csp.js.map +1 -0
  16. package/dist/mcp-apps/types.d.ts +1 -0
  17. package/dist/mcp-apps/types.d.ts.map +1 -1
  18. package/dist/mcp-apps/types.js.map +1 -1
  19. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts +1 -0
  20. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  21. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  22. package/dist/primitives/attachment.d.ts +3 -1
  23. package/dist/primitives/attachment.d.ts.map +1 -1
  24. package/dist/primitives/attachment.js.map +1 -1
  25. package/dist/primitives/composer/ComposerCancel.d.ts +5 -1
  26. package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
  27. package/dist/primitives/composer/ComposerCancel.js +14 -1
  28. package/dist/primitives/composer/ComposerCancel.js.map +1 -1
  29. package/dist/primitives/composer/ComposerInput.d.ts +14 -0
  30. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  31. package/dist/primitives/composer/ComposerInput.js +4 -3
  32. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  33. package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
  34. package/dist/primitives/composer/ComposerRoot.js +24 -19
  35. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  36. package/dist/primitives/composer/composerInputSelector.d.ts +3 -0
  37. package/dist/primitives/composer/composerInputSelector.d.ts.map +1 -0
  38. package/dist/primitives/composer/composerInputSelector.js +7 -0
  39. package/dist/primitives/composer/composerInputSelector.js.map +1 -0
  40. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts +2 -0
  41. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts.map +1 -0
  42. package/dist/primitives/composer/useComposerCancelWithFocus.js +42 -0
  43. package/dist/primitives/composer/useComposerCancelWithFocus.js.map +1 -0
  44. package/dist/primitives/composer.d.ts +35 -14
  45. package/dist/primitives/composer.d.ts.map +1 -1
  46. package/dist/primitives/composer.js.map +1 -1
  47. package/dist/primitives/message/MessagePartsGrouped.d.ts +8 -1
  48. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  49. package/dist/primitives/message/MessagePartsGrouped.js +58 -278
  50. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  51. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
  52. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -2
  53. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  54. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  55. package/dist/primitives/thread/ThreadViewport.js +4 -3
  56. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  57. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +2 -2
  58. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
  59. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  60. package/dist/primitives/thread/useThreadViewportAutoScroll.js +90 -21
  61. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  62. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  63. package/dist/sandbox-host/SandboxHost.js +3 -2
  64. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  65. package/dist/unstable/useComposerInput.d.ts +7 -4
  66. package/dist/unstable/useComposerInput.d.ts.map +1 -1
  67. package/dist/unstable/useComposerInput.js +4 -4
  68. package/dist/unstable/useComposerInput.js.map +1 -1
  69. package/dist/unstable/useComposerInputHistory.d.ts +3 -2
  70. package/dist/unstable/useComposerInputHistory.d.ts.map +1 -1
  71. package/dist/unstable/useComposerInputHistory.js +2 -2
  72. package/dist/unstable/useComposerInputHistory.js.map +1 -1
  73. package/dist/unstable/useLiveCompletionAdapter.d.ts +3 -2
  74. package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
  75. package/dist/unstable/useLiveCompletionAdapter.js +8 -7
  76. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  77. package/dist/unstable/useMentionAdapter.d.ts +12 -3
  78. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  79. package/dist/unstable/useMentionAdapter.js +2 -2
  80. package/dist/unstable/useMentionAdapter.js.map +1 -1
  81. package/dist/unstable/useMessageStallDetection.d.ts +4 -2
  82. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  83. package/dist/unstable/useMessageStallDetection.js +2 -2
  84. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  85. package/dist/unstable/useSlashCommandAdapter.d.ts +5 -2
  86. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  87. package/dist/unstable/useSlashCommandAdapter.js +5 -4
  88. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  89. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +4 -0
  90. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  91. package/dist/unstable/webmcp/useWebMcpProvider.js +2 -0
  92. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  93. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  94. package/dist/utils/getSelectionMessageId.js +31 -8
  95. package/dist/utils/getSelectionMessageId.js.map +1 -1
  96. package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
  97. package/dist/utils/hooks/useOnResizeContent.js +2 -22
  98. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  99. package/dist/utils/useIsomorphicLayoutEffect.d.ts +3 -0
  100. package/dist/utils/useIsomorphicLayoutEffect.d.ts.map +1 -0
  101. package/dist/utils/useIsomorphicLayoutEffect.js +6 -0
  102. package/dist/utils/useIsomorphicLayoutEffect.js.map +1 -0
  103. package/package.json +10 -10
  104. package/src/hooks/useToolCallElapsed.test.tsx +4 -2
  105. package/src/hooks/useToolCallElapsed.ts +3 -2
  106. package/src/index.ts +19 -2
  107. package/src/mcp-apps/McpAppRenderer.tsx +3 -9
  108. package/src/mcp-apps/app-frame.test.tsx +38 -0
  109. package/src/mcp-apps/app-frame.tsx +5 -8
  110. package/src/mcp-apps/csp.test.ts +112 -0
  111. package/src/mcp-apps/csp.ts +124 -0
  112. package/src/mcp-apps/types.ts +1 -0
  113. package/src/model-context/frame/AssistantFrame.test.ts +45 -0
  114. package/src/model-context/frame/SPEC_AssistantFrame.md +1 -0
  115. package/src/primitives/assistantModal/AssistantModalRoot.tsx +1 -0
  116. package/src/primitives/attachment.ts +4 -1
  117. package/src/primitives/composer/ComposerCancel.focus.test.tsx +307 -0
  118. package/src/primitives/composer/ComposerCancel.ts +8 -1
  119. package/src/primitives/composer/ComposerInput.tsx +19 -1
  120. package/src/primitives/composer/ComposerRoot.tsx +6 -5
  121. package/src/primitives/composer/composerInputSelector.ts +4 -0
  122. package/src/primitives/composer/useComposerCancelWithFocus.ts +57 -0
  123. package/src/primitives/composer.ts +56 -21
  124. package/src/primitives/message/MessagePartsGrouped.test.tsx +106 -2
  125. package/src/primitives/message/MessagePartsGrouped.tsx +36 -134
  126. package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +20 -12
  127. package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +6 -2
  128. package/src/primitives/thread/ThreadViewport.tsx +3 -3
  129. package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +2 -2
  130. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +666 -63
  131. package/src/primitives/thread/useThreadViewportAutoScroll.ts +201 -73
  132. package/src/sandbox-host/SandboxHost.tsx +3 -8
  133. package/src/unstable/useComposerInput.ts +7 -4
  134. package/src/unstable/useComposerInputHistory.ts +3 -2
  135. package/src/unstable/useLiveCompletionAdapter.ts +9 -14
  136. package/src/unstable/useMentionAdapter.ts +12 -3
  137. package/src/unstable/useMessageStallDetection.ts +4 -2
  138. package/src/unstable/useSlashCommandAdapter.ts +8 -4
  139. package/src/unstable/webmcp/useWebMcpProvider.ts +4 -0
  140. package/src/utils/getSelectionMessageId.test.ts +231 -0
  141. package/src/utils/getSelectionMessageId.ts +45 -7
  142. package/src/utils/hooks/useOnResizeContent.ts +2 -29
  143. package/src/utils/useIsomorphicLayoutEffect.test.tsx +21 -0
  144. package/src/utils/useIsomorphicLayoutEffect.ts +9 -0
package/src/index.ts CHANGED
@@ -164,11 +164,17 @@ export { useExternalStoreRuntime } from "./legacy-runtime/runtime-cores/external
164
164
  export { useExternalStoreSharedOptions } from "@assistant-ui/core/react";
165
165
  export {
166
166
  useExternalMessageConverter,
167
+ /** @deprecated Experimental since 2025-01-26. Not scheduled for removal; the API may change in any release. */
167
168
  convertExternalMessages as unstable_convertExternalMessages,
169
+ /** @deprecated Experimental since 2026-09-15. Not scheduled for removal; the API may change in any release. */
168
170
  createExternalMessageConversionCache as unstable_createExternalMessageConversionCache,
171
+ /** @deprecated Experimental since 2026-09-15. Not scheduled for removal; the API may change in any release. */
169
172
  type ExternalMessageConversionCache as Unstable_ExternalMessageConversionCache,
170
173
  } from "./legacy-runtime/runtime-cores/external-store/external-message-converter";
171
- export { createMessageConverter as unstable_createMessageConverter } from "./legacy-runtime/runtime-cores/external-store/createMessageConverter";
174
+ export {
175
+ /** @deprecated Experimental since 2025-02-02. Not scheduled for removal; the API may change in any release. */
176
+ createMessageConverter as unstable_createMessageConverter,
177
+ } from "./legacy-runtime/runtime-cores/external-store/createMessageConverter";
172
178
 
173
179
  // --- local ---
174
180
  export type {
@@ -496,15 +502,23 @@ export type { ToolExecutionStatus } from "./internal";
496
502
 
497
503
  // Unstable - trigger popover (unified root for @ mentions, / slash commands, etc.)
498
504
  export {
505
+ /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
499
506
  useTriggerPopoverRootContext as unstable_useTriggerPopoverRootContext,
507
+ /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
500
508
  useTriggerPopoverRootContextOptional as unstable_useTriggerPopoverRootContextOptional,
509
+ /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
501
510
  useTriggerPopoverScopeContext as unstable_useTriggerPopoverScopeContext,
511
+ /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
502
512
  useTriggerPopoverScopeContextOptional as unstable_useTriggerPopoverScopeContextOptional,
513
+ /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
503
514
  useTriggerPopoverTriggers as unstable_useTriggerPopoverTriggers,
515
+ /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
504
516
  useTriggerPopoverTriggersOptional as unstable_useTriggerPopoverTriggersOptional,
517
+ /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
505
518
  type RegisteredTrigger as Unstable_RegisteredTrigger,
506
519
  type TriggerMatch,
507
520
  type TriggerMatcher,
521
+ /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
508
522
  type TriggerBehavior as Unstable_TriggerBehavior,
509
523
  } from "./primitives/composer/trigger";
510
524
  import type {
@@ -584,7 +598,10 @@ export {
584
598
  type Unstable_WebMcpProviderOptions,
585
599
  type Unstable_WebMcpProviderResult,
586
600
  } from "./unstable/webmcp/useWebMcpProvider";
587
- export { defaultWebMcpFilter as unstable_defaultWebMcpFilter } from "./unstable/webmcp/convertTools";
601
+ export {
602
+ /** @deprecated Experimental since 2026-08-29. Not scheduled for removal; the API may change in any release. */
603
+ defaultWebMcpFilter as unstable_defaultWebMcpFilter,
604
+ } from "./unstable/webmcp/convertTools";
588
605
 
589
606
  // Shared surface carried by every distribution (scripts/check-distribution-barrels.mjs)
590
607
  export type {
@@ -1,13 +1,7 @@
1
1
  "use client";
2
2
 
3
- import {
4
- useEffect,
5
- useLayoutEffect,
6
- useMemo,
7
- useRef,
8
- useState,
9
- type ReactNode,
10
- } from "react";
3
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
4
+ import { useIsomorphicLayoutEffect } from "../utils/useIsomorphicLayoutEffect";
11
5
  import type {
12
6
  McpAppMetadata,
13
7
  TextMessagePart,
@@ -204,7 +198,7 @@ function InlineRenderer({
204
198
  const opts = resolvePartOptions(rendererOptions, part);
205
199
  const app = getMcpAppFromToolPart(part);
206
200
  const cachedAppRef = useRef<McpAppMetadata | undefined>(undefined);
207
- useLayoutEffect(() => {
201
+ useIsomorphicLayoutEffect(() => {
208
202
  if (app != null) cachedAppRef.current = app;
209
203
  }, [app]);
210
204
  const appForRender = app ?? cachedAppRef.current;
@@ -31,6 +31,44 @@ describe("McpAppFrame", () => {
31
31
  cleanup();
32
32
  });
33
33
 
34
+ it("enforces the resource CSP before rendering widget HTML", () => {
35
+ let sandboxProps: SandboxHostProps | undefined;
36
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
37
+ sandboxProps = props;
38
+ return null;
39
+ });
40
+
41
+ render(
42
+ <McpAppFrame
43
+ app={{ resourceUri: "ui://example/widget" }}
44
+ resource={{
45
+ uri: "ui://example/widget",
46
+ mimeType: MCP_APP_MIME_TYPE,
47
+ html: '<!doctype html><script src="https://cdn.example/app.js"></script>',
48
+ meta: {
49
+ csp: {
50
+ connectDomains: ["https://api.example"],
51
+ resourceDomains: ["https://cdn.example"],
52
+ },
53
+ },
54
+ }}
55
+ />,
56
+ );
57
+
58
+ expect(sandboxProps?.content.html).toContain(
59
+ 'http-equiv="Content-Security-Policy"',
60
+ );
61
+ expect(sandboxProps?.content.html).toContain(
62
+ "connect-src 'self' https://api.example",
63
+ );
64
+ expect(sandboxProps?.content.html).toContain(
65
+ "script-src 'self' 'unsafe-inline' https://cdn.example",
66
+ );
67
+ expect(
68
+ sandboxProps?.content.html.indexOf("Content-Security-Policy"),
69
+ ).toBeLessThan(sandboxProps?.content.html.indexOf("<script") ?? 0);
70
+ });
71
+
34
72
  it("keeps bridge options scoped to committed renders", async () => {
35
73
  let committedCreateBridge: SandboxHostProps["createBridge"] | null = null;
36
74
  sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
@@ -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;
@@ -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
  }
@@ -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
+ });
@@ -0,0 +1,124 @@
1
+ import type { McpAppResourceCSP } from "./types";
2
+
3
+ const isAllowedOrigin = (value: string, allowWebSocket: boolean): boolean => {
4
+ const scheme = allowWebSocket ? /^(?:https?|wss?):\/\//i : /^https?:\/\//i;
5
+ if (!scheme.test(value) || /[\s;'"`,]/.test(value)) {
6
+ return false;
7
+ }
8
+
9
+ try {
10
+ const url = new URL(value.replace("://*.", "://wildcard."));
11
+ return (
12
+ url.username === "" &&
13
+ url.password === "" &&
14
+ url.pathname === "/" &&
15
+ url.search === "" &&
16
+ url.hash === ""
17
+ );
18
+ } catch {
19
+ return false;
20
+ }
21
+ };
22
+
23
+ const getAllowedOrigins = (
24
+ values: unknown,
25
+ allowWebSocket = false,
26
+ ): string[] =>
27
+ Array.isArray(values)
28
+ ? [
29
+ ...new Set(
30
+ values.filter(
31
+ (value): value is string =>
32
+ typeof value === "string" &&
33
+ isAllowedOrigin(value, allowWebSocket),
34
+ ),
35
+ ),
36
+ ]
37
+ : [];
38
+
39
+ const directive = (name: string, values: readonly string[]) =>
40
+ `${name} ${values.join(" ")}`;
41
+
42
+ export const buildMcpAppCsp = (csp?: McpAppResourceCSP): string => {
43
+ const connectDomains = getAllowedOrigins(csp?.connectDomains, true);
44
+ const resourceDomains = getAllowedOrigins(csp?.resourceDomains);
45
+ const frameDomains = getAllowedOrigins(csp?.frameDomains);
46
+ const baseUriDomains = getAllowedOrigins(csp?.baseUriDomains);
47
+
48
+ return [
49
+ "default-src 'none'",
50
+ directive("script-src", ["'self'", "'unsafe-inline'", ...resourceDomains]),
51
+ directive("style-src", ["'self'", "'unsafe-inline'", ...resourceDomains]),
52
+ directive(
53
+ "connect-src",
54
+ connectDomains.length > 0 ? ["'self'", ...connectDomains] : ["'none'"],
55
+ ),
56
+ directive("img-src", ["'self'", "data:", ...resourceDomains]),
57
+ directive("font-src", ["'self'", ...resourceDomains]),
58
+ directive("media-src", ["'self'", "data:", ...resourceDomains]),
59
+ directive("frame-src", frameDomains.length > 0 ? frameDomains : ["'none'"]),
60
+ directive(
61
+ "base-uri",
62
+ baseUriDomains.length > 0 ? baseUriDomains : ["'self'"],
63
+ ),
64
+ "object-src 'none'",
65
+ "form-action 'none'",
66
+ ].join("; ");
67
+ };
68
+
69
+ const findDoctypeEnd = (html: string): number => {
70
+ let offset = 0;
71
+
72
+ while (offset < html.length) {
73
+ const whitespace = html.slice(offset).match(/^[\t\n\f\r ]+/)?.[0];
74
+ if (whitespace) {
75
+ offset += whitespace.length;
76
+ continue;
77
+ }
78
+ if (html.startsWith("<!--", offset)) {
79
+ if (html.startsWith("<!-->", offset)) {
80
+ offset += 5;
81
+ continue;
82
+ }
83
+ if (html.startsWith("<!--->", offset)) {
84
+ offset += 6;
85
+ continue;
86
+ }
87
+ const commentEnd = html.indexOf("-->", offset + 4);
88
+ const bangCommentEnd = html.indexOf("--!>", offset + 4);
89
+ if (commentEnd === -1 && bangCommentEnd === -1) return 0;
90
+ offset =
91
+ bangCommentEnd !== -1 &&
92
+ (commentEnd === -1 || bangCommentEnd < commentEnd)
93
+ ? bangCommentEnd + 4
94
+ : commentEnd + 3;
95
+ continue;
96
+ }
97
+ if (html.slice(offset, offset + 9).toLowerCase() === "<!doctype") {
98
+ break;
99
+ }
100
+ if (html.startsWith("<?", offset) || html.startsWith("<!", offset)) {
101
+ const bogusCommentEnd = html.indexOf(">", offset + 2);
102
+ if (bogusCommentEnd === -1) return 0;
103
+ offset = bogusCommentEnd + 1;
104
+ continue;
105
+ }
106
+ break;
107
+ }
108
+
109
+ if (html.slice(offset, offset + 9).toLowerCase() !== "<!doctype") {
110
+ return offset;
111
+ }
112
+ const end = html.indexOf(">", offset + 9);
113
+ return end === -1 ? 0 : end + 1;
114
+ };
115
+
116
+ export const applyMcpAppCsp = (
117
+ html: string,
118
+ csp?: McpAppResourceCSP,
119
+ ): string => {
120
+ const normalizedHtml = html.charCodeAt(0) === 0xfeff ? html.slice(1) : html;
121
+ const meta = `<meta http-equiv="Content-Security-Policy" content="${buildMcpAppCsp(csp)}">`;
122
+ const insertionPoint = findDoctypeEnd(normalizedHtml);
123
+ return `${normalizedHtml.slice(0, insertionPoint)}${meta}${normalizedHtml.slice(insertionPoint)}`;
124
+ };
@@ -14,6 +14,7 @@ export type McpAppResourceCSP = {
14
14
  connectDomains?: string[];
15
15
  resourceDomains?: string[];
16
16
  frameDomains?: string[];
17
+ baseUriDomains?: string[];
17
18
  [k: string]: unknown;
18
19
  };
19
20
 
@@ -237,6 +237,51 @@ describe("AssistantFrame Integration", () => {
237
237
  unsubscribe();
238
238
  });
239
239
 
240
+ it("rejects a tool call the frame drops while it is disposed", async () => {
241
+ vi.mocked(iframeWindow.postMessage).mockImplementation((data: any) => {
242
+ Promise.resolve().then(() => {
243
+ messageHandlers.get("iframe")?.({
244
+ data,
245
+ source: parentWindow,
246
+ origin: window.location.origin,
247
+ } as MessageEvent);
248
+ });
249
+ });
250
+ vi.mocked(window.removeEventListener).mockImplementation(
251
+ (event: string, handler: any) => {
252
+ if (event === "message" && messageHandlers.get("iframe") === handler) {
253
+ messageHandlers.delete("iframe");
254
+ }
255
+ },
256
+ );
257
+ const registry = new ModelContextRegistry();
258
+ const toolExecute = vi.fn().mockResolvedValue("result");
259
+ registry.addTool({
260
+ toolName: "search",
261
+ parameters: z.object({}),
262
+ execute: toolExecute,
263
+ });
264
+ AssistantFrameProvider.addModelContextProvider(registry);
265
+ const host = new AssistantFrameHost(iframeWindow);
266
+ await vi.waitFor(() => {
267
+ expect(host.getModelContext().tools?.search).toBeDefined();
268
+ });
269
+
270
+ const result = host.getModelContext().tools!.search!.execute!(
271
+ {},
272
+ {} as any,
273
+ );
274
+ AssistantFrameProvider.dispose();
275
+
276
+ await expect(result).rejects.toThrow(
277
+ "AssistantFrameProvider has been disposed",
278
+ );
279
+ expect(toolExecute).not.toHaveBeenCalled();
280
+ expect(host.getModelContext().tools).toBeUndefined();
281
+
282
+ host.dispose();
283
+ });
284
+
240
285
  it("should handle multiple providers", async () => {
241
286
  // Setup multiple providers
242
287
  const registry1 = new ModelContextRegistry();
@@ -78,6 +78,7 @@ All messages are wrapped with a channel identifier to avoid conflicts with other
78
78
  2. **Tool Execution**
79
79
  - `tool-call`: Parent requests tool execution in iframe (where tools are defined)
80
80
  - `tool-result`: Iframe returns execution result or error to parent
81
+ - `provider-disposed`: Iframe tells the parent it was disposed, so the parent rejects tool calls the iframe will not answer
81
82
 
82
83
  #### Serialization
83
84
 
@@ -7,6 +7,7 @@ import { useAuiEvent } from "@assistant-ui/store";
7
7
 
8
8
  export namespace AssistantModalPrimitiveRoot {
9
9
  export type Props = PopoverPrimitive.PopoverProps & {
10
+ /** @deprecated Experimental since 2024-10-12. Not scheduled for removal; the API may change in any release. */
10
11
  unstable_openOnRunStart?: boolean | undefined;
11
12
  };
12
13
  }
@@ -1,4 +1,7 @@
1
1
  export { AttachmentPrimitiveRoot as Root } from "./attachment/AttachmentRoot";
2
- export { AttachmentPrimitiveThumb as unstable_Thumb } from "./attachment/AttachmentThumb";
2
+ export {
3
+ /** @deprecated Experimental since 2024-10-10. Not scheduled for removal; the API may change in any release. */
4
+ AttachmentPrimitiveThumb as unstable_Thumb,
5
+ } from "./attachment/AttachmentThumb";
3
6
  export { AttachmentPrimitiveName as Name } from "./attachment/AttachmentName";
4
7
  export { AttachmentPrimitiveRemove as Remove } from "./attachment/AttachmentRemove";