@miragon/mcp-toolkit-ui 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/app/builder/BuilderBanners.d.ts +19 -0
  2. package/dist/app/builder/BuilderBanners.d.ts.map +1 -0
  3. package/dist/app/builder/BuilderBanners.js +22 -0
  4. package/dist/app/builder/BuilderBanners.js.map +1 -0
  5. package/dist/app/builder/CanvasRow.d.ts +26 -0
  6. package/dist/app/builder/CanvasRow.d.ts.map +1 -0
  7. package/dist/app/builder/CanvasRow.js +98 -0
  8. package/dist/app/builder/CanvasRow.js.map +1 -0
  9. package/dist/app/builder/CellPropsSheet.d.ts +19 -0
  10. package/dist/app/builder/CellPropsSheet.d.ts.map +1 -0
  11. package/dist/app/builder/CellPropsSheet.js +28 -0
  12. package/dist/app/builder/CellPropsSheet.js.map +1 -0
  13. package/dist/app/builder/EmptyHint.d.ts +6 -0
  14. package/dist/app/builder/EmptyHint.d.ts.map +1 -0
  15. package/dist/app/builder/EmptyHint.js +10 -0
  16. package/dist/app/builder/EmptyHint.js.map +1 -0
  17. package/dist/app/builder/LayoutSurface.d.ts +27 -0
  18. package/dist/app/builder/LayoutSurface.d.ts.map +1 -0
  19. package/dist/app/builder/LayoutSurface.js +45 -0
  20. package/dist/app/builder/LayoutSurface.js.map +1 -0
  21. package/dist/app/builder/Workspace.d.ts +9 -3
  22. package/dist/app/builder/Workspace.d.ts.map +1 -1
  23. package/dist/app/builder/Workspace.js +3 -97
  24. package/dist/app/builder/Workspace.js.map +1 -1
  25. package/dist/app/builder/builder-reducer.d.ts.map +1 -1
  26. package/dist/app/builder/builder-reducer.js +176 -164
  27. package/dist/app/builder/builder-reducer.js.map +1 -1
  28. package/dist/app/builder/use-builder-catalogue.d.ts +33 -0
  29. package/dist/app/builder/use-builder-catalogue.d.ts.map +1 -0
  30. package/dist/app/builder/use-builder-catalogue.js +155 -0
  31. package/dist/app/builder/use-builder-catalogue.js.map +1 -0
  32. package/dist/app/builder/use-key-step-entries.d.ts +22 -0
  33. package/dist/app/builder/use-key-step-entries.d.ts.map +1 -0
  34. package/dist/app/builder/use-key-step-entries.js +46 -0
  35. package/dist/app/builder/use-key-step-entries.js.map +1 -0
  36. package/dist/app/builder/use-save-dashboard.d.ts +33 -0
  37. package/dist/app/builder/use-save-dashboard.d.ts.map +1 -0
  38. package/dist/app/builder/use-save-dashboard.js +78 -0
  39. package/dist/app/builder/use-save-dashboard.js.map +1 -0
  40. package/dist/app/layout-builder.d.ts.map +1 -1
  41. package/dist/app/layout-builder.js +46 -277
  42. package/dist/app/layout-builder.js.map +1 -1
  43. package/dist/app/mcp-app-view-chrome.d.ts +77 -0
  44. package/dist/app/mcp-app-view-chrome.d.ts.map +1 -0
  45. package/dist/app/mcp-app-view-chrome.js +53 -0
  46. package/dist/app/mcp-app-view-chrome.js.map +1 -0
  47. package/dist/app/mcp-app-view.d.ts +3 -9
  48. package/dist/app/mcp-app-view.d.ts.map +1 -1
  49. package/dist/app/mcp-app-view.js +4 -31
  50. package/dist/app/mcp-app-view.js.map +1 -1
  51. package/dist/render-cases-primitives.d.ts +17 -0
  52. package/dist/render-cases-primitives.d.ts.map +1 -0
  53. package/dist/render-cases-primitives.js +88 -0
  54. package/dist/render-cases-primitives.js.map +1 -0
  55. package/dist/render-cases.d.ts +4 -0
  56. package/dist/render-cases.d.ts.map +1 -0
  57. package/dist/render-cases.js +140 -0
  58. package/dist/render-cases.js.map +1 -0
  59. package/package.json +10 -5
  60. package/src/app/builder/BuilderBanners.tsx +61 -0
  61. package/src/app/builder/CanvasRow.tsx +316 -0
  62. package/src/app/builder/CellPropsSheet.tsx +47 -0
  63. package/src/app/builder/EmptyHint.tsx +29 -0
  64. package/src/app/builder/LayoutSurface.tsx +140 -0
  65. package/src/app/builder/Workspace.tsx +33 -342
  66. package/src/app/builder/builder-reducer.ts +204 -170
  67. package/src/app/builder/use-builder-catalogue.ts +187 -0
  68. package/src/app/builder/use-key-step-entries.ts +60 -0
  69. package/src/app/builder/use-save-dashboard.ts +97 -0
  70. package/src/app/layout-builder.tsx +92 -497
  71. package/src/app/mcp-app-view-chrome.tsx +204 -0
  72. package/src/app/mcp-app-view.tsx +32 -140
  73. package/src/lib/parse-tool-result.property.test.ts +90 -0
  74. package/src/render-cases-primitives.tsx +255 -0
  75. package/src/render-cases.tsx +239 -0
  76. package/src/render-smoke.test.tsx +100 -0
  77. package/src/ui-catalog.test.ts +52 -0
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Presentational chrome for `McpAppView` — the toolbar, the pipeline error
3
+ * list, and the build/render body — plus the view-envelope types they share.
4
+ * Split out of `mcp-app-view.tsx` so the shell component keeps only the
5
+ * host-sync and state logic while this file stays hook-free.
6
+ */
7
+ import { Maximize2, Minimize2, Pencil, RefreshCw } from "lucide-react"
8
+ import type { LayoutConfig, PipelineStepRef } from "@miragon/mcp-toolkit-core"
9
+ import { LayoutBuilder, type LayoutBuilderProps } from "./layout-builder.js"
10
+ import { WidgetRenderer, type WidgetComponent } from "./widget-renderer.js"
11
+
12
+ export interface McpAppViewLabels {
13
+ loading?: string
14
+ refresh?: string
15
+ refreshing?: string
16
+ enterFullscreen?: string
17
+ exitFullscreen?: string
18
+ build?: string
19
+ }
20
+
21
+ export const DEFAULT_LABELS: Required<McpAppViewLabels> = {
22
+ loading: "Waiting for pipeline result...",
23
+ refresh: "Refresh",
24
+ refreshing: "Loading...",
25
+ enterFullscreen: "Fullscreen",
26
+ exitFullscreen: "Collapse",
27
+ build: "Build",
28
+ }
29
+
30
+ export interface RefreshParams {
31
+ keys?: Record<string, unknown>
32
+ steps?: PipelineStepRef[]
33
+ layout?: LayoutConfig
34
+ title?: string
35
+ }
36
+
37
+ export interface ViewData {
38
+ _refreshParams?: RefreshParams
39
+ title?: string
40
+ context: {
41
+ keys: Record<string, unknown>
42
+ stepIds: string[]
43
+ stepData: Record<
44
+ string,
45
+ { data: unknown; keys: Record<string, unknown>; _app: string; _dataType: string }
46
+ >
47
+ errors: { stepId: string; reason: string }[]
48
+ }
49
+ layout?: LayoutConfig
50
+ /**
51
+ * Whether the server's in-iframe builder platform is usable (set by
52
+ * `render-view` from `createFrameworkApp`'s `app.builder`). The shell gates
53
+ * its Build affordance on this so the edit button never appears against a
54
+ * server whose `get-builder-catalogue` tool isn't registered.
55
+ */
56
+ builderAvailable?: boolean
57
+ }
58
+
59
+ /**
60
+ * A renderable view payload: the envelope `render-view`/`show_*` tools emit
61
+ * as `structuredContent`. Module-level so it is referentially stable as the
62
+ * recovery hook's `isValid` dependency.
63
+ */
64
+ export function isCompleteViewData(value: unknown): value is ViewData {
65
+ const v = value as ViewData | null | undefined
66
+ return Boolean(v?.context && v?.layout)
67
+ }
68
+
69
+ // -------------------------------------------------------------------------- //
70
+ // Toolbar
71
+ // -------------------------------------------------------------------------- //
72
+
73
+ export interface ViewToolbarProps {
74
+ /** The toolbar hides entirely while build mode owns the surface. */
75
+ hidden: boolean
76
+ viewData: ViewData
77
+ builderAvailable: boolean
78
+ isFullscreen: boolean
79
+ isRefreshing: boolean
80
+ labels: Required<McpAppViewLabels>
81
+ onRefresh: () => Promise<void>
82
+ onEnterBuildMode: () => void
83
+ onToggleFullscreen: () => Promise<void>
84
+ }
85
+
86
+ export function ViewToolbar({
87
+ hidden,
88
+ viewData,
89
+ builderAvailable,
90
+ isFullscreen,
91
+ isRefreshing,
92
+ labels,
93
+ onRefresh,
94
+ onEnterBuildMode,
95
+ onToggleFullscreen,
96
+ }: ViewToolbarProps) {
97
+ if (hidden) return null
98
+ return (
99
+ <div className="mb-4 flex items-center justify-between">
100
+ {viewData.title && <h2 className="text-xl font-bold">{viewData.title}</h2>}
101
+ <div className="ml-auto flex items-center gap-2">
102
+ {viewData._refreshParams && viewData.layout && (
103
+ <button
104
+ onClick={() => {
105
+ void onRefresh()
106
+ }}
107
+ disabled={isRefreshing}
108
+ className="hover:bg-accent hover:text-accent-foreground inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors disabled:opacity-50"
109
+ >
110
+ <RefreshCw
111
+ aria-hidden="true"
112
+ className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
113
+ />
114
+ {isRefreshing ? labels.refreshing : labels.refresh}
115
+ </button>
116
+ )}
117
+ {builderAvailable && viewData._refreshParams && (
118
+ <button
119
+ onClick={onEnterBuildMode}
120
+ className="hover:bg-accent hover:text-accent-foreground inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors"
121
+ >
122
+ <Pencil className="h-4 w-4" />
123
+ {labels.build}
124
+ </button>
125
+ )}
126
+ <button
127
+ onClick={() => {
128
+ void onToggleFullscreen()
129
+ }}
130
+ className="hover:bg-accent hover:text-accent-foreground inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors"
131
+ >
132
+ {isFullscreen ? (
133
+ <Minimize2 aria-hidden="true" className="h-4 w-4" />
134
+ ) : (
135
+ <Maximize2 aria-hidden="true" className="h-4 w-4" />
136
+ )}
137
+ {isFullscreen ? labels.exitFullscreen : labels.enterFullscreen}
138
+ </button>
139
+ </div>
140
+ </div>
141
+ )
142
+ }
143
+
144
+ // -------------------------------------------------------------------------- //
145
+ // Pipeline error list
146
+ // -------------------------------------------------------------------------- //
147
+
148
+ export function StepErrors({
149
+ hidden,
150
+ errors,
151
+ }: {
152
+ /** Build mode surfaces its own diagnostics; the shell list hides. */
153
+ hidden: boolean
154
+ errors: ViewData["context"]["errors"]
155
+ }) {
156
+ if (hidden || errors.length === 0) return null
157
+ return (
158
+ <div className="mb-4 flex flex-col gap-2">
159
+ {errors.map((err) => (
160
+ <div key={err.stepId} className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
161
+ <strong>{err.stepId}:</strong> {err.reason}
162
+ </div>
163
+ ))}
164
+ </div>
165
+ )
166
+ }
167
+
168
+ // -------------------------------------------------------------------------- //
169
+ // Body: LayoutBuilder in build mode, WidgetRenderer otherwise
170
+ // -------------------------------------------------------------------------- //
171
+
172
+ export interface ViewBodyProps {
173
+ buildMode: boolean
174
+ viewData: ViewData
175
+ widgets: Record<string, WidgetComponent>
176
+ callTool: (name: string, args: object) => Promise<unknown>
177
+ onExit: LayoutBuilderProps["onExit"]
178
+ }
179
+
180
+ export function ViewBody({ buildMode, viewData, widgets, callTool, onExit }: ViewBodyProps) {
181
+ if (buildMode) {
182
+ return (
183
+ <LayoutBuilder
184
+ initialLayout={viewData.layout}
185
+ title={viewData.title}
186
+ initialKeys={viewData._refreshParams?.keys}
187
+ initialSteps={viewData._refreshParams?.steps}
188
+ widgets={widgets}
189
+ callTool={callTool}
190
+ onExit={onExit}
191
+ />
192
+ )
193
+ }
194
+ if (!viewData.layout) return null
195
+ return (
196
+ <WidgetRenderer
197
+ layout={viewData.layout}
198
+ keys={viewData.context.keys}
199
+ stepData={viewData.context.stepData}
200
+ errors={viewData.context.errors}
201
+ widgets={widgets}
202
+ />
203
+ )
204
+ }
@@ -1,71 +1,23 @@
1
1
  import { useState, useCallback } from "react"
