@assistant-ui/react 0.15.16 → 0.15.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/index.d.ts +8 -4
- package/dist/index.js +4 -2
- package/dist/mcp-apps/McpAppRenderer.d.ts +36 -9
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +108 -57
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.js +20 -17
- package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
- package/dist/mcp-apps/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +229 -115
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.js +2 -3
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/mcp-apps/index.d.ts +3 -3
- package/dist/model-context/frame/useAssistantFrameHost.d.ts +1 -1
- package/dist/model-context/frame/useAssistantFrameHost.js +10 -11
- package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
- package/dist/model-context/makeAssistantVisible.js +1 -1
- package/dist/model-context/makeAssistantVisible.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarCopy.js +3 -3
- package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.js +3 -3
- package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +3 -3
- package/dist/primitives/actionBar/ActionBarFeedbackNegative.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +3 -3
- package/dist/primitives/actionBar/ActionBarFeedbackPositive.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarRoot.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarStopSpeaking.js +3 -3
- package/dist/primitives/actionBar/ActionBarStopSpeaking.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreContent.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreItem.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreRoot.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreSeparator.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreTrigger.js.map +1 -1
- package/dist/primitives/actionBarMore/scope.d.ts +2 -2
- package/dist/primitives/actionBarMore/scope.d.ts.map +1 -1
- package/dist/primitives/actionBarMore/scope.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalAnchor.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalContent.js +1 -1
- package/dist/primitives/assistantModal/AssistantModalContent.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalTrigger.js.map +1 -1
- package/dist/primitives/assistantModal/scope.d.ts +2 -2
- package/dist/primitives/assistantModal/scope.d.ts.map +1 -1
- package/dist/primitives/assistantModal/scope.js.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerRoot.js.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/primitives/composer/ComposerDictationTranscript.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +1 -3
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerInputPluginContext.d.ts +1 -2
- package/dist/primitives/composer/ComposerInputPluginContext.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInputPluginContext.js +1 -6
- package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
- package/dist/primitives/composer/ComposerQuote.js +5 -5
- package/dist/primitives/composer/ComposerQuote.js.map +1 -1
- package/dist/primitives/composer/ComposerRoot.js +3 -3
- package/dist/primitives/composer/ComposerRoot.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.d.ts +5 -0
- package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.js +157 -130
- package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js +46 -15
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverBack.js +3 -3
- package/dist/primitives/composer/trigger/TriggerPopoverBack.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.js +4 -4
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +40 -10
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverItems.js +4 -4
- package/dist/primitives/composer/trigger/TriggerPopoverItems.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts +2 -0
- package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverResource.js +63 -61
- package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +2 -0
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
- package/dist/primitives/composer/trigger/detectTrigger.d.ts +21 -1
- package/dist/primitives/composer/trigger/detectTrigger.d.ts.map +1 -0
- package/dist/primitives/composer/trigger/detectTrigger.js +17 -3
- package/dist/primitives/composer/trigger/detectTrigger.js.map +1 -1
- package/dist/primitives/composer/trigger/index.d.ts +3 -1
- package/dist/primitives/composer/trigger/index.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/index.js.map +1 -1
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts +6 -0
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts.map +1 -0
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +9 -0
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js.map +1 -0
- package/dist/primitives/composer/trigger/triggerDetectionResource.d.ts +3 -4
- package/dist/primitives/composer/trigger/triggerDetectionResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerDetectionResource.js +15 -14
- package/dist/primitives/composer/trigger/triggerDetectionResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.js +3 -5
- package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js +18 -19
- package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
- package/dist/primitives/composer.d.ts +3 -2
- package/dist/primitives/composer.js.map +1 -1
- package/dist/primitives/error/ErrorMessage.js.map +1 -1
- package/dist/primitives/message/MessageIf.js.map +1 -1
- package/dist/primitives/message/MessageParts.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/message/MessageRoot.js +4 -4
- package/dist/primitives/message/MessageRoot.js.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts +1 -1
- package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js +3 -3
- package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +3 -3
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
- package/dist/primitives/thread/ThreadIf.js.map +1 -1
- package/dist/primitives/thread/ThreadViewport.js +3 -3
- package/dist/primitives/thread/ThreadViewport.js.map +1 -1
- package/dist/primitives/thread/ThreadViewportFooter.js +3 -3
- package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +4 -4
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/primitives/threadList/ThreadListNew.js +3 -3
- package/dist/primitives/threadList/ThreadListNew.js.map +1 -1
- package/dist/primitives/threadListFocusGroup.d.ts +2 -1
- package/dist/primitives/threadListFocusGroup.d.ts.map +1 -1
- package/dist/primitives/threadListFocusGroup.js +2 -2
- package/dist/primitives/threadListFocusGroup.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemRoot.js +3 -3
- package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemTrigger.js +1 -1
- package/dist/primitives/threadListItem/ThreadListItemTrigger.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreItem.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreSeparator.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +1 -2
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js.map +1 -1
- package/dist/primitives/threadListItemMore/scope.d.ts +2 -2
- package/dist/primitives/threadListItemMore/scope.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/scope.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +148 -72
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.js +3 -1
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
- package/dist/unstable/useMentionAdapter.d.ts +9 -3
- package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
- package/dist/unstable/useMentionAdapter.js +50 -43
- package/dist/unstable/useMentionAdapter.js.map +1 -1
- package/dist/unstable/useModelContextSnapshot.d.ts +34 -0
- package/dist/unstable/useModelContextSnapshot.d.ts.map +1 -0
- package/dist/unstable/useModelContextSnapshot.js +61 -0
- package/dist/unstable/useModelContextSnapshot.js.map +1 -0
- package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
- package/dist/unstable/useSlashCommandAdapter.js +37 -29
- package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
- package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +14 -0
- package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -0
- package/dist/unstable/webmcp/WebMcpRegistrationResource.js +85 -0
- package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -0
- package/dist/unstable/webmcp/convertTools.d.ts +21 -0
- package/dist/unstable/webmcp/convertTools.d.ts.map +1 -0
- package/dist/unstable/webmcp/convertTools.js +110 -0
- package/dist/unstable/webmcp/convertTools.js.map +1 -0
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts +21 -0
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -0
- package/dist/unstable/webmcp/useWebMcpProvider.js +105 -0
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -0
- package/dist/unstable/webmcp/webmcp-host.d.ts +39 -0
- package/dist/unstable/webmcp/webmcp-host.d.ts.map +1 -0
- package/dist/unstable/webmcp/webmcp-host.js +29 -0
- package/dist/unstable/webmcp/webmcp-host.js.map +1 -0
- package/dist/utils/Primitive.d.ts +6 -6
- package/dist/utils/Primitive.js +2 -2
- package/dist/utils/Primitive.js.map +1 -1
- package/dist/utils/createActionButton.js +3 -3
- package/dist/utils/createActionButton.js.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.js +1 -1
- package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
- package/dist/utils/hooks/useOnScrollToBottom.js +1 -1
- package/dist/utils/hooks/useOnScrollToBottom.js.map +1 -1
- package/dist/utils/smooth/SmoothContext.d.ts +24 -0
- package/dist/utils/smooth/SmoothContext.d.ts.map +1 -1
- package/dist/utils/smooth/useSmooth.js +1 -1
- package/dist/utils/smooth/useSmooth.js.map +1 -1
- package/package.json +14 -22
- package/src/index.ts +22 -1
- package/src/mcp-apps/McpAppRenderer.test.tsx +386 -9
- package/src/mcp-apps/McpAppRenderer.tsx +171 -69
- package/src/mcp-apps/McpAppsRemoteHost.concurrent.test.tsx +215 -0
- package/src/mcp-apps/McpAppsRemoteHost.ts +28 -12
- package/src/mcp-apps/app-frame.test.tsx +349 -0
- package/src/mcp-apps/app-frame.tsx +68 -9
- package/src/mcp-apps/bridge.ts +7 -2
- package/src/mcp-apps/index.ts +6 -1
- package/src/model-context/frame/AssistantFrame.test.ts +3 -3
- package/src/model-context/frame/SPEC_AssistantFrame.md +1 -1
- package/src/model-context/frame/useAssistantFrameHost.ts +2 -2
- package/src/model-context/makeAssistantVisible.tsx +1 -1
- package/src/primitives/actionBar/ActionBarCopy.tsx +1 -1
- package/src/primitives/actionBar/ActionBarExportMarkdown.tsx +1 -1
- package/src/primitives/actionBar/ActionBarFeedbackNegative.tsx +1 -1
- package/src/primitives/actionBar/ActionBarFeedbackPositive.tsx +1 -1
- package/src/primitives/actionBar/ActionBarStopSpeaking.tsx +1 -1
- package/src/primitives/actionBarMore/scope.ts +2 -2
- package/src/primitives/assistantModal/AssistantModalContent.tsx +1 -1
- package/src/primitives/assistantModal/scope.ts +2 -2
- package/src/primitives/composer/ComposerAttachmentDropzone.tsx +1 -1
- package/src/primitives/composer/ComposerInput.test.tsx +2 -1
- package/src/primitives/composer/ComposerInput.tsx +3 -3
- package/src/primitives/composer/ComposerInputPluginContext.tsx +0 -10
- package/src/primitives/composer/ComposerQuote.tsx +1 -1
- package/src/primitives/composer/ComposerRoot.tsx +1 -1
- package/src/primitives/composer/trigger/TriggerPopover.tsx +16 -1
- package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +4 -2
- package/src/primitives/composer/trigger/TriggerPopoverBack.tsx +1 -1
- package/src/primitives/composer/trigger/TriggerPopoverBehavior.test.tsx +109 -0
- package/src/primitives/composer/trigger/TriggerPopoverCategories.tsx +1 -1
- package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +4 -2
- package/src/primitives/composer/trigger/TriggerPopoverItems.tsx +1 -1
- package/src/primitives/composer/trigger/TriggerPopoverResource.ts +4 -1
- package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +2 -0
- package/src/primitives/composer/trigger/detectTrigger.test.ts +88 -5
- package/src/primitives/composer/trigger/detectTrigger.ts +50 -9
- package/src/primitives/composer/trigger/index.ts +1 -0
- package/src/primitives/composer/trigger/matchesTriggerItemQuery.test.ts +52 -0
- package/src/primitives/composer/trigger/matchesTriggerItemQuery.ts +13 -0
- package/src/primitives/composer/trigger/triggerDetectionResource.ts +10 -7
- package/src/primitives/composer/trigger/triggerNavigationResource.ts +3 -10
- package/src/primitives/composer/trigger/triggerSelectionResource.test.ts +15 -3
- package/src/primitives/composer/trigger/triggerSelectionResource.ts +1 -4
- package/src/primitives/composer.ts +1 -0
- package/src/primitives/message/MessageRoot.tsx +1 -1
- package/src/primitives/selectionToolbar/SelectionToolbarQuote.tsx +1 -1
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +1 -1
- package/src/primitives/thread/ThreadViewport.tsx +1 -1
- package/src/primitives/thread/ThreadViewportFooter.tsx +1 -1
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +8 -7
- package/src/primitives/threadList/ThreadListNew.tsx +1 -1
- package/src/primitives/threadListFocusGroup.ts +2 -2
- package/src/primitives/threadListItem/ThreadListItemRoot.tsx +1 -1
- package/src/primitives/threadListItem/ThreadListItemTrigger.tsx +1 -1
- package/src/primitives/threadListItemMore/ThreadListItemMoreContent.tsx +1 -1
- package/src/primitives/threadListItemMore/ThreadListItemMoreRoot.tsx +1 -1
- package/src/primitives/threadListItemMore/ThreadListItemMoreTrigger.tsx +2 -2
- package/src/primitives/threadListItemMore/scope.ts +2 -2
- package/src/sandbox-host/SandboxHost.test.tsx +165 -3
- package/src/sandbox-host/SandboxHost.tsx +42 -9
- package/src/tests/RemoteThreadListRuntime.adapterProvider.test.tsx +31 -1
- package/src/unstable/useComposerInputHistory.test.tsx +2 -1
- package/src/unstable/useLiveCompletionAdapter.test.tsx +60 -1
- package/src/unstable/useLiveCompletionAdapter.ts +4 -1
- package/src/unstable/useMentionAdapter.test.tsx +337 -0
- package/src/unstable/useMentionAdapter.ts +94 -57
- package/src/unstable/useModelContextSnapshot.test.tsx +132 -0
- package/src/unstable/useModelContextSnapshot.ts +67 -0
- package/src/unstable/useSlashCommandAdapter.test.tsx +141 -0
- package/src/unstable/useSlashCommandAdapter.ts +47 -19
- package/src/unstable/webmcp/WebMcpRegistrationResource.ts +92 -0
- package/src/unstable/webmcp/__tests__/webmcp.fake.tsx +168 -0
- package/src/unstable/webmcp/convertTools.test.ts +393 -0
- package/src/unstable/webmcp/convertTools.ts +180 -0
- package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
- package/src/unstable/webmcp/useWebMcpProvider.ts +150 -0
- package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
- package/src/unstable/webmcp/webmcp-host.ts +83 -0
- package/src/utils/Primitive.tsx +2 -2
- package/src/utils/createActionButton.tsx +1 -1
- package/src/utils/hooks/useOnResizeContent.ts +1 -1
- package/src/utils/hooks/useOnScrollToBottom.ts +1 -1
- package/src/utils/smooth/useSmooth.ts +1 -1
- package/dist/legacy-runtime/runtime/MessagePartRuntime.d.ts +0 -3
- package/dist/legacy-runtime/runtime/MessagePartRuntime.js +0 -2
- package/dist/legacy-runtime/runtime/MessageRuntime.d.ts +0 -3
- package/dist/legacy-runtime/runtime/MessageRuntime.js +0 -2
- package/dist/legacy-runtime/runtime/ThreadListRuntime.d.ts +0 -3
- package/dist/legacy-runtime/runtime/ThreadListRuntime.js +0 -2
- package/dist/utils/invokeCallbackSafely.d.ts +0 -5
- package/dist/utils/invokeCallbackSafely.d.ts.map +0 -1
- package/dist/utils/invokeCallbackSafely.js +0 -15
- package/dist/utils/invokeCallbackSafely.js.map +0 -1
- package/dist/utils/useToolArgsFieldStatus.d.ts +0 -15
- package/dist/utils/useToolArgsFieldStatus.d.ts.map +0 -1
- package/dist/utils/useToolArgsFieldStatus.js +0 -23
- package/dist/utils/useToolArgsFieldStatus.js.map +0 -1
- package/src/legacy-runtime/runtime/MessagePartRuntime.ts +0 -2
- package/src/legacy-runtime/runtime/MessageRuntime.ts +0 -3
- package/src/legacy-runtime/runtime/ThreadListRuntime.ts +0 -4
- package/src/utils/invokeCallbackSafely.ts +0 -11
- package/src/utils/useToolArgsFieldStatus.ts +0 -18
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ToolResponse,
|
|
3
|
+
toJSONSchema,
|
|
4
|
+
type Tool,
|
|
5
|
+
type ToolModelContentPart,
|
|
6
|
+
} from "assistant-stream";
|
|
7
|
+
import { generateId } from "@assistant-ui/core/internal";
|
|
8
|
+
import type {
|
|
9
|
+
WebMcpCallToolResult,
|
|
10
|
+
WebMcpContent,
|
|
11
|
+
WebMcpToolDescriptor,
|
|
12
|
+
} from "./webmcp-host";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The predicate the WebMCP provider uses when no `filter` is passed: an enabled
|
|
16
|
+
* frontend tool with a client-side `execute`. A tool authored without a `type`
|
|
17
|
+
* is included, because `execute` is what distinguishes the deprecated
|
|
18
|
+
* type-less form from a backend or human tool. A `filter` replaces this, so
|
|
19
|
+
* pass it through to narrow the default set rather than widen it.
|
|
20
|
+
*/
|
|
21
|
+
export const defaultWebMcpFilter = (
|
|
22
|
+
_name: string,
|
|
23
|
+
tool: Tool<any, any>,
|
|
24
|
+
): boolean =>
|
|
25
|
+
(tool.type === "frontend" || tool.type === undefined) &&
|
|
26
|
+
!!tool.execute &&
|
|
27
|
+
!tool.disabled;
|
|
28
|
+
|
|
29
|
+
export const toWebMcpInputSchema = (tool: Tool<any, any>): unknown =>
|
|
30
|
+
tool.parameters
|
|
31
|
+
? toJSONSchema(tool.parameters)
|
|
32
|
+
: { type: "object", properties: {} };
|
|
33
|
+
|
|
34
|
+
const textContent = (text: string): WebMcpContent => ({ type: "text", text });
|
|
35
|
+
|
|
36
|
+
const errorResult = (message: string): WebMcpCallToolResult => ({
|
|
37
|
+
isError: true,
|
|
38
|
+
content: [textContent(message)],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// bigint throws in JSON.stringify and symbol serializes to undefined, but both
|
|
42
|
+
// have a faithful string form. A value that cannot be serialized at all still
|
|
43
|
+
// throws through to the error result.
|
|
44
|
+
const toText = (value: unknown): string => {
|
|
45
|
+
if (typeof value === "string") return value;
|
|
46
|
+
if (typeof value === "bigint" || typeof value === "symbol") {
|
|
47
|
+
return value.toString();
|
|
48
|
+
}
|
|
49
|
+
return JSON.stringify(value) ?? String(value);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const primitiveContent = (value: unknown): WebMcpContent[] => [
|
|
53
|
+
textContent(toText(value)),
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const mapModelContentPart = (part: ToolModelContentPart): WebMcpContent => {
|
|
57
|
+
if (part.type === "text") {
|
|
58
|
+
return textContent(part.text ?? "");
|
|
59
|
+
}
|
|
60
|
+
if (part.type === "file") {
|
|
61
|
+
if (
|
|
62
|
+
typeof part.mediaType === "string" &&
|
|
63
|
+
part.mediaType.startsWith("image/")
|
|
64
|
+
) {
|
|
65
|
+
return { type: "image", data: part.data ?? "", mimeType: part.mediaType };
|
|
66
|
+
}
|
|
67
|
+
return textContent(part.data ?? "");
|
|
68
|
+
}
|
|
69
|
+
return textContent(toText(part));
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const toMcpContent = async (
|
|
73
|
+
result: unknown,
|
|
74
|
+
options: {
|
|
75
|
+
tool: Tool<any, any>;
|
|
76
|
+
toolCallId: string;
|
|
77
|
+
args: Record<string, unknown>;
|
|
78
|
+
},
|
|
79
|
+
): Promise<WebMcpCallToolResult> => {
|
|
80
|
+
const response = ToolResponse.toResponse(result);
|
|
81
|
+
if (response.modelContent) {
|
|
82
|
+
const content = response.modelContent.map(mapModelContentPart);
|
|
83
|
+
return response.isError ? { isError: true, content } : { content };
|
|
84
|
+
}
|
|
85
|
+
if (!response.isError && options.tool.toModelOutput) {
|
|
86
|
+
try {
|
|
87
|
+
const parts = await options.tool.toModelOutput({
|
|
88
|
+
toolCallId: options.toolCallId,
|
|
89
|
+
input: options.args,
|
|
90
|
+
output: response.result,
|
|
91
|
+
});
|
|
92
|
+
return { content: parts.map(mapModelContentPart) };
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.warn(
|
|
95
|
+
"[assistant-ui] toModelOutput threw; falling back to default projection.",
|
|
96
|
+
e,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const content = primitiveContent(response.result);
|
|
101
|
+
return response.isError ? { isError: true, content } : { content };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
type StandardSchemaLike = {
|
|
105
|
+
"~standard": {
|
|
106
|
+
version: number;
|
|
107
|
+
validate: (
|
|
108
|
+
value: unknown,
|
|
109
|
+
) =>
|
|
110
|
+
| { issues?: readonly unknown[] | undefined }
|
|
111
|
+
| Promise<{ issues?: readonly unknown[] | undefined }>;
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const isStandardSchema = (schema: unknown): schema is StandardSchemaLike =>
|
|
116
|
+
typeof schema === "object" &&
|
|
117
|
+
schema !== null &&
|
|
118
|
+
"~standard" in schema &&
|
|
119
|
+
(schema as StandardSchemaLike)["~standard"].version === 1;
|
|
120
|
+
|
|
121
|
+
export const toWebMcpTool = (
|
|
122
|
+
name: string,
|
|
123
|
+
getTool: () => Tool<any, any>,
|
|
124
|
+
lifecycleSignal?: AbortSignal,
|
|
125
|
+
): WebMcpToolDescriptor => ({
|
|
126
|
+
name,
|
|
127
|
+
description: getTool().description ?? "",
|
|
128
|
+
inputSchema: toWebMcpInputSchema(getTool()),
|
|
129
|
+
execute: async (rawArgs, context) => {
|
|
130
|
+
if (lifecycleSignal?.aborted) {
|
|
131
|
+
return errorResult(`Tool "${name}" is no longer registered`);
|
|
132
|
+
}
|
|
133
|
+
const tool = getTool();
|
|
134
|
+
const args = (rawArgs ?? {}) as Record<string, unknown>;
|
|
135
|
+
const toolCallId = generateId();
|
|
136
|
+
try {
|
|
137
|
+
const callerSignal = context?.signal;
|
|
138
|
+
const abortSignal = !callerSignal
|
|
139
|
+
? lifecycleSignal
|
|
140
|
+
: !lifecycleSignal
|
|
141
|
+
? callerSignal
|
|
142
|
+
: AbortSignal.any([callerSignal, lifecycleSignal]);
|
|
143
|
+
let executeFn = tool.execute;
|
|
144
|
+
if (isStandardSchema(tool.parameters)) {
|
|
145
|
+
let validation = tool.parameters["~standard"].validate(args);
|
|
146
|
+
validation = await validation;
|
|
147
|
+
if (validation.issues) {
|
|
148
|
+
const issues = validation.issues;
|
|
149
|
+
executeFn =
|
|
150
|
+
tool.experimental_onSchemaValidationError ??
|
|
151
|
+
(() => {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Function parameter validation failed. ${JSON.stringify(issues)}`,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (abortSignal?.aborted) {
|
|
160
|
+
return errorResult("Tool execution was cancelled.");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!executeFn) {
|
|
164
|
+
return errorResult(`Tool "${name}" has no client-side implementation.`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const result = await executeFn(args, {
|
|
168
|
+
toolCallId,
|
|
169
|
+
abortSignal: abortSignal ?? new AbortController().signal,
|
|
170
|
+
human: () =>
|
|
171
|
+
Promise.reject(
|
|
172
|
+
new Error("human input not supported in WebMCP context"),
|
|
173
|
+
),
|
|
174
|
+
});
|
|
175
|
+
return await toMcpContent(result, { tool, toolCallId, args });
|
|
176
|
+
} catch (e) {
|
|
177
|
+
return errorResult(e instanceof Error ? e.message : String(e));
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
});
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { StrictMode, type ReactNode } from "react";
|
|
4
|
+
import { cleanup } from "@testing-library/react";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { Tool } from "assistant-stream";
|
|
7
|
+
import type {
|
|
8
|
+
WebMcpHost,
|
|
9
|
+
WebMcpModelContext,
|
|
10
|
+
WebMcpToolDescriptor,
|
|
11
|
+
} from "./webmcp-host";
|
|
12
|
+
|
|
13
|
+
const { hostRef } = vi.hoisted(() => ({
|
|
14
|
+
hostRef: { current: null as WebMcpHost | null },
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("./webmcp-host", async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal<typeof import("./webmcp-host")>();
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
getDefaultWebMcpHost: (): WebMcpHost =>
|
|
22
|
+
hostRef.current ?? actual.getDefaultWebMcpHost(),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
providerResult,
|
|
28
|
+
createAsyncModelContext,
|
|
29
|
+
createFakeWebMcpHost,
|
|
30
|
+
createProvider,
|
|
31
|
+
frontendTool,
|
|
32
|
+
mountProvider,
|
|
33
|
+
silenceWarnings,
|
|
34
|
+
waitForNames,
|
|
35
|
+
} = await import("./__tests__/webmcp.fake");
|
|
36
|
+
|
|
37
|
+
const useHost = <T extends WebMcpHost>(host: T): T => {
|
|
38
|
+
hostRef.current = host;
|
|
39
|
+
return host;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const strict = (children: ReactNode) => <StrictMode>{children}</StrictMode>;
|
|
43
|
+
|
|
44
|
+
const backendTool = { type: "backend" } as Tool<any, any>;
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
cleanup();
|
|
48
|
+
hostRef.current = null;
|
|
49
|
+
delete (document as { modelContext?: WebMcpModelContext }).modelContext;
|
|
50
|
+
vi.restoreAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("unstable_useWebMcpProvider", () => {
|
|
54
|
+
it("reports unsupported and registers nothing when the page has no model context", async () => {
|
|
55
|
+
const provider = createProvider({ search: frontendTool() });
|
|
56
|
+
mountProvider(provider);
|
|
57
|
+
|
|
58
|
+
await vi.waitFor(() => expect(providerResult().status).toBe("unsupported"));
|
|
59
|
+
expect(providerResult().registeredToolNames).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("registers the filtered tools and reports them sorted", async () => {
|
|
63
|
+
const host = useHost(createFakeWebMcpHost());
|
|
64
|
+
mountProvider(
|
|
65
|
+
createProvider({
|
|
66
|
+
search: frontendTool(),
|
|
67
|
+
alpha: frontendTool({ description: "alpha" }),
|
|
68
|
+
server: backendTool,
|
|
69
|
+
off: frontendTool({ disabled: true }),
|
|
70
|
+
broken: frontendTool({ execute: undefined }),
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
await waitForNames(["alpha", "search"]);
|
|
75
|
+
expect(providerResult().status).toBe("active");
|
|
76
|
+
expect([...host.registry.keys()].sort()).toEqual(["alpha", "search"]);
|
|
77
|
+
expect(host.registry.get("search")?.description).toBe("search things");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("honours a custom filter and re-syncs when the filter identity changes", async () => {
|
|
81
|
+
const host = useHost(createFakeWebMcpHost());
|
|
82
|
+
const provider = createProvider({
|
|
83
|
+
search: frontendTool(),
|
|
84
|
+
alpha: frontendTool(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const { rerender } = mountProvider(provider, {
|
|
88
|
+
filter: (name) => name === "search",
|
|
89
|
+
});
|
|
90
|
+
await waitForNames(["search"]);
|
|
91
|
+
|
|
92
|
+
rerender({ filter: (name) => name === "alpha" });
|
|
93
|
+
await waitForNames(["alpha"]);
|
|
94
|
+
expect(host.unregisterCalls).toEqual(["search"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("warns and skips a tool whose filter throws", async () => {
|
|
98
|
+
const warn = silenceWarnings();
|
|
99
|
+
useHost(createFakeWebMcpHost());
|
|
100
|
+
mountProvider(
|
|
101
|
+
createProvider({ search: frontendTool(), bad: frontendTool() }),
|
|
102
|
+
{
|
|
103
|
+
filter: (name) => {
|
|
104
|
+
if (name === "bad") throw new Error("filter boom");
|
|
105
|
+
return true;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await waitForNames(["search"]);
|
|
111
|
+
expect(warn).toHaveBeenCalledWith(
|
|
112
|
+
expect.stringContaining('tool "bad"'),
|
|
113
|
+
expect.any(Error),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("warns once for a tool whose filter keeps throwing", async () => {
|
|
118
|
+
const warn = silenceWarnings();
|
|
119
|
+
const adapter = useHost(createFakeWebMcpHost());
|
|
120
|
+
const bad = frontendTool();
|
|
121
|
+
const provider = createProvider({ bad, search: frontendTool() });
|
|
122
|
+
mountProvider(provider, {
|
|
123
|
+
filter: (_name, tool) => {
|
|
124
|
+
if (tool === bad) throw new Error("filter boom");
|
|
125
|
+
return true;
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
await waitForNames(["search"]);
|
|
129
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
130
|
+
|
|
131
|
+
provider.setTools({ bad, search: frontendTool(), extra: frontendTool() });
|
|
132
|
+
await waitForNames(["extra", "search"]);
|
|
133
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
134
|
+
expect(adapter.registry.has("bad")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("re-attempts a tool whose filter stops throwing", async () => {
|
|
138
|
+
const warn = silenceWarnings();
|
|
139
|
+
const adapter = useHost(createFakeWebMcpHost());
|
|
140
|
+
const flaky = frontendTool();
|
|
141
|
+
let throwing = true;
|
|
142
|
+
const provider = createProvider({ flaky });
|
|
143
|
+
mountProvider(provider, {
|
|
144
|
+
filter: (_name, tool) => {
|
|
145
|
+
if (tool === flaky && throwing) throw new Error("filter boom");
|
|
146
|
+
return true;
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
await vi.waitFor(() => expect(warn).toHaveBeenCalledOnce());
|
|
150
|
+
expect(adapter.registry.has("flaky")).toBe(false);
|
|
151
|
+
|
|
152
|
+
throwing = false;
|
|
153
|
+
provider.setTools({ flaky });
|
|
154
|
+
await waitForNames(["flaky"]);
|
|
155
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("adds and removes registrations as the model context changes", async () => {
|
|
159
|
+
const host = useHost(createFakeWebMcpHost());
|
|
160
|
+
const provider = createProvider({ search: frontendTool() });
|
|
161
|
+
mountProvider(provider);
|
|
162
|
+
await waitForNames(["search"]);
|
|
163
|
+
|
|
164
|
+
provider.setTools({ search: frontendTool(), alpha: frontendTool() });
|
|
165
|
+
await waitForNames(["alpha", "search"]);
|
|
166
|
+
|
|
167
|
+
provider.setTools({ alpha: frontendTool() });
|
|
168
|
+
await waitForNames(["alpha"]);
|
|
169
|
+
expect(host.unregisterCalls).toEqual(["search"]);
|
|
170
|
+
expect(host.registerCalls).toEqual(["search", "alpha"]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("keeps one registration across an implementation change and calls through to the latest tool", async () => {
|
|
174
|
+
const host = useHost(createFakeWebMcpHost());
|
|
175
|
+
const provider = createProvider({
|
|
176
|
+
search: frontendTool({ execute: async () => "first" }),
|
|
177
|
+
});
|
|
178
|
+
mountProvider(provider);
|
|
179
|
+
await waitForNames(["search"]);
|
|
180
|
+
|
|
181
|
+
const descriptor = host.registry.get("search")!;
|
|
182
|
+
await expect(descriptor.execute({})).resolves.toEqual({
|
|
183
|
+
content: [{ type: "text", text: "first" }],
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
provider.setTools({
|
|
187
|
+
search: frontendTool({ execute: async () => "second" }),
|
|
188
|
+
});
|
|
189
|
+
await vi.waitFor(async () => {
|
|
190
|
+
await expect(descriptor.execute({})).resolves.toEqual({
|
|
191
|
+
content: [{ type: "text", text: "second" }],
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
expect(host.registerCalls).toEqual(["search"]);
|
|
195
|
+
expect(host.unregisterCalls).toEqual([]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("re-registers when the description or the schema changes", async () => {
|
|
199
|
+
const host = useHost(createFakeWebMcpHost());
|
|
200
|
+
const provider = createProvider({ search: frontendTool() });
|
|
201
|
+
mountProvider(provider);
|
|
202
|
+
await waitForNames(["search"]);
|
|
203
|
+
|
|
204
|
+
provider.setTools({ search: frontendTool({ description: "renamed" }) });
|
|
205
|
+
await vi.waitFor(() =>
|
|
206
|
+
expect(host.registry.get("search")?.description).toBe("renamed"),
|
|
207
|
+
);
|
|
208
|
+
expect(host.registerCalls).toEqual(["search", "search"]);
|
|
209
|
+
expect(host.unregisterCalls).toEqual(["search"]);
|
|
210
|
+
expect(providerResult().registeredToolNames).toEqual(["search"]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("re-registers when a description is mutated on the same tool object", async () => {
|
|
214
|
+
const host = useHost(createFakeWebMcpHost());
|
|
215
|
+
const tool = frontendTool();
|
|
216
|
+
const provider = createProvider({ search: tool });
|
|
217
|
+
mountProvider(provider);
|
|
218
|
+
await waitForNames(["search"]);
|
|
219
|
+
|
|
220
|
+
(tool as { description: string }).description = "renamed in place";
|
|
221
|
+
provider.setTools({ search: tool });
|
|
222
|
+
|
|
223
|
+
await vi.waitFor(() =>
|
|
224
|
+
expect(host.registry.get("search")?.description).toBe("renamed in place"),
|
|
225
|
+
);
|
|
226
|
+
expect(host.registerCalls).toEqual(["search", "search"]);
|
|
227
|
+
expect(host.unregisterCalls).toEqual(["search"]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("does not observe a schema mutated in place on the same tool object", async () => {
|
|
231
|
+
const adapter = useHost(createFakeWebMcpHost());
|
|
232
|
+
const tool = frontendTool();
|
|
233
|
+
const provider = createProvider({ search: tool });
|
|
234
|
+
mountProvider(provider);
|
|
235
|
+
await waitForNames(["search"]);
|
|
236
|
+
const before = adapter.registry.get("search")?.inputSchema;
|
|
237
|
+
|
|
238
|
+
(tool.parameters as Record<string, unknown>)["properties"] = {
|
|
239
|
+
city: { type: "string" },
|
|
240
|
+
};
|
|
241
|
+
provider.setTools({ search: tool });
|
|
242
|
+
await vi.waitFor(() => expect(adapter.registerCalls).toEqual(["search"]));
|
|
243
|
+
|
|
244
|
+
expect(adapter.registry.get("search")?.inputSchema).toBe(before);
|
|
245
|
+
expect(adapter.unregisterCalls).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("does not treat a tool named after an Object.prototype key as inherited", async () => {
|
|
249
|
+
const host = useHost(createFakeWebMcpHost());
|
|
250
|
+
const provider = createProvider({
|
|
251
|
+
constructor: frontendTool({ description: "ctor" }),
|
|
252
|
+
toString: frontendTool({ description: "str" }),
|
|
253
|
+
});
|
|
254
|
+
mountProvider(provider);
|
|
255
|
+
|
|
256
|
+
await waitForNames(["constructor", "toString"]);
|
|
257
|
+
expect(host.registry.get("constructor")?.description).toBe("ctor");
|
|
258
|
+
|
|
259
|
+
provider.setTools({ constructor: frontendTool({ description: "ctor" }) });
|
|
260
|
+
await waitForNames(["constructor"]);
|
|
261
|
+
expect(host.unregisterCalls).toEqual(["toString"]);
|
|
262
|
+
expect(host.registerCalls).toEqual(["constructor", "toString"]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("unregisters everything on unmount and warns when a disposer throws", async () => {
|
|
266
|
+
const warn = silenceWarnings();
|
|
267
|
+
const host = useHost(createFakeWebMcpHost());
|
|
268
|
+
const { view } = mountProvider(
|
|
269
|
+
createProvider({ search: frontendTool(), alpha: frontendTool() }),
|
|
270
|
+
);
|
|
271
|
+
await waitForNames(["alpha", "search"]);
|
|
272
|
+
|
|
273
|
+
view.unmount();
|
|
274
|
+
expect(host.registry.size).toBe(0);
|
|
275
|
+
expect(host.unregisterCalls.sort()).toEqual(["alpha", "search"]);
|
|
276
|
+
expect(warn).not.toHaveBeenCalled();
|
|
277
|
+
|
|
278
|
+
useHost({
|
|
279
|
+
...createFakeWebMcpHost(),
|
|
280
|
+
registerTool: () => () => {
|
|
281
|
+
throw new Error("dispose boom");
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
const second = mountProvider(createProvider({ search: frontendTool() }));
|
|
285
|
+
await waitForNames(["search"]);
|
|
286
|
+
second.view.unmount();
|
|
287
|
+
expect(warn).toHaveBeenCalledWith(
|
|
288
|
+
expect.stringContaining("Unregistering WebMCP tool"),
|
|
289
|
+
expect.any(Error),
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("warns and skips a name registerTool synchronously refuses", async () => {
|
|
294
|
+
const warn = silenceWarnings();
|
|
295
|
+
const host = useHost(createFakeWebMcpHost());
|
|
296
|
+
host.registry.set("search", {} as WebMcpToolDescriptor);
|
|
297
|
+
|
|
298
|
+
mountProvider(
|
|
299
|
+
createProvider({ search: frontendTool(), alpha: frontendTool() }),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
await waitForNames(["alpha"]);
|
|
303
|
+
expect(warn).toHaveBeenCalledWith(
|
|
304
|
+
expect.stringContaining("registerTool failed"),
|
|
305
|
+
expect.any(Error),
|
|
306
|
+
);
|
|
307
|
+
expect(host.registry.get("search")).toEqual({});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("warns once for a refused name and stops retrying it until it leaves the model context", async () => {
|
|
311
|
+
const warn = silenceWarnings();
|
|
312
|
+
const host = useHost(createFakeWebMcpHost());
|
|
313
|
+
host.registry.set("search", {} as WebMcpToolDescriptor);
|
|
314
|
+
const attempts = vi.spyOn(host, "registerTool");
|
|
315
|
+
|
|
316
|
+
const provider = createProvider({ search: frontendTool() });
|
|
317
|
+
mountProvider(provider);
|
|
318
|
+
await vi.waitFor(() => expect(warn).toHaveBeenCalledOnce());
|
|
319
|
+
|
|
320
|
+
for (let i = 0; i < 10; i++) {
|
|
321
|
+
provider.setTools({ search: frontendTool({ description: `v${i}` }) });
|
|
322
|
+
}
|
|
323
|
+
await vi.waitFor(() =>
|
|
324
|
+
expect(providerResult().registeredToolNames).toEqual([]),
|
|
325
|
+
);
|
|
326
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
327
|
+
expect(attempts).toHaveBeenCalledOnce();
|
|
328
|
+
|
|
329
|
+
host.registry.delete("search");
|
|
330
|
+
provider.setTools({ alpha: frontendTool() });
|
|
331
|
+
await waitForNames(["alpha"]);
|
|
332
|
+
provider.setTools({ alpha: frontendTool(), search: frontendTool() });
|
|
333
|
+
await waitForNames(["alpha", "search"]);
|
|
334
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("does not re-serialize schemas when an inline filter changes identity", async () => {
|
|
338
|
+
useHost(createFakeWebMcpHost());
|
|
339
|
+
let schemaReads = 0;
|
|
340
|
+
const tool = frontendTool();
|
|
341
|
+
Object.defineProperty(tool, "parameters", {
|
|
342
|
+
get: () => {
|
|
343
|
+
schemaReads++;
|
|
344
|
+
return { type: "object", properties: {} };
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const { rerender } = mountProvider(createProvider({ search: tool }), {
|
|
349
|
+
filter: () => true,
|
|
350
|
+
});
|
|
351
|
+
await waitForNames(["search"]);
|
|
352
|
+
const afterMount = schemaReads;
|
|
353
|
+
|
|
354
|
+
for (let i = 0; i < 5; i++) rerender({ filter: () => true });
|
|
355
|
+
await waitForNames(["search"]);
|
|
356
|
+
expect(schemaReads).toBe(afterMount);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("holds a single live registration under StrictMode", async () => {
|
|
360
|
+
const host = useHost(createFakeWebMcpHost());
|
|
361
|
+
mountProvider(createProvider({ search: frontendTool() }), {}, strict);
|
|
362
|
+
|
|
363
|
+
await waitForNames(["search"]);
|
|
364
|
+
expect([...host.registry.keys()]).toEqual(["search"]);
|
|
365
|
+
expect(providerResult().status).toBe("active");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("warns and drops the name when a registration is rejected", async () => {
|
|
369
|
+
const warn = silenceWarnings();
|
|
370
|
+
const pageOwned: WebMcpToolDescriptor[] = [];
|
|
371
|
+
const registry = createAsyncModelContext();
|
|
372
|
+
const context = (document as { modelContext?: WebMcpModelContext })
|
|
373
|
+
.modelContext!;
|
|
374
|
+
const original = context.registerTool.bind(context);
|
|
375
|
+
context.registerTool = (tool, options) => {
|
|
376
|
+
if (tool.name === "search") {
|
|
377
|
+
return Promise.reject(new Error("already registered"));
|
|
378
|
+
}
|
|
379
|
+
pageOwned.push(tool);
|
|
380
|
+
return original(tool, options);
|
|
381
|
+
};
|
|
382
|
+
registry.set("search", { name: "search" } as WebMcpToolDescriptor);
|
|
383
|
+
|
|
384
|
+
mountProvider(createProvider({ search: frontendTool() }));
|
|
385
|
+
|
|
386
|
+
await vi.waitFor(() =>
|
|
387
|
+
expect(providerResult().registeredToolNames).toEqual([]),
|
|
388
|
+
);
|
|
389
|
+
expect(warn).toHaveBeenCalledWith(
|
|
390
|
+
expect.stringContaining('tool "search" failed'),
|
|
391
|
+
expect.any(Error),
|
|
392
|
+
);
|
|
393
|
+
expect(registry.get("search")).toEqual({ name: "search" });
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("names the permissions policy when the host refuses with NotAllowedError", async () => {
|
|
397
|
+
const warn = silenceWarnings();
|
|
398
|
+
let reject!: (error: unknown) => void;
|
|
399
|
+
useHost({
|
|
400
|
+
available: true,
|
|
401
|
+
registerTool: (_def, onError) => {
|
|
402
|
+
reject = (error) => onError?.(error);
|
|
403
|
+
return () => {};
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
mountProvider(createProvider({ search: frontendTool() }));
|
|
407
|
+
await waitForNames(["search"]);
|
|
408
|
+
|
|
409
|
+
const denied = new Error("denied");
|
|
410
|
+
denied.name = "NotAllowedError";
|
|
411
|
+
reject(denied);
|
|
412
|
+
|
|
413
|
+
await vi.waitFor(() =>
|
|
414
|
+
expect(warn).toHaveBeenCalledWith(
|
|
415
|
+
expect.stringContaining("tools permission is disabled"),
|
|
416
|
+
denied,
|
|
417
|
+
),
|
|
418
|
+
);
|
|
419
|
+
await waitForNames([]);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("keeps the tool exposed across a description change on a conforming host", async () => {
|
|
423
|
+
const registry = createAsyncModelContext();
|
|
424
|
+
const provider = createProvider({ search: frontendTool() });
|
|
425
|
+
mountProvider(provider);
|
|
426
|
+
await waitForNames(["search"]);
|
|
427
|
+
|
|
428
|
+
provider.setTools({ search: frontendTool({ description: "renamed" }) });
|
|
429
|
+
await vi.waitFor(() =>
|
|
430
|
+
expect(registry.get("search")?.description).toBe("renamed"),
|
|
431
|
+
);
|
|
432
|
+
await Promise.resolve();
|
|
433
|
+
expect(registry.get("search")?.description).toBe("renamed");
|
|
434
|
+
expect(providerResult().registeredToolNames).toEqual(["search"]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("ignores a late failure reported by a replaced registration", async () => {
|
|
438
|
+
const warn = silenceWarnings();
|
|
439
|
+
const calls: {
|
|
440
|
+
def: WebMcpToolDescriptor;
|
|
441
|
+
onError?: (error: unknown) => void;
|
|
442
|
+
dispose: ReturnType<typeof vi.fn>;
|
|
443
|
+
}[] = [];
|
|
444
|
+
useHost({
|
|
445
|
+
available: true,
|
|
446
|
+
registerTool: (def, onError) => {
|
|
447
|
+
const dispose = vi.fn();
|
|
448
|
+
calls.push({ def, onError, dispose });
|
|
449
|
+
return dispose;
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const provider = createProvider({ search: frontendTool() });
|
|
454
|
+
mountProvider(provider);
|
|
455
|
+
await waitForNames(["search"]);
|
|
456
|
+
|
|
457
|
+
provider.setTools({ search: frontendTool({ description: "renamed" }) });
|
|
458
|
+
await vi.waitFor(() => expect(calls).toHaveLength(2));
|
|
459
|
+
|
|
460
|
+
calls[0]!.onError?.(new Error("late failure"));
|
|
461
|
+
|
|
462
|
+
expect(providerResult().registeredToolNames).toEqual(["search"]);
|
|
463
|
+
expect(calls[1]!.dispose).not.toHaveBeenCalled();
|
|
464
|
+
expect(warn).not.toHaveBeenCalled();
|
|
465
|
+
});
|
|
466
|
+
});
|