@miragon/mcp-toolkit-ui 0.10.1
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 +36 -0
- package/dist/app/adapt-data-widget.d.ts +33 -0
- package/dist/app/adapt-data-widget.d.ts.map +1 -0
- package/dist/app/adapt-data-widget.js +35 -0
- package/dist/app/adapt-data-widget.js.map +1 -0
- package/dist/app/builder/CatalogueSheet.d.ts +13 -0
- package/dist/app/builder/CatalogueSheet.d.ts.map +1 -0
- package/dist/app/builder/CatalogueSheet.js +33 -0
- package/dist/app/builder/CatalogueSheet.js.map +1 -0
- package/dist/app/builder/PipelineStrip.d.ts +22 -0
- package/dist/app/builder/PipelineStrip.d.ts.map +1 -0
- package/dist/app/builder/PipelineStrip.js +31 -0
- package/dist/app/builder/PipelineStrip.js.map +1 -0
- package/dist/app/builder/PreviewPane.d.ts +13 -0
- package/dist/app/builder/PreviewPane.d.ts.map +1 -0
- package/dist/app/builder/PreviewPane.js +21 -0
- package/dist/app/builder/PreviewPane.js.map +1 -0
- package/dist/app/builder/SaveDialog.d.ts +14 -0
- package/dist/app/builder/SaveDialog.d.ts.map +1 -0
- package/dist/app/builder/SaveDialog.js +14 -0
- package/dist/app/builder/SaveDialog.js.map +1 -0
- package/dist/app/builder/Toolbar.d.ts +17 -0
- package/dist/app/builder/Toolbar.d.ts.map +1 -0
- package/dist/app/builder/Toolbar.js +15 -0
- package/dist/app/builder/Toolbar.js.map +1 -0
- package/dist/app/builder/Workspace.d.ts +28 -0
- package/dist/app/builder/Workspace.d.ts.map +1 -0
- package/dist/app/builder/Workspace.js +117 -0
- package/dist/app/builder/Workspace.js.map +1 -0
- package/dist/app/builder/builder-model.d.ts +86 -0
- package/dist/app/builder/builder-model.d.ts.map +1 -0
- package/dist/app/builder/builder-model.js +174 -0
- package/dist/app/builder/builder-model.js.map +1 -0
- package/dist/app/builder/builder-reducer.d.ts +71 -0
- package/dist/app/builder/builder-reducer.d.ts.map +1 -0
- package/dist/app/builder/builder-reducer.js +201 -0
- package/dist/app/builder/builder-reducer.js.map +1 -0
- package/dist/app/builder/labels.d.ts +78 -0
- package/dist/app/builder/labels.d.ts.map +1 -0
- package/dist/app/builder/labels.js +71 -0
- package/dist/app/builder/labels.js.map +1 -0
- package/dist/app/builder/status-chip.d.ts +10 -0
- package/dist/app/builder/status-chip.d.ts.map +1 -0
- package/dist/app/builder/status-chip.js +16 -0
- package/dist/app/builder/status-chip.js.map +1 -0
- package/dist/app/host-bridge.d.ts +211 -0
- package/dist/app/host-bridge.d.ts.map +1 -0
- package/dist/app/host-bridge.js +247 -0
- package/dist/app/host-bridge.js.map +1 -0
- package/dist/app/index.d.ts +16 -0
- package/dist/app/index.d.ts.map +1 -0
- package/dist/app/index.js +18 -0
- package/dist/app/index.js.map +1 -0
- package/dist/app/layout-builder.d.ts +48 -0
- package/dist/app/layout-builder.d.ts.map +1 -0
- package/dist/app/layout-builder.js +418 -0
- package/dist/app/layout-builder.js.map +1 -0
- package/dist/app/mcp-app-view.d.ts +43 -0
- package/dist/app/mcp-app-view.d.ts.map +1 -0
- package/dist/app/mcp-app-view.js +169 -0
- package/dist/app/mcp-app-view.js.map +1 -0
- package/dist/app/mcp-toolkit-app.d.ts +18 -0
- package/dist/app/mcp-toolkit-app.d.ts.map +1 -0
- package/dist/app/mcp-toolkit-app.js +22 -0
- package/dist/app/mcp-toolkit-app.js.map +1 -0
- package/dist/app/use-host-actions.d.ts +57 -0
- package/dist/app/use-host-actions.d.ts.map +1 -0
- package/dist/app/use-host-actions.js +42 -0
- package/dist/app/use-host-actions.js.map +1 -0
- package/dist/app/widget-fixture.d.ts +180 -0
- package/dist/app/widget-fixture.d.ts.map +1 -0
- package/dist/app/widget-fixture.js +273 -0
- package/dist/app/widget-fixture.js.map +1 -0
- package/dist/app/widget-props-sheet.d.ts +20 -0
- package/dist/app/widget-props-sheet.d.ts.map +1 -0
- package/dist/app/widget-props-sheet.js +87 -0
- package/dist/app/widget-props-sheet.js.map +1 -0
- package/dist/app/widget-renderer.d.ts +87 -0
- package/dist/app/widget-renderer.d.ts.map +1 -0
- package/dist/app/widget-renderer.js +105 -0
- package/dist/app/widget-renderer.js.map +1 -0
- package/dist/components/DrillButton.d.ts +22 -0
- package/dist/components/DrillButton.d.ts.map +1 -0
- package/dist/components/DrillButton.js +13 -0
- package/dist/components/DrillButton.js.map +1 -0
- package/dist/components/FilterBar.d.ts +28 -0
- package/dist/components/FilterBar.d.ts.map +1 -0
- package/dist/components/FilterBar.js +12 -0
- package/dist/components/FilterBar.js.map +1 -0
- package/dist/components/GridLayout.d.ts +26 -0
- package/dist/components/GridLayout.d.ts.map +1 -0
- package/dist/components/GridLayout.js +31 -0
- package/dist/components/GridLayout.js.map +1 -0
- package/dist/components/GroupCard.d.ts +21 -0
- package/dist/components/GroupCard.d.ts.map +1 -0
- package/dist/components/GroupCard.js +24 -0
- package/dist/components/GroupCard.js.map +1 -0
- package/dist/components/KpiGrid.d.ts +43 -0
- package/dist/components/KpiGrid.d.ts.map +1 -0
- package/dist/components/KpiGrid.js +35 -0
- package/dist/components/KpiGrid.js.map +1 -0
- package/dist/components/ListFooter.d.ts +22 -0
- package/dist/components/ListFooter.d.ts.map +1 -0
- package/dist/components/ListFooter.js +13 -0
- package/dist/components/ListFooter.js.map +1 -0
- package/dist/components/Pills.d.ts +25 -0
- package/dist/components/Pills.d.ts.map +1 -0
- package/dist/components/Pills.js +17 -0
- package/dist/components/Pills.js.map +1 -0
- package/dist/components/SectionHeading.d.ts +16 -0
- package/dist/components/SectionHeading.d.ts.map +1 -0
- package/dist/components/SectionHeading.js +10 -0
- package/dist/components/SectionHeading.js.map +1 -0
- package/dist/components/WidgetHeader.d.ts +21 -0
- package/dist/components/WidgetHeader.d.ts.map +1 -0
- package/dist/components/WidgetHeader.js +11 -0
- package/dist/components/WidgetHeader.js.map +1 -0
- package/dist/hooks/index.d.ts +11 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +7 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/use-debounced-value.d.ts +7 -0
- package/dist/hooks/use-debounced-value.d.ts.map +1 -0
- package/dist/hooks/use-debounced-value.js +15 -0
- package/dist/hooks/use-debounced-value.js.map +1 -0
- package/dist/hooks/use-mobile.d.ts +6 -0
- package/dist/hooks/use-mobile.d.ts.map +1 -0
- package/dist/hooks/use-mobile.js +19 -0
- package/dist/hooks/use-mobile.js.map +1 -0
- package/dist/hooks/use-tool-query.d.ts +21 -0
- package/dist/hooks/use-tool-query.d.ts.map +1 -0
- package/dist/hooks/use-tool-query.js +47 -0
- package/dist/hooks/use-tool-query.js.map +1 -0
- package/dist/hooks/use-tool-result-recovery.d.ts +54 -0
- package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -0
- package/dist/hooks/use-tool-result-recovery.js +93 -0
- package/dist/hooks/use-tool-result-recovery.js.map +1 -0
- package/dist/hooks/use-view-data.d.ts +19 -0
- package/dist/hooks/use-view-data.d.ts.map +1 -0
- package/dist/hooks/use-view-data.js +18 -0
- package/dist/hooks/use-view-data.js.map +1 -0
- package/dist/hooks/use-view-tool-query.d.ts +18 -0
- package/dist/hooks/use-view-tool-query.d.ts.map +1 -0
- package/dist/hooks/use-view-tool-query.js +29 -0
- package/dist/hooks/use-view-tool-query.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/parse-tool-result.d.ts +39 -0
- package/dist/lib/parse-tool-result.d.ts.map +1 -0
- package/dist/lib/parse-tool-result.js +66 -0
- package/dist/lib/parse-tool-result.js.map +1 -0
- package/dist/lib/tone-utils.d.ts +18 -0
- package/dist/lib/tone-utils.d.ts.map +1 -0
- package/dist/lib/tone-utils.js +25 -0
- package/dist/lib/tone-utils.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/locale/index.d.ts +3 -0
- package/dist/locale/index.d.ts.map +1 -0
- package/dist/locale/index.js +7 -0
- package/dist/locale/index.js.map +1 -0
- package/dist/locale/locale-provider.d.ts +50 -0
- package/dist/locale/locale-provider.d.ts.map +1 -0
- package/dist/locale/locale-provider.js +47 -0
- package/dist/locale/locale-provider.js.map +1 -0
- package/dist/primitives/alert.d.ts +10 -0
- package/dist/primitives/alert.d.ts.map +1 -0
- package/dist/primitives/alert.js +25 -0
- package/dist/primitives/alert.js.map +1 -0
- package/dist/primitives/badge.d.ts +8 -0
- package/dist/primitives/badge.d.ts.map +1 -0
- package/dist/primitives/badge.js +34 -0
- package/dist/primitives/badge.js.map +1 -0
- package/dist/primitives/button.d.ts +11 -0
- package/dist/primitives/button.d.ts.map +1 -0
- package/dist/primitives/button.js +36 -0
- package/dist/primitives/button.js.map +1 -0
- package/dist/primitives/card.d.ts +12 -0
- package/dist/primitives/card.d.ts.map +1 -0
- package/dist/primitives/card.js +25 -0
- package/dist/primitives/card.js.map +1 -0
- package/dist/primitives/dialog.d.ts +18 -0
- package/dist/primitives/dialog.d.ts.map +1 -0
- package/dist/primitives/dialog.js +38 -0
- package/dist/primitives/dialog.js.map +1 -0
- package/dist/primitives/input.d.ts +4 -0
- package/dist/primitives/input.d.ts.map +1 -0
- package/dist/primitives/input.js +7 -0
- package/dist/primitives/input.js.map +1 -0
- package/dist/primitives/scroll-area.d.ts +6 -0
- package/dist/primitives/scroll-area.d.ts.map +1 -0
- package/dist/primitives/scroll-area.js +11 -0
- package/dist/primitives/scroll-area.js.map +1 -0
- package/dist/primitives/select.d.ts +16 -0
- package/dist/primitives/select.d.ts.map +1 -0
- package/dist/primitives/select.js +38 -0
- package/dist/primitives/select.js.map +1 -0
- package/dist/primitives/separator.d.ts +4 -0
- package/dist/primitives/separator.d.ts.map +1 -0
- package/dist/primitives/separator.js +9 -0
- package/dist/primitives/separator.js.map +1 -0
- package/dist/primitives/sheet.d.ts +15 -0
- package/dist/primitives/sheet.d.ts.map +1 -0
- package/dist/primitives/sheet.js +41 -0
- package/dist/primitives/sheet.js.map +1 -0
- package/dist/primitives/skeleton.d.ts +3 -0
- package/dist/primitives/skeleton.d.ts.map +1 -0
- package/dist/primitives/skeleton.js +7 -0
- package/dist/primitives/skeleton.js.map +1 -0
- package/dist/primitives/switch.d.ts +5 -0
- package/dist/primitives/switch.d.ts.map +1 -0
- package/dist/primitives/switch.js +8 -0
- package/dist/primitives/switch.js.map +1 -0
- package/dist/primitives/table.d.ts +11 -0
- package/dist/primitives/table.d.ts.map +1 -0
- package/dist/primitives/table.js +28 -0
- package/dist/primitives/table.js.map +1 -0
- package/dist/primitives/tabs.d.ts +12 -0
- package/dist/primitives/tabs.d.ts.map +1 -0
- package/dist/primitives/tabs.js +29 -0
- package/dist/primitives/tabs.js.map +1 -0
- package/dist/providers/query-provider.d.ts +11 -0
- package/dist/providers/query-provider.d.ts.map +1 -0
- package/dist/providers/query-provider.js +20 -0
- package/dist/providers/query-provider.js.map +1 -0
- package/dist/theme/create-theme.d.ts +130 -0
- package/dist/theme/create-theme.d.ts.map +1 -0
- package/dist/theme/create-theme.js +65 -0
- package/dist/theme/create-theme.js.map +1 -0
- package/dist/theme/index.d.ts +7 -0
- package/dist/theme/index.d.ts.map +1 -0
- package/dist/theme/index.js +7 -0
- package/dist/theme/index.js.map +1 -0
- package/dist/theme/presets.d.ts +25 -0
- package/dist/theme/presets.d.ts.map +1 -0
- package/dist/theme/presets.js +68 -0
- package/dist/theme/presets.js.map +1 -0
- package/dist/theme/theme-provider.d.ts +69 -0
- package/dist/theme/theme-provider.d.ts.map +1 -0
- package/dist/theme/theme-provider.js +79 -0
- package/dist/theme/theme-provider.js.map +1 -0
- package/package.json +71 -0
- package/src/app/adapt-data-widget.tsx +51 -0
- package/src/app/builder/CatalogueSheet.tsx +230 -0
- package/src/app/builder/PipelineStrip.tsx +260 -0
- package/src/app/builder/PreviewPane.tsx +72 -0
- package/src/app/builder/SaveDialog.tsx +95 -0
- package/src/app/builder/Toolbar.tsx +95 -0
- package/src/app/builder/Workspace.tsx +483 -0
- package/src/app/builder/builder-model.test.ts +351 -0
- package/src/app/builder/builder-model.ts +213 -0
- package/src/app/builder/builder-reducer.test.ts +441 -0
- package/src/app/builder/builder-reducer.ts +289 -0
- package/src/app/builder/labels.ts +139 -0
- package/src/app/builder/status-chip.tsx +37 -0
- package/src/app/host-bridge.test.ts +206 -0
- package/src/app/host-bridge.tsx +371 -0
- package/src/app/index.ts +51 -0
- package/src/app/layout-builder.tsx +872 -0
- package/src/app/mcp-app-view.tsx +367 -0
- package/src/app/mcp-toolkit-app.tsx +25 -0
- package/src/app/use-host-actions.test.ts +16 -0
- package/src/app/use-host-actions.tsx +88 -0
- package/src/app/widget-fixture.test.ts +108 -0
- package/src/app/widget-fixture.tsx +441 -0
- package/src/app/widget-props-sheet.tsx +285 -0
- package/src/app/widget-renderer.test.ts +42 -0
- package/src/app/widget-renderer.tsx +200 -0
- package/src/components/DrillButton.tsx +37 -0
- package/src/components/FilterBar.tsx +68 -0
- package/src/components/GridLayout.tsx +49 -0
- package/src/components/GroupCard.tsx +52 -0
- package/src/components/KpiGrid.tsx +134 -0
- package/src/components/ListFooter.tsx +48 -0
- package/src/components/Pills.tsx +45 -0
- package/src/components/SectionHeading.tsx +26 -0
- package/src/components/WidgetHeader.tsx +51 -0
- package/src/globals.css +168 -0
- package/src/hooks/index.ts +17 -0
- package/src/hooks/use-debounced-value.ts +15 -0
- package/src/hooks/use-mobile.ts +24 -0
- package/src/hooks/use-tool-query.ts +63 -0
- package/src/hooks/use-tool-result-recovery.test.ts +65 -0
- package/src/hooks/use-tool-result-recovery.ts +144 -0
- package/src/hooks/use-view-data.ts +31 -0
- package/src/hooks/use-view-tool-query.ts +38 -0
- package/src/index.ts +134 -0
- package/src/lib/parse-tool-result.test.ts +139 -0
- package/src/lib/parse-tool-result.ts +89 -0
- package/src/lib/tone-utils.test.ts +35 -0
- package/src/lib/tone-utils.ts +38 -0
- package/src/lib/utils.ts +6 -0
- package/src/locale/index.ts +7 -0
- package/src/locale/locale-provider.tsx +81 -0
- package/src/primitives/alert.tsx +60 -0
- package/src/primitives/badge.tsx +49 -0
- package/src/primitives/button.tsx +62 -0
- package/src/primitives/card.tsx +92 -0
- package/src/primitives/dialog.tsx +144 -0
- package/src/primitives/input.tsx +21 -0
- package/src/primitives/scroll-area.tsx +54 -0
- package/src/primitives/select.tsx +173 -0
- package/src/primitives/separator.tsx +21 -0
- package/src/primitives/sheet.tsx +134 -0
- package/src/primitives/skeleton.tsx +13 -0
- package/src/primitives/switch.tsx +24 -0
- package/src/primitives/table.tsx +87 -0
- package/src/primitives/tabs.tsx +79 -0
- package/src/providers/query-provider.tsx +38 -0
- package/src/theme/create-theme.test.ts +108 -0
- package/src/theme/create-theme.ts +158 -0
- package/src/theme/index.ts +9 -0
- package/src/theme/presets.ts +82 -0
- package/src/theme/theme-provider.tsx +152 -0
- package/src/ui-catalog.test.ts +112 -0
- package/ui-catalog.json +1280 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { decodeRecoveredResult, resolveRecoveryToolName } from "./use-tool-result-recovery.js"
|
|
3
|
+
|
|
4
|
+
// The hook's effect wiring (single-flight, StrictMode re-run, host-payload
|
|
5
|
+
// precedence) is exercised end-to-end by the consumer's Playwright host
|
|
6
|
+
// simulation (structuredContent keep/strip scenarios); these tests pin the
|
|
7
|
+
// pure decision logic.
|
|
8
|
+
|
|
9
|
+
describe("resolveRecoveryToolName", () => {
|
|
10
|
+
it("reads the SEP-1865 shape toolInfo.tool.name first", () => {
|
|
11
|
+
expect(
|
|
12
|
+
resolveRecoveryToolName({
|
|
13
|
+
toolInfo: { tool: { name: "render-view" }, name: "ignored" },
|
|
14
|
+
}),
|
|
15
|
+
).toBe("render-view")
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("falls back to the flattened variants some hosts send", () => {
|
|
19
|
+
expect(resolveRecoveryToolName({ toolInfo: { name: "show_cockpit" } })).toBe("show_cockpit")
|
|
20
|
+
expect(resolveRecoveryToolName({ toolInfo: { toolName: "show_cockpit" } })).toBe("show_cockpit")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("returns undefined when the tool name is missing or unusable", () => {
|
|
24
|
+
expect(resolveRecoveryToolName(undefined)).toBeUndefined()
|
|
25
|
+
expect(resolveRecoveryToolName({})).toBeUndefined()
|
|
26
|
+
expect(resolveRecoveryToolName({ toolInfo: null })).toBeUndefined()
|
|
27
|
+
expect(resolveRecoveryToolName({ toolInfo: { name: "" } })).toBeUndefined()
|
|
28
|
+
expect(resolveRecoveryToolName({ toolInfo: { name: 42 } })).toBeUndefined()
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe("decodeRecoveredResult", () => {
|
|
33
|
+
const isView = (v: unknown): boolean =>
|
|
34
|
+
Boolean((v as { context?: unknown; layout?: unknown } | null)?.context) &&
|
|
35
|
+
Boolean((v as { layout?: unknown } | null)?.layout)
|
|
36
|
+
|
|
37
|
+
it("decodes structuredContent-first and validates it", () => {
|
|
38
|
+
const result = {
|
|
39
|
+
content: [{ type: "text", text: "Cockpit rendered" }],
|
|
40
|
+
structuredContent: { context: { keys: {} }, layout: { rows: [] } },
|
|
41
|
+
}
|
|
42
|
+
expect(decodeRecoveredResult(result, isView)).toEqual({
|
|
43
|
+
context: { keys: {} },
|
|
44
|
+
layout: { rows: [] },
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("rejects payloads the validator refuses", () => {
|
|
49
|
+
const result = {
|
|
50
|
+
content: [{ type: "text", text: "just a summary" }],
|
|
51
|
+
structuredContent: { unrelated: true },
|
|
52
|
+
}
|
|
53
|
+
expect(decodeRecoveredResult(result, isView)).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("returns null for error results instead of throwing", () => {
|
|
57
|
+
const result = { isError: true, content: [{ type: "text", text: "[503] engine down" }] }
|
|
58
|
+
expect(decodeRecoveredResult(result, isView)).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("returns null for empty results", () => {
|
|
62
|
+
expect(decodeRecoveredResult(undefined, isView)).toBeNull()
|
|
63
|
+
expect(decodeRecoveredResult({ content: [] }, isView)).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react"
|
|
2
|
+
import { parseToolResult } from "../lib/parse-tool-result.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Recovery for hosts that deliver the `ui/notifications/tool-result`
|
|
6
|
+
* notification without `structuredContent` (observed on claude.ai and Claude
|
|
7
|
+
* Desktop, which forward only the model-facing `content` blocks). The
|
|
8
|
+
* mcp-use bridge then hands the widget an empty props object and the shell
|
|
9
|
+
* would idle on its loading skeleton forever.
|
|
10
|
+
*
|
|
11
|
+
* When the result is ready but the payload is invalid, the hook re-executes
|
|
12
|
+
* the originating tool once via the host bridge's official `callTool` —
|
|
13
|
+
* tool-call RESPONSES carry `structuredContent` intact on those hosts — and
|
|
14
|
+
* exposes the decoded payload. Spec-conforming hosts (e.g. the mcp-use
|
|
15
|
+
* inspector) deliver a valid payload up front, so the recovery never fires
|
|
16
|
+
* there.
|
|
17
|
+
*
|
|
18
|
+
* Assumes the originating tool is a read-only render pipeline (true for all
|
|
19
|
+
* toolkit `render-view`/`show_*` tools) — the re-execution must be safe to
|
|
20
|
+
* repeat.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface ToolResultRecoveryOptions {
|
|
24
|
+
/** `!isPending` from `useWidget` — the host reported a finished tool call. */
|
|
25
|
+
resultReady: boolean
|
|
26
|
+
/** The props the host delivered (`useWidget().props`). */
|
|
27
|
+
props: unknown
|
|
28
|
+
/**
|
|
29
|
+
* Validates a candidate payload. MUST be referentially stable (module-level
|
|
30
|
+
* function or memoised) — it is an effect dependency.
|
|
31
|
+
*/
|
|
32
|
+
isValid: (value: unknown) => boolean
|
|
33
|
+
/** The tool arguments from `ui/notifications/tool-input` (`useWidget().toolInput`). */
|
|
34
|
+
toolInput: Record<string, unknown> | null | undefined
|
|
35
|
+
/** Raw host context (`useWidget().hostContext`); `toolInfo` names the originating tool. */
|
|
36
|
+
hostContext: Record<string, unknown> | null | undefined
|
|
37
|
+
/** The bridge's `callTool` (`useWidget().callTool`). */
|
|
38
|
+
callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ToolResultRecoveryState<T> {
|
|
42
|
+
/** The recovered payload, `null` until the re-execution resolved (or when it failed). */
|
|
43
|
+
data: T | null
|
|
44
|
+
/** `true` when the single recovery attempt failed — the shell keeps its skeleton, no retry loop. */
|
|
45
|
+
failed: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const IDLE: ToolResultRecoveryState<never> = { data: null, failed: false }
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves the originating tool name from the host context's `toolInfo`.
|
|
52
|
+
* Reads the SEP-1865 shape first (`toolInfo.tool.name`), then the flattened
|
|
53
|
+
* variants some hosts send (`toolInfo.name`, `toolInfo.toolName`).
|
|
54
|
+
*/
|
|
55
|
+
export function resolveRecoveryToolName(hostContext: unknown): string | undefined {
|
|
56
|
+
if (!hostContext || typeof hostContext !== "object") return undefined
|
|
57
|
+
const info = (hostContext as Record<string, unknown>).toolInfo
|
|
58
|
+
if (!info || typeof info !== "object") return undefined
|
|
59
|
+
const rec = info as Record<string, unknown>
|
|
60
|
+
const tool = rec.tool
|
|
61
|
+
const candidates = [
|
|
62
|
+
tool && typeof tool === "object" ? (tool as Record<string, unknown>).name : undefined,
|
|
63
|
+
rec.name,
|
|
64
|
+
rec.toolName,
|
|
65
|
+
]
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
if (typeof candidate === "string" && candidate.length > 0) return candidate
|
|
68
|
+
}
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decodes a re-executed tool result and validates it. Exposed for tests;
|
|
74
|
+
* the hook uses it on the `callTool` response.
|
|
75
|
+
*/
|
|
76
|
+
export function decodeRecoveredResult<T>(
|
|
77
|
+
result: unknown,
|
|
78
|
+
isValid: (value: unknown) => boolean,
|
|
79
|
+
): T | null {
|
|
80
|
+
let decoded: unknown
|
|
81
|
+
try {
|
|
82
|
+
decoded = parseToolResult<unknown>(result)
|
|
83
|
+
} catch {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
return decoded !== null && isValid(decoded) ? (decoded as T) : null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function useToolResultRecovery<T>(
|
|
90
|
+
options: ToolResultRecoveryOptions,
|
|
91
|
+
): ToolResultRecoveryState<T> {
|
|
92
|
+
const { resultReady, props, isValid, toolInput, hostContext, callTool } = options
|
|
93
|
+
const [state, setState] = useState<ToolResultRecoveryState<T>>(IDLE)
|
|
94
|
+
// Single-flight: the promise ref survives StrictMode's dev-mode
|
|
95
|
+
// effect re-run (cleanup + setup on the same instance), so the re-run
|
|
96
|
+
// re-subscribes to the SAME in-flight call instead of firing a second one.
|
|
97
|
+
const flightRef = useRef<Promise<ToolResultRecoveryState<T>> | null>(null)
|
|
98
|
+
const flightInputRef = useRef<unknown>(null)
|
|
99
|
+
|
|
100
|
+
const toolName = resolveRecoveryToolName(hostContext)
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
// A new tool run (result no longer ready, or fresh tool-input identity)
|
|
104
|
+
// invalidates any previous recovery attempt and re-arms the single-flight.
|
|
105
|
+
if (flightRef.current && (!resultReady || flightInputRef.current !== toolInput)) {
|
|
106
|
+
flightRef.current = null
|
|
107
|
+
setState((prev) => (prev.data === null && !prev.failed ? prev : IDLE))
|
|
108
|
+
}
|
|
109
|
+
// Never fire on conforming hosts (valid payload) or before the
|
|
110
|
+
// originating tool is known.
|
|
111
|
+
if (!resultReady || isValid(props) || toolName === undefined) return
|
|
112
|
+
|
|
113
|
+
if (!flightRef.current) {
|
|
114
|
+
flightInputRef.current = toolInput
|
|
115
|
+
flightRef.current = (async (): Promise<ToolResultRecoveryState<T>> => {
|
|
116
|
+
try {
|
|
117
|
+
const result = await callTool(toolName, toolInput ?? {})
|
|
118
|
+
const data = decodeRecoveredResult<T>(result, isValid)
|
|
119
|
+
if (data !== null) return { data, failed: false }
|
|
120
|
+
console.warn(
|
|
121
|
+
`[mcp-toolkit] tool-result recovery: re-executing "${toolName}" returned no usable payload`,
|
|
122
|
+
)
|
|
123
|
+
return { data: null, failed: true }
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.warn(`[mcp-toolkit] tool-result recovery for "${toolName}" failed:`, err)
|
|
126
|
+
return { data: null, failed: true }
|
|
127
|
+
}
|
|
128
|
+
})()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const flight = flightRef.current
|
|
132
|
+
let stale = false
|
|
133
|
+
void flight.then((outcome) => {
|
|
134
|
+
// Drop the outcome when this subscription was superseded or the flight
|
|
135
|
+
// was invalidated by a newer tool run.
|
|
136
|
+
if (!stale && flightRef.current === flight) setState(outcome)
|
|
137
|
+
})
|
|
138
|
+
return () => {
|
|
139
|
+
stale = true
|
|
140
|
+
}
|
|
141
|
+
}, [resultReady, props, isValid, toolName, toolInput, callTool])
|
|
142
|
+
|
|
143
|
+
return state
|
|
144
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useToolQuery } from "./use-tool-query.js"
|
|
2
|
+
|
|
3
|
+
export interface ViewDataResult<T> {
|
|
4
|
+
data: T | null
|
|
5
|
+
/** True while self-fetching with no data yet — the first paint of a cockpit cell. */
|
|
6
|
+
loading: boolean
|
|
7
|
+
error: Error | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The seam that lets one widget component serve both modes without a duplicate UI.
|
|
12
|
+
*
|
|
13
|
+
* Standalone (a `*_show_*` widget) the agent's tool result is handed in via
|
|
14
|
+
* `initialData` (from the view's stepData, e.g. through `adaptDataWidget`) and
|
|
15
|
+
* returned verbatim. Embedded in a self-fetching cockpit only scope params are
|
|
16
|
+
* passed, so the component fetches `tool` with `args` under `key` — disabled
|
|
17
|
+
* unless data is absent AND `ready` (i.e. the required id/key is present).
|
|
18
|
+
* Sibling widgets sharing `key` dedupe to a single call, so a composed view of
|
|
19
|
+
* N widgets over one data feed fetches once.
|
|
20
|
+
*/
|
|
21
|
+
export function useViewData<T>(
|
|
22
|
+
initialData: T | null | undefined,
|
|
23
|
+
key: ReadonlyArray<unknown>,
|
|
24
|
+
tool: string,
|
|
25
|
+
args: Record<string, unknown>,
|
|
26
|
+
ready: boolean,
|
|
27
|
+
): ViewDataResult<T> {
|
|
28
|
+
const query = useToolQuery<T>([...key], tool, args, { enabled: !initialData && ready })
|
|
29
|
+
const data = initialData ?? query.data ?? null
|
|
30
|
+
return { data, loading: !data && ready && !query.isError, error: query.error ?? null }
|
|
31
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useQuery, type UseQueryResult } from "@tanstack/react-query"
|
|
2
|
+
import { useCallTool } from "../providers/query-provider.js"
|
|
3
|
+
import { parseViewToolResult } from "../lib/parse-tool-result.js"
|
|
4
|
+
|
|
5
|
+
export interface UseViewToolQueryOptions {
|
|
6
|
+
enabled?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Self-fetch hook for `*_show_*` widget tools — the envelope-unwrapping sibling
|
|
11
|
+
* of {@link useToolQuery}. A widget tool's text channel carries only a model
|
|
12
|
+
* summary and its `structuredContent` is the full view envelope, so decoding
|
|
13
|
+
* with the plain {@link parseToolResult} would hand the widget the whole
|
|
14
|
+
* envelope; this hook decodes via {@link parseViewToolResult} (single-step →
|
|
15
|
+
* flat data, multi-step → keyed by step id) instead.
|
|
16
|
+
*
|
|
17
|
+
* `args` is appended to the effective query key — same queryKey-vs-args
|
|
18
|
+
* contract as {@link useToolQuery}, so sibling widgets sharing a query key
|
|
19
|
+
* collapse to a single deduped tool call.
|
|
20
|
+
*/
|
|
21
|
+
export function useViewToolQuery<T = unknown>(
|
|
22
|
+
queryKey: unknown[],
|
|
23
|
+
toolName: string,
|
|
24
|
+
args: object,
|
|
25
|
+
opts?: UseViewToolQueryOptions,
|
|
26
|
+
): UseQueryResult<T, Error> {
|
|
27
|
+
const callTool = useCallTool()
|
|
28
|
+
|
|
29
|
+
return useQuery<T, Error>({
|
|
30
|
+
queryKey: [...queryKey, args],
|
|
31
|
+
queryFn: async () => {
|
|
32
|
+
if (!callTool) throw new Error("callTool not available")
|
|
33
|
+
const result = await callTool(toolName, args)
|
|
34
|
+
return parseViewToolResult<T>(result)
|
|
35
|
+
},
|
|
36
|
+
enabled: !!callTool && (opts?.enabled ?? true),
|
|
37
|
+
})
|
|
38
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// shadcn base primitives
|
|
2
|
+
export {
|
|
3
|
+
Card,
|
|
4
|
+
CardHeader,
|
|
5
|
+
CardFooter,
|
|
6
|
+
CardTitle,
|
|
7
|
+
CardAction,
|
|
8
|
+
CardDescription,
|
|
9
|
+
CardContent,
|
|
10
|
+
} from "./primitives/card.js"
|
|
11
|
+
export { Badge, badgeVariants } from "./primitives/badge.js"
|
|
12
|
+
export {
|
|
13
|
+
Table,
|
|
14
|
+
TableHeader,
|
|
15
|
+
TableBody,
|
|
16
|
+
TableFooter,
|
|
17
|
+
TableRow,
|
|
18
|
+
TableHead,
|
|
19
|
+
TableCell,
|
|
20
|
+
TableCaption,
|
|
21
|
+
} from "./primitives/table.js"
|
|
22
|
+
export { Separator } from "./primitives/separator.js"
|
|
23
|
+
export { Skeleton } from "./primitives/skeleton.js"
|
|
24
|
+
export { Tabs, TabsList, TabsTrigger, TabsContent } from "./primitives/tabs.js"
|
|
25
|
+
export { Button, buttonVariants } from "./primitives/button.js"
|
|
26
|
+
export { Input } from "./primitives/input.js"
|
|
27
|
+
export {
|
|
28
|
+
Select,
|
|
29
|
+
SelectContent,
|
|
30
|
+
SelectGroup,
|
|
31
|
+
SelectItem,
|
|
32
|
+
SelectLabel,
|
|
33
|
+
SelectScrollDownButton,
|
|
34
|
+
SelectScrollUpButton,
|
|
35
|
+
SelectSeparator,
|
|
36
|
+
SelectTrigger,
|
|
37
|
+
SelectValue,
|
|
38
|
+
} from "./primitives/select.js"
|
|
39
|
+
export {
|
|
40
|
+
Dialog,
|
|
41
|
+
DialogClose,
|
|
42
|
+
DialogContent,
|
|
43
|
+
DialogDescription,
|
|
44
|
+
DialogFooter,
|
|
45
|
+
DialogHeader,
|
|
46
|
+
DialogOverlay,
|
|
47
|
+
DialogPortal,
|
|
48
|
+
DialogTitle,
|
|
49
|
+
DialogTrigger,
|
|
50
|
+
} from "./primitives/dialog.js"
|
|
51
|
+
export { Alert, AlertDescription, AlertTitle } from "./primitives/alert.js"
|
|
52
|
+
export { Switch } from "./primitives/switch.js"
|
|
53
|
+
export { ScrollArea, ScrollBar } from "./primitives/scroll-area.js"
|
|
54
|
+
export {
|
|
55
|
+
Sheet,
|
|
56
|
+
SheetClose,
|
|
57
|
+
SheetContent,
|
|
58
|
+
SheetDescription,
|
|
59
|
+
SheetFooter,
|
|
60
|
+
SheetHeader,
|
|
61
|
+
SheetTitle,
|
|
62
|
+
SheetTrigger,
|
|
63
|
+
} from "./primitives/sheet.js"
|
|
64
|
+
|
|
65
|
+
// Hooks
|
|
66
|
+
// NOTE: the view-tool hooks (`useViewToolQuery`, `useViewData`) are exported
|
|
67
|
+
// from the `./hooks` subpath only — see `packages/ui/src/hooks/index.ts`.
|
|
68
|
+
export { useIsMobile } from "./hooks/use-mobile.js"
|
|
69
|
+
export { useToolQuery, useToolMutation } from "./hooks/use-tool-query.js"
|
|
70
|
+
export type { UseToolQueryOptions } from "./hooks/use-tool-query.js"
|
|
71
|
+
export { useDebouncedValue } from "./hooks/use-debounced-value.js"
|
|
72
|
+
|
|
73
|
+
// Tone token system — the semantic status tones (ToneVariant + TONE_* maps)
|
|
74
|
+
// behind the tone-aware components below. Pure data + class strings.
|
|
75
|
+
export { TONE_SOFT, TONE_DOT, TONE_TEXT } from "./lib/tone-utils.js"
|
|
76
|
+
export type { ToneVariant } from "./lib/tone-utils.js"
|
|
77
|
+
|
|
78
|
+
// Composed components — focused widget building blocks assembled from the
|
|
79
|
+
// primitives. Pure (React + primitives + cn), so they live in the root barrel.
|
|
80
|
+
export { GridLayout, GridItem } from "./components/GridLayout.js"
|
|
81
|
+
export type { GridLayoutProps, GridItemProps } from "./components/GridLayout.js"
|
|
82
|
+
export { KpiGrid } from "./components/KpiGrid.js"
|
|
83
|
+
export type { KpiGridProps, KpiGridHeader, KpiCell } from "./components/KpiGrid.js"
|
|
84
|
+
export { LivePill, CountPill } from "./components/Pills.js"
|
|
85
|
+
export type { LivePillProps, CountPillProps } from "./components/Pills.js"
|
|
86
|
+
export { SectionHeading } from "./components/SectionHeading.js"
|
|
87
|
+
export type { SectionHeadingProps } from "./components/SectionHeading.js"
|
|
88
|
+
export { WidgetHeader } from "./components/WidgetHeader.js"
|
|
89
|
+
export type { WidgetHeaderProps } from "./components/WidgetHeader.js"
|
|
90
|
+
export { GroupCard } from "./components/GroupCard.js"
|
|
91
|
+
export type { GroupCardProps } from "./components/GroupCard.js"
|
|
92
|
+
export { FilterBar } from "./components/FilterBar.js"
|
|
93
|
+
export type { FilterBarProps, FilterChip } from "./components/FilterBar.js"
|
|
94
|
+
export { DrillButton } from "./components/DrillButton.js"
|
|
95
|
+
export type { DrillButtonProps } from "./components/DrillButton.js"
|
|
96
|
+
export { ListFooter } from "./components/ListFooter.js"
|
|
97
|
+
export type { ListFooterProps } from "./components/ListFooter.js"
|
|
98
|
+
|
|
99
|
+
// Providers
|
|
100
|
+
export { AppQueryProvider, useCallTool, queryClient } from "./providers/query-provider.js"
|
|
101
|
+
|
|
102
|
+
// Theming / white-label token system. Browser-safe (React + DOM only, no
|
|
103
|
+
// `mcp-use/react`), so it belongs in this root barrel: a consumer can re-skin
|
|
104
|
+
// the toolkit's primitives from a single switch without pulling in the host
|
|
105
|
+
// runtime. See packages/ui/src/theme/ and docs/guides/white-labeling.md.
|
|
106
|
+
export { createTheme, ThemeProvider, useTheme, themePresets } from "./theme/index.js"
|
|
107
|
+
export type {
|
|
108
|
+
ThemeTokens,
|
|
109
|
+
ThemeDefinition,
|
|
110
|
+
ThemeVars,
|
|
111
|
+
CreateThemeOptions,
|
|
112
|
+
ThemeMode,
|
|
113
|
+
ThemeContextValue,
|
|
114
|
+
ThemeProviderProps,
|
|
115
|
+
ThemePresetId,
|
|
116
|
+
} from "./theme/index.js"
|
|
117
|
+
|
|
118
|
+
// i18n / localization. Browser-safe (React only, no `mcp-use/react`), so it
|
|
119
|
+
// belongs in this root barrel: a consumer wraps its widget tree in
|
|
120
|
+
// <LocaleProvider locale=… translator=…> and reads the active locale with
|
|
121
|
+
// useLocale/useTranslate. Pairs with `createTranslator` from
|
|
122
|
+
// `@miragon/mcp-toolkit-core`. See packages/ui/src/locale/.
|
|
123
|
+
export { LocaleProvider, useLocale, useTranslate } from "./locale/index.js"
|
|
124
|
+
export type { LocaleContextValue, LocaleProviderProps, BoundTranslate } from "./locale/index.js"
|
|
125
|
+
|
|
126
|
+
// NOTE: MCP App shell (McpAppView, WidgetRenderer) is exported from the
|
|
127
|
+
// `./app` subpath only. Keeping it out of the main barrel prevents consumers
|
|
128
|
+
// that only need primitives (e.g. an admin portal) from pulling `mcp-use/react`
|
|
129
|
+
// and its langchain transitive peer into their Vite bundle.
|
|
130
|
+
|
|
131
|
+
// Utils
|
|
132
|
+
export { cn } from "./lib/utils.js"
|
|
133
|
+
export { parseToolResult, parseViewToolResult } from "./lib/parse-tool-result.js"
|
|
134
|
+
export type { ParseToolResultOptions } from "./lib/parse-tool-result.js"
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { parseToolResult, parseViewToolResult } from "./parse-tool-result.js"
|
|
3
|
+
|
|
4
|
+
describe("parseToolResult (structuredContent-first contract)", () => {
|
|
5
|
+
it("prefers structuredContent over the text channel", () => {
|
|
6
|
+
const result = {
|
|
7
|
+
content: [{ type: "text", text: "Process list: 2 definitions." }],
|
|
8
|
+
structuredContent: { items: [1, 2], total: 2 },
|
|
9
|
+
}
|
|
10
|
+
expect(parseToolResult(result)).toEqual({ items: [1, 2], total: 2 })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("prefers structuredContent even when the text channel is parseable JSON", () => {
|
|
14
|
+
const result = {
|
|
15
|
+
content: [{ type: "text", text: JSON.stringify({ fromText: true }) }],
|
|
16
|
+
structuredContent: { fromStructured: true },
|
|
17
|
+
}
|
|
18
|
+
expect(parseToolResult(result)).toEqual({ fromStructured: true })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("falls back to JSON-decoding the first text block without structuredContent", () => {
|
|
22
|
+
const result = {
|
|
23
|
+
content: [{ type: "text", text: JSON.stringify({ items: [1, 2], total: 2 }) }],
|
|
24
|
+
}
|
|
25
|
+
expect(parseToolResult(result)).toEqual({ items: [1, 2], total: 2 })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("returns non-JSON text verbatim without structuredContent", () => {
|
|
29
|
+
const result = { content: [{ type: "text", text: "plain status line" }] }
|
|
30
|
+
expect(parseToolResult<string>(result)).toBe("plain status line")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("falls back to the raw result when neither channel is present", () => {
|
|
34
|
+
const result = { something: "else" }
|
|
35
|
+
expect(parseToolResult(result)).toEqual({ something: "else" })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("treats structuredContent: null as absent and falls through to text", () => {
|
|
39
|
+
const result = {
|
|
40
|
+
content: [{ type: "text", text: JSON.stringify({ fromText: true }) }],
|
|
41
|
+
structuredContent: null,
|
|
42
|
+
}
|
|
43
|
+
expect(parseToolResult(result)).toEqual({ fromText: true })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("returns a falsy-but-present structuredContent (e.g. 0) instead of falling through", () => {
|
|
47
|
+
const result = { content: [{ type: "text", text: "summary" }], structuredContent: 0 }
|
|
48
|
+
expect(parseToolResult<number>(result)).toBe(0)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("throws the first text block as the error message on isError results", () => {
|
|
52
|
+
const result = { isError: true, content: [{ type: "text", text: "boom" }] }
|
|
53
|
+
expect(() => parseToolResult(result)).toThrowError("boom")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("throws a generic message on isError results without content", () => {
|
|
57
|
+
expect(() => parseToolResult({ isError: true })).toThrowError("Tool call failed")
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('parseToolResult with prefer: "text"', () => {
|
|
62
|
+
it("reads the text channel first when both are present", () => {
|
|
63
|
+
const result = {
|
|
64
|
+
content: [{ type: "text", text: JSON.stringify({ fromText: true }) }],
|
|
65
|
+
structuredContent: { fromStructured: true },
|
|
66
|
+
}
|
|
67
|
+
expect(parseToolResult(result, { prefer: "text" })).toEqual({ fromText: true })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("falls back to structuredContent when there is no text block", () => {
|
|
71
|
+
const result = { structuredContent: { fromStructured: true } }
|
|
72
|
+
expect(parseToolResult(result, { prefer: "text" })).toEqual({ fromStructured: true })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("returns non-JSON text verbatim before consulting structuredContent", () => {
|
|
76
|
+
const result = {
|
|
77
|
+
content: [{ type: "text", text: "plain line" }],
|
|
78
|
+
structuredContent: { fromStructured: true },
|
|
79
|
+
}
|
|
80
|
+
expect(parseToolResult<string>(result, { prefer: "text" })).toBe("plain line")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("still throws on isError results", () => {
|
|
84
|
+
const result = { isError: true, content: [{ type: "text", text: "boom" }] }
|
|
85
|
+
expect(() => parseToolResult(result, { prefer: "text" })).toThrowError("boom")
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe("parseViewToolResult (show-tool envelope unwrap)", () => {
|
|
90
|
+
it("unwraps a single-step view envelope to the widget's flat data", () => {
|
|
91
|
+
const result = {
|
|
92
|
+
content: [{ type: "text", text: "Model summary." }],
|
|
93
|
+
structuredContent: {
|
|
94
|
+
title: "Instances",
|
|
95
|
+
context: {
|
|
96
|
+
keys: {},
|
|
97
|
+
stepIds: ["result"],
|
|
98
|
+
stepData: { result: { data: { rows: [1], total: 1 }, keys: {} } },
|
|
99
|
+
errors: [],
|
|
100
|
+
},
|
|
101
|
+
layout: [],
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
expect(parseViewToolResult(result)).toEqual({ rows: [1], total: 1 })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("returns multi-step composed views keyed by step id", () => {
|
|
108
|
+
const result = {
|
|
109
|
+
structuredContent: {
|
|
110
|
+
context: {
|
|
111
|
+
stepIds: ["kpis", "result_1"],
|
|
112
|
+
stepData: {
|
|
113
|
+
kpis: { data: { totalCount: 5 } },
|
|
114
|
+
result_1: { data: { patterns: [] } },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
layout: [],
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
expect(parseViewToolResult(result)).toEqual({
|
|
121
|
+
kpis: { totalCount: 5 },
|
|
122
|
+
result_1: { patterns: [] },
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("passes non-envelope results (e.g. *_data feeds) through unchanged", () => {
|
|
127
|
+
const data = { jobs: [], totalCount: 0 }
|
|
128
|
+
const result = {
|
|
129
|
+
content: [{ type: "text", text: JSON.stringify(data) }],
|
|
130
|
+
structuredContent: data,
|
|
131
|
+
}
|
|
132
|
+
expect(parseViewToolResult(result)).toEqual(data)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("propagates isError results as thrown errors", () => {
|
|
136
|
+
const result = { isError: true, content: [{ type: "text", text: "boom" }] }
|
|
137
|
+
expect(() => parseViewToolResult(result)).toThrowError("boom")
|
|
138
|
+
})
|
|
139
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ViewStructuredContent } from "@miragon/mcp-toolkit-core"
|
|
2
|
+
|
|
3
|
+
export interface ParseToolResultOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Which channel to read first when both are present.
|
|
6
|
+
*
|
|
7
|
+
* - `"structured"` (default): `structuredContent`-first. Since the widget-tool
|
|
8
|
+
* "text-channel diet" the text block carries only a short model-facing
|
|
9
|
+
* summary, while the data payload lives in `structuredContent` (the view
|
|
10
|
+
* builders and `*_data` feeds always set it). The text channel remains a
|
|
11
|
+
* fallback for plain results from other servers.
|
|
12
|
+
* - `"text"`: text-first (the pre-0.4 behaviour) — JSON-decode the first text
|
|
13
|
+
* block, then fall back to `structuredContent`. Kept for callers that
|
|
14
|
+
* deliberately prefer a tool's human-readable text payload.
|
|
15
|
+
*/
|
|
16
|
+
prefer?: "structured" | "text"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Decodes an MCP tool result down to its data payload.
|
|
21
|
+
*
|
|
22
|
+
* Default (`prefer: "structured"`): `isError` → throw the first text block,
|
|
23
|
+
* then `structuredContent` (if non-null), then JSON-decode the first text
|
|
24
|
+
* block (verbatim string if it isn't JSON), then the raw result.
|
|
25
|
+
*
|
|
26
|
+
* This is **structured-first** — a deliberate, observable change from the
|
|
27
|
+
* pre-0.4 text-first parsing: tools that return BOTH a text summary and
|
|
28
|
+
* `structuredContent` now yield the structured payload, not the summary
|
|
29
|
+
* string. `useToolQuery`/`useToolMutation` and the MCP App shell all decode
|
|
30
|
+
* through this one parser so the convention is uniform.
|
|
31
|
+
*/
|
|
32
|
+
export function parseToolResult<T = unknown>(result: unknown, opts?: ParseToolResultOptions): T {
|
|
33
|
+
const r = result as
|
|
34
|
+
| {
|
|
35
|
+
content?: Array<{ text?: string }>
|
|
36
|
+
structuredContent?: unknown
|
|
37
|
+
isError?: boolean
|
|
38
|
+
}
|
|
39
|
+
| undefined
|
|
40
|
+
if (r?.isError) throw new Error(r.content?.[0]?.text ?? "Tool call failed")
|
|
41
|
+
|
|
42
|
+
const fromStructured = (): T | undefined =>
|
|
43
|
+
r?.structuredContent != null ? (r.structuredContent as T) : undefined
|
|
44
|
+
const fromText = (): T | undefined => {
|
|
45
|
+
const text = r?.content?.[0]?.text
|
|
46
|
+
if (text == null) return undefined
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(text) as T
|
|
49
|
+
} catch {
|
|
50
|
+
return text as T
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (opts?.prefer === "text") {
|
|
55
|
+
const text = fromText()
|
|
56
|
+
if (text !== undefined) return text
|
|
57
|
+
const structured = fromStructured()
|
|
58
|
+
if (structured !== undefined) return structured
|
|
59
|
+
return r as T
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const structured = fromStructured()
|
|
63
|
+
if (structured !== undefined) return structured
|
|
64
|
+
const text = fromText()
|
|
65
|
+
if (text !== undefined) return text
|
|
66
|
+
return r as T
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Shape of the `structuredContent` envelope emitted by the view builders. */
|
|
70
|
+
type ViewEnvelope = Pick<ViewStructuredContent, "context">
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decodes a `*_show_*` widget-tool result down to the widget's flat data
|
|
74
|
+
* payload. `buildSingleWidgetView`/`buildComposedView` wrap the data in the
|
|
75
|
+
* `McpAppView` envelope (`context.stepData[<id>].data`); this unwraps it:
|
|
76
|
+
* single-step views yield the step's data directly, multi-step composed views
|
|
77
|
+
* yield an object keyed by step id. Non-envelope results (e.g. `*_data`
|
|
78
|
+
* feeds) pass through {@link parseToolResult} unchanged.
|
|
79
|
+
*/
|
|
80
|
+
export function parseViewToolResult<T = unknown>(result: unknown): T {
|
|
81
|
+
const parsed = parseToolResult<unknown>(result)
|
|
82
|
+
const ctx = (parsed as ViewEnvelope | null)?.context
|
|
83
|
+
const stepIds = ctx?.stepIds
|
|
84
|
+
if (ctx?.stepData && Array.isArray(stepIds) && stepIds.length > 0) {
|
|
85
|
+
if (stepIds.length === 1) return ctx.stepData[stepIds[0] ?? ""]?.data as T
|
|
86
|
+
return Object.fromEntries(stepIds.map((id) => [id, ctx.stepData[id]?.data])) as T
|
|
87
|
+
}
|
|
88
|
+
return parsed as T
|
|
89
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { TONE_DOT, TONE_SOFT, TONE_TEXT, type ToneVariant } from "./tone-utils.js"
|
|
3
|
+
|
|
4
|
+
const ALL_TONES: ToneVariant[] = ["neutral", "info", "success", "warning", "danger"]
|
|
5
|
+
|
|
6
|
+
describe("tone-utils", () => {
|
|
7
|
+
it("covers every ToneVariant in each map (no missing tones)", () => {
|
|
8
|
+
for (const map of [TONE_SOFT, TONE_DOT, TONE_TEXT]) {
|
|
9
|
+
expect(Object.keys(map).sort()).toEqual([...ALL_TONES].sort())
|
|
10
|
+
}
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("maps each tone to a non-empty class string in every map", () => {
|
|
14
|
+
for (const tone of ALL_TONES) {
|
|
15
|
+
expect(TONE_SOFT[tone], `soft:${tone}`).toBeTruthy()
|
|
16
|
+
expect(TONE_DOT[tone], `dot:${tone}`).toBeTruthy()
|
|
17
|
+
expect(TONE_TEXT[tone], `text:${tone}`).toBeTruthy()
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("routes each status tone to its matching design token", () => {
|
|
22
|
+
expect(TONE_SOFT.danger).toBe("bg-danger-soft text-danger")
|
|
23
|
+
expect(TONE_SOFT.warning).toBe("bg-warning-soft text-warning")
|
|
24
|
+
expect(TONE_SOFT.success).toBe("bg-success-soft text-success")
|
|
25
|
+
expect(TONE_SOFT.info).toBe("bg-info-soft text-info")
|
|
26
|
+
expect(TONE_DOT.danger).toBe("bg-danger")
|
|
27
|
+
expect(TONE_TEXT.success).toBe("text-success")
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("routes the neutral tone to the shadcn muted ramp", () => {
|
|
31
|
+
expect(TONE_SOFT.neutral).toBe("bg-muted text-muted-foreground")
|
|
32
|
+
expect(TONE_DOT.neutral).toBe("bg-muted-foreground")
|
|
33
|
+
expect(TONE_TEXT.neutral).toBe("text-foreground")
|
|
34
|
+
})
|
|
35
|
+
})
|