2
2
  import { useDisplayMode, useHostContext, useToolContext } from "mcp-use/react"
3
- import { Maximize2, Minimize2, Pencil, RefreshCw } from "lucide-react"
4
3
  import type { LayoutConfig, PipelineStepRef } from "@miragon/mcp-toolkit-core"
5
4
  import { Skeleton } from "../primitives/skeleton.js"
6
5
  import { AppQueryProvider, queryClient } from "../providers/query-provider.js"
7
6
  import { useToolResultRecovery } from "../hooks/use-tool-result-recovery.js"
8
7
  import { parseToolResult } from "../lib/parse-tool-result.js"
9
8
  import { useHostBridge } from "./host-bridge.js"
10
- import { LayoutBuilder } from "./layout-builder.js"
11
- import { WidgetRenderer, type WidgetComponent } from "./widget-renderer.js"
9
+ import {
10
+ DEFAULT_LABELS,
11
+ isCompleteViewData,
12
+ StepErrors,
13
+ ViewBody,
14
+ ViewToolbar,
15
+ type McpAppViewLabels,
16
+ type ViewData,
17
+ } from "./mcp-app-view-chrome.js"
18
+ import type { WidgetComponent } from "./widget-renderer.js"
12
19
 
13
- export interface McpAppViewLabels {
14
- loading?: string
15
- refresh?: string
16
- refreshing?: string
17
- enterFullscreen?: string
18
- exitFullscreen?: string
19
- build?: string
20
- }
21
-
22
- const DEFAULT_LABELS: Required<McpAppViewLabels> = {
23
- loading: "Waiting for pipeline result...",
24
- refresh: "Refresh",
25
- refreshing: "Loading...",
26
- enterFullscreen: "Fullscreen",
27
- exitFullscreen: "Collapse",
28
- build: "Build",
29
- }
30
-
31
- interface RefreshParams {
32
- keys?: Record<string, unknown>
33
- steps?: PipelineStepRef[]
34
- layout?: LayoutConfig
35
- title?: string
36
- }
37
-
38
- interface ViewData {
39
- _refreshParams?: RefreshParams
40
- title?: string
41
- context: {
42
- keys: Record<string, unknown>
43
- stepIds: string[]
44
- stepData: Record<
45
- string,
46
- { data: unknown; keys: Record<string, unknown>; _app: string; _dataType: string }
47
- >
48
- errors: { stepId: string; reason: string }[]
49
- }
50
- layout?: LayoutConfig
51
- /**
52
- * Whether the server's in-iframe builder platform is usable (set by
53
- * `render-view` from `createFrameworkApp`'s `app.builder`). The shell gates
54
- * its Build affordance on this so the edit button never appears against a
55
- * server whose `get-builder-catalogue` tool isn't registered.
56
- */
57
- builderAvailable?: boolean
58
- }
59
-
60
- /**
61
- * A renderable view payload: the envelope `render-view`/`show_*` tools emit
62
- * as `structuredContent`. Module-level so it is referentially stable as the
63
- * recovery hook's `isValid` dependency.
64
- */
65
- function isCompleteViewData(value: unknown): value is ViewData {
66
- const v = value as ViewData | null | undefined
67
- return Boolean(v?.context && v?.layout)
68
- }
20
+ export type { McpAppViewLabels } from "./mcp-app-view-chrome.js"
69
21
 
