@assistant-ui/react 0.15.24 → 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 (204) hide show
  1. package/dist/hooks/useToolCallElapsed.d.ts +3 -1
  2. package/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
  3. package/dist/hooks/useToolCallElapsed.js +8 -8
  4. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  5. package/dist/index.d.ts +39 -7
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -3
  8. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  9. package/dist/mcp-apps/McpAppRenderer.js +3 -2
  10. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  11. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  12. package/dist/mcp-apps/app-frame.js +56 -46
  13. package/dist/mcp-apps/app-frame.js.map +1 -1
  14. package/dist/mcp-apps/csp.d.ts +4 -0
  15. package/dist/mcp-apps/csp.d.ts.map +1 -0
  16. package/dist/mcp-apps/csp.js +93 -0
  17. package/dist/mcp-apps/csp.js.map +1 -0
  18. package/dist/mcp-apps/types.d.ts +1 -0
  19. package/dist/mcp-apps/types.d.ts.map +1 -1
  20. package/dist/mcp-apps/types.js.map +1 -1
  21. package/dist/model-context/makeAssistantVisible.d.ts +1 -0
  22. package/dist/model-context/makeAssistantVisible.d.ts.map +1 -1
  23. package/dist/model-context/makeAssistantVisible.js +31 -12
  24. package/dist/model-context/makeAssistantVisible.js.map +1 -1
  25. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts +1 -0
  26. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  27. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  28. package/dist/primitives/attachment.d.ts +3 -1
  29. package/dist/primitives/attachment.d.ts.map +1 -1
  30. package/dist/primitives/attachment.js.map +1 -1
  31. package/dist/primitives/composer/ComposerCancel.d.ts +5 -1
  32. package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
  33. package/dist/primitives/composer/ComposerCancel.js +14 -1
  34. package/dist/primitives/composer/ComposerCancel.js.map +1 -1
  35. package/dist/primitives/composer/ComposerInput.d.ts +15 -1
  36. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  37. package/dist/primitives/composer/ComposerInput.js +5 -4
  38. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  39. package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
  40. package/dist/primitives/composer/ComposerRoot.js +24 -19
  41. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  42. package/dist/primitives/composer/composerInputSelector.d.ts +3 -0
  43. package/dist/primitives/composer/composerInputSelector.d.ts.map +1 -0
  44. package/dist/primitives/composer/composerInputSelector.js +7 -0
  45. package/dist/primitives/composer/composerInputSelector.js.map +1 -0
  46. package/dist/primitives/composer/trigger/TriggerPopover.d.ts +11 -11
  47. package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
  48. package/dist/primitives/composer/trigger/TriggerPopover.js +62 -60
  49. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  50. package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts +8 -8
  51. package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts.map +1 -1
  52. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +6 -6
  53. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  54. package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts +3 -3
  55. package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts.map +1 -1
  56. package/dist/primitives/composer/trigger/TriggerPopoverCategories.js.map +1 -1
  57. package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts +9 -9
  58. package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts.map +1 -1
  59. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +7 -7
  60. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  61. package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts +5 -5
  62. package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts.map +1 -1
  63. package/dist/primitives/composer/trigger/TriggerPopoverItems.js.map +1 -1
  64. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +8 -8
  65. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +8 -8
  66. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
  67. package/dist/primitives/composer/trigger/index.d.ts +1 -1
  68. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts +2 -0
  69. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts.map +1 -0
  70. package/dist/primitives/composer/useComposerCancelWithFocus.js +42 -0
  71. package/dist/primitives/composer/useComposerCancelWithFocus.js.map +1 -0
  72. package/dist/primitives/composer.d.ts +55 -15
  73. package/dist/primitives/composer.d.ts.map +1 -1
  74. package/dist/primitives/composer.js +8 -1
  75. package/dist/primitives/composer.js.map +1 -1
  76. package/dist/primitives/message/MessagePartsGrouped.d.ts +8 -1
  77. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  78. package/dist/primitives/message/MessagePartsGrouped.js +58 -278
  79. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  80. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
  81. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -2
  82. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  83. package/dist/primitives/thread/ThreadLoadEarlier.d.ts +19 -0
  84. package/dist/primitives/thread/ThreadLoadEarlier.d.ts.map +1 -0
  85. package/dist/primitives/thread/ThreadLoadEarlier.js +36 -0
  86. package/dist/primitives/thread/ThreadLoadEarlier.js.map +1 -0
  87. package/dist/primitives/thread/ThreadMessages.d.ts +1 -1
  88. package/dist/primitives/thread/ThreadMessages.d.ts.map +1 -1
  89. package/dist/primitives/thread/ThreadMessages.js +2 -2
  90. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  91. package/dist/primitives/thread/ThreadViewport.js +4 -3
  92. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  93. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +2 -2
  94. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
  95. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  96. package/dist/primitives/thread/useThreadViewportAutoScroll.js +195 -17
  97. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  98. package/dist/primitives/thread.d.ts +2 -0
  99. package/dist/primitives/thread.d.ts.map +1 -1
  100. package/dist/primitives/thread.js +5 -2
  101. package/dist/primitives/thread.js.map +1 -1
  102. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  103. package/dist/sandbox-host/SandboxHost.js +9 -3
  104. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  105. package/dist/unstable/useComposerInput.d.ts +7 -4
  106. package/dist/unstable/useComposerInput.d.ts.map +1 -1
  107. package/dist/unstable/useComposerInput.js +4 -4
  108. package/dist/unstable/useComposerInput.js.map +1 -1
  109. package/dist/unstable/useComposerInputHistory.d.ts +3 -2
  110. package/dist/unstable/useComposerInputHistory.d.ts.map +1 -1
  111. package/dist/unstable/useComposerInputHistory.js +2 -2
  112. package/dist/unstable/useComposerInputHistory.js.map +1 -1
  113. package/dist/unstable/useLiveCompletionAdapter.d.ts +3 -2
  114. package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
  115. package/dist/unstable/useLiveCompletionAdapter.js +8 -7
  116. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  117. package/dist/unstable/useMentionAdapter.d.ts +12 -3
  118. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  119. package/dist/unstable/useMentionAdapter.js +2 -2
  120. package/dist/unstable/useMentionAdapter.js.map +1 -1
  121. package/dist/unstable/useMessageStallDetection.d.ts +4 -2
  122. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  123. package/dist/unstable/useMessageStallDetection.js +12 -8
  124. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  125. package/dist/unstable/useSlashCommandAdapter.d.ts +5 -2
  126. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  127. package/dist/unstable/useSlashCommandAdapter.js +5 -4
  128. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  129. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +4 -0
  130. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  131. package/dist/unstable/webmcp/useWebMcpProvider.js +2 -0
  132. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  133. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  134. package/dist/utils/getSelectionMessageId.js +31 -8
  135. package/dist/utils/getSelectionMessageId.js.map +1 -1
  136. package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
  137. package/dist/utils/hooks/useOnResizeContent.js +2 -22
  138. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  139. package/dist/utils/smooth/useSmooth.js +6 -6
  140. package/dist/utils/smooth/useSmooth.js.map +1 -1
  141. package/dist/utils/useIsomorphicLayoutEffect.d.ts +3 -0
  142. package/dist/utils/useIsomorphicLayoutEffect.d.ts.map +1 -0
  143. package/dist/utils/useIsomorphicLayoutEffect.js +6 -0
  144. package/dist/utils/useIsomorphicLayoutEffect.js.map +1 -0
  145. package/package.json +12 -11
  146. package/src/hooks/useToolCallElapsed.test.tsx +37 -0
  147. package/src/hooks/useToolCallElapsed.ts +10 -7
  148. package/src/index.ts +47 -5
  149. package/src/mcp-apps/McpAppRenderer.tsx +3 -9
  150. package/src/mcp-apps/app-frame.test.tsx +38 -0
  151. package/src/mcp-apps/app-frame.tsx +5 -8
  152. package/src/mcp-apps/csp.test.ts +112 -0
  153. package/src/mcp-apps/csp.ts +124 -0
  154. package/src/mcp-apps/types.ts +1 -0
  155. package/src/model-context/frame/AssistantFrame.test.ts +45 -0
  156. package/src/model-context/frame/SPEC_AssistantFrame.md +1 -0
  157. package/src/model-context/makeAssistantVisible.test.tsx +315 -5
  158. package/src/model-context/makeAssistantVisible.tsx +43 -7
  159. package/src/primitives/assistantModal/AssistantModalRoot.tsx +1 -0
  160. package/src/primitives/attachment.ts +4 -1
  161. package/src/primitives/composer/ComposerCancel.focus.test.tsx +307 -0
  162. package/src/primitives/composer/ComposerCancel.ts +8 -1
  163. package/src/primitives/composer/ComposerInput.tsx +20 -2
  164. package/src/primitives/composer/ComposerRoot.tsx +6 -5
  165. package/src/primitives/composer/composerInputSelector.ts +4 -0
  166. package/src/primitives/composer/trigger/TriggerPopover.tsx +11 -11
  167. package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +10 -13
  168. package/src/primitives/composer/trigger/TriggerPopoverCategories.tsx +2 -2
  169. package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +11 -14
  170. package/src/primitives/composer/trigger/TriggerPopoverExports.test.ts +61 -0
  171. package/src/primitives/composer/trigger/TriggerPopoverItems.tsx +3 -3
  172. package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +8 -8
  173. package/src/primitives/composer/useComposerCancelWithFocus.ts +57 -0
  174. package/src/primitives/composer.ts +70 -15
  175. package/src/primitives/message/MessagePartsGrouped.test.tsx +106 -2
  176. package/src/primitives/message/MessagePartsGrouped.tsx +36 -134
  177. package/src/primitives/message/MessageRoot.test.tsx +125 -2
  178. package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +20 -12
  179. package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +6 -2
  180. package/src/primitives/thread/ThreadLoadEarlier.tsx +35 -0
  181. package/src/primitives/thread/ThreadMessages.tsx +1 -0
  182. package/src/primitives/thread/ThreadViewport.tsx +3 -3
  183. package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +2 -2
  184. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +922 -68
  185. package/src/primitives/thread/useThreadViewportAutoScroll.ts +330 -56
  186. package/src/primitives/thread.ts +2 -0
  187. package/src/sandbox-host/SandboxHost.test.tsx +34 -0
  188. package/src/sandbox-host/SandboxHost.tsx +11 -12
  189. package/src/tests/toolCallTiming.test.tsx +19 -2
  190. package/src/unstable/useComposerInput.ts +7 -4
  191. package/src/unstable/useComposerInputHistory.ts +3 -2
  192. package/src/unstable/useLiveCompletionAdapter.ts +9 -14
  193. package/src/unstable/useMentionAdapter.ts +12 -3
  194. package/src/unstable/useMessageStallDetection.test.tsx +40 -0
  195. package/src/unstable/useMessageStallDetection.ts +14 -8
  196. package/src/unstable/useSlashCommandAdapter.ts +8 -4
  197. package/src/unstable/webmcp/useWebMcpProvider.ts +4 -0
  198. package/src/utils/getSelectionMessageId.test.ts +231 -0
  199. package/src/utils/getSelectionMessageId.ts +45 -7
  200. package/src/utils/hooks/useOnResizeContent.ts +2 -29
  201. package/src/utils/smooth/useSmooth.test.tsx +16 -5
  202. package/src/utils/smooth/useSmooth.ts +7 -7
  203. package/src/utils/useIsomorphicLayoutEffect.test.tsx +21 -0
  204. package/src/utils/useIsomorphicLayoutEffect.ts +9 -0
@@ -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