70
22
  export interface McpAppViewProps {
71
23
  /**
@@ -264,7 +216,7 @@ export function McpAppView({
264
216
  )
265
217
  }
266
218
 
267
- const showHeader = !buildMode
219
+ const isFullscreen = displayMode === "fullscreen"
268
220
 
269
221
  // Follow the server's own signal by default: render-view reports
270
222
  // `builderAvailable` (derived from `app.builder`), so the Build button only
@@ -281,95 +233,35 @@ export function McpAppView({
281
233
  // `bootstrapView`'s auto-resize, so we let content dictate height and
282
234
  // avoid setting overflowY (which would clip our own content inside a
283
235
  // host that's already auto-sized to us).
284
- ...(displayMode === "fullscreen"
285
- ? { minHeight: "100vh", overflowY: "auto" as const }
286
- : null),
236
+ ...(isFullscreen ? { minHeight: "100vh", overflowY: "auto" as const } : null),
287
237
  paddingTop: safeArea.top,
288
238
  paddingRight: safeArea.right,
289
239
  paddingBottom: safeArea.bottom,
290
240
  paddingLeft: safeArea.left,
291
241
  }}
292
242
  >
293
- {showHeader && (
294
- <div className="mb-4 flex items-center justify-between">
295
- {viewData.title && <h2 className="text-xl font-bold">{viewData.title}</h2>}
296
- <div className="ml-auto flex items-center gap-2">
297
- {viewData._refreshParams && viewData.layout && (
298
- <button
299
- onClick={() => {
300
- void refreshView()
301
- }}
302
- disabled={isRefreshing}
303
- className="hover:bg-accent hover:text-accent-foreground inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors disabled:opacity-50"
304
- >
305
- <RefreshCw
306
- aria-hidden="true"
307
- className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
308
- />
309
- {isRefreshing ? effectiveLabels.refreshing : effectiveLabels.refresh}
310
- </button>
311
- )}
312
- {builderAvailable && viewData._refreshParams && (
313
- <button
314
- onClick={() => setBuildMode(true)}
315
- className="hover:bg-accent hover:text-accent-foreground inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors"
316
- >
317
- <Pencil className="h-4 w-4" />
318
- {effectiveLabels.build}
319
- </button>
320
- )}
321
- <button
322
- onClick={() => {
323
- void toggleFullscreen()
324
- }}
325
- className="hover:bg-accent hover:text-accent-foreground inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors"
326
- >
327
- {displayMode === "fullscreen" ? (
328
- <Minimize2 aria-hidden="true" className="h-4 w-4" />
329
- ) : (
330
- <Maximize2 aria-hidden="true" className="h-4 w-4" />
331
- )}
332
- {displayMode === "fullscreen"
333
- ? effectiveLabels.exitFullscreen
334
- : effectiveLabels.enterFullscreen}
335
- </button>
336
- </div>
337
- </div>
338
- )}
243
+ <ViewToolbar
244
+ hidden={buildMode}
245
+ viewData={viewData}
246
+ builderAvailable={builderAvailable}
247
+ isFullscreen={isFullscreen}
248
+ isRefreshing={isRefreshing}
249
+ labels={effectiveLabels}
250
+ onRefresh={refreshView}
251
+ onEnterBuildMode={() => setBuildMode(true)}
252
+ onToggleFullscreen={toggleFullscreen}
253
+ />
339
254
 
340
- {!buildMode && viewData.context.errors.length > 0 && (
341
- <div className="mb-4 flex flex-col gap-2">
342
- {viewData.context.errors.map((err) => (
343
- <div
344
- key={err.stepId}
345
- className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
346
- >
347
- <strong>{err.stepId}:</strong> {err.reason}
348
- </div>
349
- ))}
350
- </div>
351
- )}
255
+ <StepErrors hidden={buildMode} errors={viewData.context.errors} />
352
256
 
353
257
  <AppQueryProvider callTool={callToolFn}>
354
- {buildMode ? (
355
- <LayoutBuilder
356
- initialLayout={viewData.layout}
357
- title={viewData.title}
358
- initialKeys={viewData._refreshParams?.keys}
359
- initialSteps={viewData._refreshParams?.steps}
360
- widgets={widgets}
361
- callTool={callToolFn}
362
- onExit={exitBuildMode}
363
- />
364
- ) : viewData.layout ? (
365
- <WidgetRenderer
366
- layout={viewData.layout}
367
- keys={viewData.context.keys}
368
- stepData={viewData.context.stepData}
369
- errors={viewData.context.errors}
370
- widgets={widgets}
371
- />
372
- ) : null}
258
+ <ViewBody
259
+ buildMode={buildMode}
260
+ viewData={viewData}
261
+ widgets={widgets}
262
+ callTool={callToolFn}
263
+ onExit={exitBuildMode}
264
+ />
373
265
  </AppQueryProvider>
374
266
  </main>
375
267
  )
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import fc from "fast-check"
3
+ import { parseToolResult } from "./parse-tool-result.js"
4
+
5
+ // Deterministic PRNG: a property failure in CI must replay identically on the
6
+ // next run, never appear as a fresh random draw (FITNESS.md phase 5c).
7
+ fc.configureGlobal({ seed: 42, numRuns: 100 })
8
+
9
+ // CallToolResult-ish shapes: structuredContent × content arrays whose first
10
+ // text block is valid JSON about half the time and arbitrary text otherwise.
11
+ const textArb = fc.oneof(
12
+ fc.string(),
13
+ fc.jsonValue().map((value) => JSON.stringify(value)),
14
+ )
15
+ const contentArb = fc.oneof(
16
+ fc.constant([]),
17
+ fc.array(fc.record({ type: fc.constant("text"), text: textArb }, { requiredKeys: ["type"] }), {
18
+ maxLength: 2,
19
+ }),
20
+ )
21
+ const resultArb = fc.record(
22
+ {
23
+ isError: fc.constant(false),
24
+ structuredContent: fc.oneof(fc.constant(null), fc.jsonValue()),
25
+ content: contentArb,
26
+ },
27
+ { requiredKeys: [] },
28
+ )
29
+
30
+ describe("parseToolResult (property)", () => {
31
+ // Bug class guarded: this is the single decode seam every widget, query hook
32
+ // and the app shell funnel through — a malformed host response crashing here
33
+ // would take down the whole widget render path instead of one data read.
34
+ it("never throws on arbitrary junk without a truthy isError", () => {
35
+ const junkArb = fc
36
+ .anything()
37
+ .filter((junk) => !(typeof junk === "object" && junk !== null && "isError" in junk))
38
+ fc.assert(
39
+ fc.property(junkArb, (junk) => {
40
+ expect(() => parseToolResult(junk)).not.toThrow()
41
+ }),
42
+ )
43
+ })
44
+
45
+ // Bug class guarded: the structured-first precedence IS the 0.4 contract —
46
+ // a tool emitting both a text summary and structuredContent must decode to
47
+ // the data payload, never regress to the summary string; and every result
48
+ // must land on a documented outcome (structured, JSON text, verbatim text,
49
+ // raw result), never on undefined.
50
+ it("yields exactly the documented outcome, structured content first", () => {
51
+ fc.assert(
52
+ fc.property(resultArb, (result) => {
53
+ const out: unknown = parseToolResult(result)
54
+ const text = Array.isArray(result.content) ? result.content[0]?.text : undefined
55
+ if (result.structuredContent != null) {
56
+ expect(out).toBe(result.structuredContent)
57
+ } else if (text !== undefined) {
58
+ let decoded: unknown = text
59
+ try {
60
+ decoded = JSON.parse(text)
61
+ } catch {
62
+ // Non-JSON text is returned verbatim — that IS the documented outcome.
63
+ }
64
+ expect(out).toEqual(decoded)
65
+ } else {
66
+ expect(out).toBe(result)
67
+ }
68
+ }),
69
+ )
70
+ })
71
+
72
+ // Bug class guarded: error results must surface as thrown Errors carrying
73
+ // the first text block, so TanStack Query error states (and try/catch in
74
+ // widgets) show the server's message instead of silently rendering junk.
75
+ it("throws the first text block for every isError result", () => {
76
+ fc.assert(
77
+ fc.property(resultArb, fc.string(), (result, message) => {
78
+ const errorResult = { ...result, isError: true, content: [{ type: "text", text: message }] }
79
+ let thrown: unknown
80
+ try {
81
+ parseToolResult(errorResult)
82
+ } catch (err) {
83
+ thrown = err
84
+ }
85
+ expect(thrown).toBeInstanceOf(Error)
86
+ expect((thrown as Error).message).toBe(message)
87
+ }),
88
+ )
89
+ })
90
+ })