@miragon/mcp-toolkit-ui 2.7.0 → 2.8.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 (387) hide show
  1. package/README.md +7 -4
  2. package/dist/app/adapt-data-widget.d.ts +1 -22
  3. package/dist/app/adapt-data-widget.d.ts.map +1 -1
  4. package/dist/app/adapt-data-widget.js +1 -35
  5. package/dist/app/adapt-data-widget.js.map +1 -1
  6. package/dist/app/builder/BuilderBanners.js +1 -1
  7. package/dist/app/builder/BuilderBanners.js.map +1 -1
  8. package/dist/app/builder/CanvasRow.d.ts +4 -2
  9. package/dist/app/builder/CanvasRow.d.ts.map +1 -1
  10. package/dist/app/builder/CanvasRow.js +21 -7
  11. package/dist/app/builder/CanvasRow.js.map +1 -1
  12. package/dist/app/builder/CatalogueSheet.d.ts.map +1 -1
  13. package/dist/app/builder/CatalogueSheet.js +11 -4
  14. package/dist/app/builder/CatalogueSheet.js.map +1 -1
  15. package/dist/app/builder/CellPropsSheet.d.ts +7 -3
  16. package/dist/app/builder/CellPropsSheet.d.ts.map +1 -1
  17. package/dist/app/builder/CellPropsSheet.js +10 -3
  18. package/dist/app/builder/CellPropsSheet.js.map +1 -1
  19. package/dist/app/builder/DiscardDialog.d.ts +18 -0
  20. package/dist/app/builder/DiscardDialog.d.ts.map +1 -0
  21. package/dist/app/builder/DiscardDialog.js +18 -0
  22. package/dist/app/builder/DiscardDialog.js.map +1 -0
  23. package/dist/app/builder/LayoutSurface.d.ts +33 -9
  24. package/dist/app/builder/LayoutSurface.d.ts.map +1 -1
  25. package/dist/app/builder/LayoutSurface.js +74 -23
  26. package/dist/app/builder/LayoutSurface.js.map +1 -1
  27. package/dist/app/builder/PipelineStrip.d.ts +2 -1
  28. package/dist/app/builder/PipelineStrip.d.ts.map +1 -1
  29. package/dist/app/builder/PipelineStrip.js +26 -13
  30. package/dist/app/builder/PipelineStrip.js.map +1 -1
  31. package/dist/app/builder/SaveDialog.d.ts +9 -1
  32. package/dist/app/builder/SaveDialog.d.ts.map +1 -1
  33. package/dist/app/builder/SaveDialog.js +8 -2
  34. package/dist/app/builder/SaveDialog.js.map +1 -1
  35. package/dist/app/builder/Toolbar.d.ts +12 -4
  36. package/dist/app/builder/Toolbar.d.ts.map +1 -1
  37. package/dist/app/builder/Toolbar.js +18 -7
  38. package/dist/app/builder/Toolbar.js.map +1 -1
  39. package/dist/app/builder/Workspace.d.ts +23 -3
  40. package/dist/app/builder/Workspace.d.ts.map +1 -1
  41. package/dist/app/builder/Workspace.js +58 -8
  42. package/dist/app/builder/Workspace.js.map +1 -1
  43. package/dist/app/builder/builder-model.d.ts +143 -1
  44. package/dist/app/builder/builder-model.d.ts.map +1 -1
  45. package/dist/app/builder/builder-model.js +191 -1
  46. package/dist/app/builder/builder-model.js.map +1 -1
  47. package/dist/app/builder/reveal.d.ts +10 -0
  48. package/dist/app/builder/reveal.d.ts.map +1 -0
  49. package/dist/app/builder/reveal.js +10 -0
  50. package/dist/app/builder/reveal.js.map +1 -0
  51. package/dist/app/builder/use-builder-catalogue.d.ts +4 -1
  52. package/dist/app/builder/use-builder-catalogue.d.ts.map +1 -1
  53. package/dist/app/builder/use-builder-catalogue.js +44 -17
  54. package/dist/app/builder/use-builder-catalogue.js.map +1 -1
  55. package/dist/app/builder/use-builder-session.d.ts +30 -0
  56. package/dist/app/builder/use-builder-session.d.ts.map +1 -0
  57. package/dist/app/builder/use-builder-session.js +58 -0
  58. package/dist/app/builder/use-builder-session.js.map +1 -0
  59. package/dist/app/builder/use-display-resolver.d.ts +17 -0
  60. package/dist/app/builder/use-display-resolver.d.ts.map +1 -0
  61. package/dist/app/builder/use-display-resolver.js +16 -0
  62. package/dist/app/builder/use-display-resolver.js.map +1 -0
  63. package/dist/app/index.d.ts +8 -3
  64. package/dist/app/index.d.ts.map +1 -1
  65. package/dist/app/index.js +8 -2
  66. package/dist/app/index.js.map +1 -1
  67. package/dist/app/layout-builder.d.ts +13 -25
  68. package/dist/app/layout-builder.d.ts.map +1 -1
  69. package/dist/app/layout-builder.js +75 -81
  70. package/dist/app/layout-builder.js.map +1 -1
  71. package/dist/app/mcp-app-view-chrome.d.ts +12 -3
  72. package/dist/app/mcp-app-view-chrome.d.ts.map +1 -1
  73. package/dist/app/mcp-app-view-chrome.js +21 -5
  74. package/dist/app/mcp-app-view-chrome.js.map +1 -1
  75. package/dist/app/mcp-app-view-states.d.ts +11 -3
  76. package/dist/app/mcp-app-view-states.d.ts.map +1 -1
  77. package/dist/app/mcp-app-view-states.js +29 -8
  78. package/dist/app/mcp-app-view-states.js.map +1 -1
  79. package/dist/app/mcp-app-view.d.ts +33 -1
  80. package/dist/app/mcp-app-view.d.ts.map +1 -1
  81. package/dist/app/mcp-app-view.js +181 -127
  82. package/dist/app/mcp-app-view.js.map +1 -1
  83. package/dist/app/mcp-toolkit-app.d.ts +6 -1
  84. package/dist/app/mcp-toolkit-app.d.ts.map +1 -1
  85. package/dist/app/mcp-toolkit-app.js +8 -1
  86. package/dist/app/mcp-toolkit-app.js.map +1 -1
  87. package/dist/app/mcp-use-host-bridge.d.ts +15 -6
  88. package/dist/app/mcp-use-host-bridge.d.ts.map +1 -1
  89. package/dist/app/mcp-use-host-bridge.js +51 -22
  90. package/dist/app/mcp-use-host-bridge.js.map +1 -1
  91. package/dist/app/model-context.d.ts +71 -0
  92. package/dist/app/model-context.d.ts.map +1 -0
  93. package/dist/app/model-context.js +101 -0
  94. package/dist/app/model-context.js.map +1 -0
  95. package/dist/app/use-build-mode-focus.d.ts +33 -0
  96. package/dist/app/use-build-mode-focus.d.ts.map +1 -0
  97. package/dist/app/use-build-mode-focus.js +59 -0
  98. package/dist/app/use-build-mode-focus.js.map +1 -0
  99. package/dist/app/use-view-actions.d.ts +31 -0
  100. package/dist/app/use-view-actions.d.ts.map +1 -0
  101. package/dist/app/use-view-actions.js +81 -0
  102. package/dist/app/use-view-actions.js.map +1 -0
  103. package/dist/app/use-view-lifecycle.d.ts +4 -0
  104. package/dist/app/use-view-lifecycle.d.ts.map +1 -1
  105. package/dist/app/use-view-lifecycle.js +2 -0
  106. package/dist/app/use-view-lifecycle.js.map +1 -1
  107. package/dist/app/view-lifecycle.d.ts +80 -4
  108. package/dist/app/view-lifecycle.d.ts.map +1 -1
  109. package/dist/app/view-lifecycle.js +90 -3
  110. package/dist/app/view-lifecycle.js.map +1 -1
  111. package/dist/app/widget-fixture.d.ts +10 -3
  112. package/dist/app/widget-fixture.d.ts.map +1 -1
  113. package/dist/app/widget-fixture.js +21 -8
  114. package/dist/app/widget-fixture.js.map +1 -1
  115. package/dist/app/widget-props-sheet.d.ts +3 -1
  116. package/dist/app/widget-props-sheet.d.ts.map +1 -1
  117. package/dist/app/widget-props-sheet.js +22 -10
  118. package/dist/app/widget-props-sheet.js.map +1 -1
  119. package/dist/app/widget-renderer.js +1 -1
  120. package/dist/app/widget-renderer.js.map +1 -1
  121. package/dist/components/DrillButton.d.ts +9 -6
  122. package/dist/components/DrillButton.d.ts.map +1 -1
  123. package/dist/components/DrillButton.js +8 -6
  124. package/dist/components/DrillButton.js.map +1 -1
  125. package/dist/components/EmptyState.d.ts.map +1 -1
  126. package/dist/components/EmptyState.js +4 -2
  127. package/dist/components/EmptyState.js.map +1 -1
  128. package/dist/components/ErrorState.d.ts +5 -2
  129. package/dist/components/ErrorState.d.ts.map +1 -1
  130. package/dist/components/ErrorState.js +25 -8
  131. package/dist/components/ErrorState.js.map +1 -1
  132. package/dist/components/FilterBar.d.ts +15 -4
  133. package/dist/components/FilterBar.d.ts.map +1 -1
  134. package/dist/components/FilterBar.js +11 -6
  135. package/dist/components/FilterBar.js.map +1 -1
  136. package/dist/components/KpiGrid.d.ts +66 -21
  137. package/dist/components/KpiGrid.d.ts.map +1 -1
  138. package/dist/components/KpiGrid.js +101 -34
  139. package/dist/components/KpiGrid.js.map +1 -1
  140. package/dist/components/ListFooter.d.ts +31 -4
  141. package/dist/components/ListFooter.d.ts.map +1 -1
  142. package/dist/components/ListFooter.js +30 -11
  143. package/dist/components/ListFooter.js.map +1 -1
  144. package/dist/components/PagedListFooter.d.ts +57 -0
  145. package/dist/components/PagedListFooter.d.ts.map +1 -0
  146. package/dist/components/PagedListFooter.js +27 -0
  147. package/dist/components/PagedListFooter.js.map +1 -0
  148. package/dist/components/Pills.d.ts +27 -7
  149. package/dist/components/Pills.d.ts.map +1 -1
  150. package/dist/components/Pills.js +21 -6
  151. package/dist/components/Pills.js.map +1 -1
  152. package/dist/components/SectionHeading.d.ts +3 -1
  153. package/dist/components/SectionHeading.d.ts.map +1 -1
  154. package/dist/components/SectionHeading.js +6 -2
  155. package/dist/components/SectionHeading.js.map +1 -1
  156. package/dist/components/Toast.d.ts +9 -3
  157. package/dist/components/Toast.d.ts.map +1 -1
  158. package/dist/components/Toast.js +31 -16
  159. package/dist/components/Toast.js.map +1 -1
  160. package/dist/components/WidgetHeader.d.ts +34 -10
  161. package/dist/components/WidgetHeader.d.ts.map +1 -1
  162. package/dist/components/WidgetHeader.js +11 -6
  163. package/dist/components/WidgetHeader.js.map +1 -1
  164. package/dist/hooks/index.d.ts +4 -4
  165. package/dist/hooks/index.d.ts.map +1 -1
  166. package/dist/hooks/index.js +1 -1
  167. package/dist/hooks/index.js.map +1 -1
  168. package/dist/hooks/tool-result-recovery-model.d.ts +155 -0
  169. package/dist/hooks/tool-result-recovery-model.d.ts.map +1 -0
  170. package/dist/hooks/tool-result-recovery-model.js +273 -0
  171. package/dist/hooks/tool-result-recovery-model.js.map +1 -0
  172. package/dist/hooks/use-tool-query.d.ts +56 -14
  173. package/dist/hooks/use-tool-query.d.ts.map +1 -1
  174. package/dist/hooks/use-tool-query.js +102 -25
  175. package/dist/hooks/use-tool-query.js.map +1 -1
  176. package/dist/hooks/use-tool-result-recovery.d.ts +47 -52
  177. package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -1
  178. package/dist/hooks/use-tool-result-recovery.js +58 -120
  179. package/dist/hooks/use-tool-result-recovery.js.map +1 -1
  180. package/dist/hooks/use-view-data.d.ts +52 -10
  181. package/dist/hooks/use-view-data.d.ts.map +1 -1
  182. package/dist/hooks/use-view-data.js +81 -16
  183. package/dist/hooks/use-view-data.js.map +1 -1
  184. package/dist/hooks/use-view-tool-query.d.ts +12 -7
  185. package/dist/hooks/use-view-tool-query.d.ts.map +1 -1
  186. package/dist/hooks/use-view-tool-query.js +15 -14
  187. package/dist/hooks/use-view-tool-query.js.map +1 -1
  188. package/dist/index.d.ts +17 -10
  189. package/dist/index.d.ts.map +1 -1
  190. package/dist/index.js +18 -6
  191. package/dist/index.js.map +1 -1
  192. package/dist/lib/focus-return.d.ts +61 -0
  193. package/dist/lib/focus-return.d.ts.map +1 -0
  194. package/dist/lib/focus-return.js +46 -0
  195. package/dist/lib/focus-return.js.map +1 -0
  196. package/dist/lib/parse-tool-result.d.ts +23 -1
  197. package/dist/lib/parse-tool-result.d.ts.map +1 -1
  198. package/dist/lib/parse-tool-result.js +38 -2
  199. package/dist/lib/parse-tool-result.js.map +1 -1
  200. package/dist/lib/toast-model.d.ts +41 -1
  201. package/dist/lib/toast-model.d.ts.map +1 -1
  202. package/dist/lib/toast-model.js +67 -1
  203. package/dist/lib/toast-model.js.map +1 -1
  204. package/dist/lib/tone-utils.d.ts +58 -6
  205. package/dist/lib/tone-utils.d.ts.map +1 -1
  206. package/dist/lib/tone-utils.js +66 -4
  207. package/dist/lib/tone-utils.js.map +1 -1
  208. package/dist/lib/tool-query.d.ts +107 -0
  209. package/dist/lib/tool-query.d.ts.map +1 -0
  210. package/dist/lib/tool-query.js +182 -0
  211. package/dist/lib/tool-query.js.map +1 -0
  212. package/dist/lib/tool-result-observer.d.ts +108 -0
  213. package/dist/lib/tool-result-observer.d.ts.map +1 -0
  214. package/dist/lib/tool-result-observer.js +158 -0
  215. package/dist/lib/tool-result-observer.js.map +1 -0
  216. package/dist/lib/typography.d.ts +37 -0
  217. package/dist/lib/typography.d.ts.map +1 -0
  218. package/dist/lib/typography.js +41 -0
  219. package/dist/lib/typography.js.map +1 -0
  220. package/dist/lib/utils.d.ts.map +1 -1
  221. package/dist/lib/utils.js +11 -1
  222. package/dist/lib/utils.js.map +1 -1
  223. package/dist/locale/index.d.ts +1 -1
  224. package/dist/locale/index.d.ts.map +1 -1
  225. package/dist/locale/message-types.d.ts +117 -4
  226. package/dist/locale/message-types.d.ts.map +1 -1
  227. package/dist/locale/messages-de.d.ts.map +1 -1
  228. package/dist/locale/messages-de.js +44 -10
  229. package/dist/locale/messages-de.js.map +1 -1
  230. package/dist/locale/messages-en.d.ts.map +1 -1
  231. package/dist/locale/messages-en.js +44 -10
  232. package/dist/locale/messages-en.js.map +1 -1
  233. package/dist/locale/toolkit-messages.d.ts +19 -1
  234. package/dist/locale/toolkit-messages.d.ts.map +1 -1
  235. package/dist/locale/toolkit-messages.js +54 -1
  236. package/dist/locale/toolkit-messages.js.map +1 -1
  237. package/dist/primitives/alert.d.ts +1 -1
  238. package/dist/primitives/alert.d.ts.map +1 -1
  239. package/dist/primitives/alert.js +2 -0
  240. package/dist/primitives/alert.js.map +1 -1
  241. package/dist/primitives/badge.d.ts +1 -1
  242. package/dist/primitives/badge.d.ts.map +1 -1
  243. package/dist/primitives/badge.js +9 -4
  244. package/dist/primitives/badge.js.map +1 -1
  245. package/dist/primitives/button.d.ts +1 -1
  246. package/dist/primitives/button.js +1 -1
  247. package/dist/primitives/button.js.map +1 -1
  248. package/dist/primitives/dialog.d.ts +1 -1
  249. package/dist/primitives/dialog.d.ts.map +1 -1
  250. package/dist/primitives/dialog.js +10 -3
  251. package/dist/primitives/dialog.js.map +1 -1
  252. package/dist/primitives/select.js +1 -1
  253. package/dist/primitives/select.js.map +1 -1
  254. package/dist/primitives/sheet.d.ts +1 -1
  255. package/dist/primitives/sheet.d.ts.map +1 -1
  256. package/dist/primitives/sheet.js +6 -2
  257. package/dist/primitives/sheet.js.map +1 -1
  258. package/dist/providers/query-provider.d.ts +32 -1
  259. package/dist/providers/query-provider.d.ts.map +1 -1
  260. package/dist/providers/query-provider.js +53 -4
  261. package/dist/providers/query-provider.js.map +1 -1
  262. package/dist/render-cases-primitives.js +1 -1
  263. package/dist/render-cases-primitives.js.map +1 -1
  264. package/dist/render-cases.d.ts.map +1 -1
  265. package/dist/render-cases.js +30 -6
  266. package/dist/render-cases.js.map +1 -1
  267. package/dist/render-effects.d.ts +42 -0
  268. package/dist/render-effects.d.ts.map +1 -0
  269. package/dist/render-effects.js +81 -0
  270. package/dist/render-effects.js.map +1 -0
  271. package/dist/theme/create-theme.d.ts +19 -1
  272. package/dist/theme/create-theme.d.ts.map +1 -1
  273. package/dist/theme/create-theme.js +15 -0
  274. package/dist/theme/create-theme.js.map +1 -1
  275. package/package.json +5 -4
  276. package/src/app/adapt-data-widget.tsx +2 -42
  277. package/src/app/builder/BuilderBanners.tsx +1 -1
  278. package/src/app/builder/CanvasRow.tsx +63 -17
  279. package/src/app/builder/CatalogueSheet.tsx +50 -29
  280. package/src/app/builder/CellPropsSheet.tsx +17 -2
  281. package/src/app/builder/DiscardDialog.tsx +59 -0
  282. package/src/app/builder/LayoutSurface.tsx +206 -66
  283. package/src/app/builder/PipelineStrip.tsx +253 -153
  284. package/src/app/builder/SaveDialog.tsx +14 -2
  285. package/src/app/builder/Toolbar.tsx +144 -59
  286. package/src/app/builder/Workspace.tsx +226 -58
  287. package/src/app/builder/builder-model.ts +341 -2
  288. package/src/app/builder/builder-model.ux.test.ts +456 -0
  289. package/src/app/builder/builder-ux.test.tsx +844 -0
  290. package/src/app/builder/reveal.ts +10 -0
  291. package/src/app/builder/use-builder-catalogue.ts +56 -19
  292. package/src/app/builder/use-builder-session.ts +68 -0
  293. package/src/app/builder/use-display-resolver.ts +24 -0
  294. package/src/app/index.ts +12 -2
  295. package/src/app/layout-builder.tsx +178 -191
  296. package/src/app/mcp-app-view-chrome.test.tsx +141 -1
  297. package/src/app/mcp-app-view-chrome.tsx +56 -6
  298. package/src/app/mcp-app-view-states.tsx +51 -11
  299. package/src/app/mcp-app-view.effects.test.tsx +292 -0
  300. package/src/app/mcp-app-view.test.tsx +222 -2
  301. package/src/app/mcp-app-view.tsx +267 -117
  302. package/src/app/mcp-toolkit-app.test.tsx +40 -0
  303. package/src/app/mcp-toolkit-app.tsx +8 -1
  304. package/src/app/mcp-use-host-bridge.test.tsx +180 -0
  305. package/src/app/mcp-use-host-bridge.tsx +67 -24
  306. package/src/app/model-context.test.tsx +116 -0
  307. package/src/app/model-context.tsx +125 -0
  308. package/src/app/use-build-mode-focus.ts +62 -0
  309. package/src/app/use-view-actions.ts +89 -0
  310. package/src/app/use-view-lifecycle.ts +6 -0
  311. package/src/app/view-lifecycle.test.ts +216 -0
  312. package/src/app/view-lifecycle.ts +165 -5
  313. package/src/app/widget-fixture.effects.test.tsx +51 -0
  314. package/src/app/widget-fixture.render.test.tsx +81 -0
  315. package/src/app/widget-fixture.tsx +34 -11
  316. package/src/app/widget-props-sheet.tsx +57 -16
  317. package/src/app/widget-renderer.tsx +1 -1
  318. package/src/components/DrillButton.tsx +23 -10
  319. package/src/components/EmptyState.tsx +6 -4
  320. package/src/components/ErrorState.tsx +31 -9
  321. package/src/components/FilterBar.test.tsx +82 -0
  322. package/src/components/FilterBar.tsx +52 -26
  323. package/src/components/KpiGrid.test.tsx +279 -0
  324. package/src/components/KpiGrid.tsx +297 -90
  325. package/src/components/ListFooter.test.tsx +116 -0
  326. package/src/components/ListFooter.tsx +102 -15
  327. package/src/components/PagedListFooter.test.tsx +140 -0
  328. package/src/components/PagedListFooter.tsx +107 -0
  329. package/src/components/Pills.test.tsx +77 -0
  330. package/src/components/Pills.tsx +63 -10
  331. package/src/components/SectionHeading.tsx +9 -3
  332. package/src/components/Toast.tsx +37 -19
  333. package/src/components/WidgetHeader.test.tsx +78 -0
  334. package/src/components/WidgetHeader.tsx +63 -16
  335. package/src/components/feedback-states.test.tsx +98 -9
  336. package/src/components/tone-figures.test.tsx +8 -4
  337. package/src/hooks/index.ts +6 -3
  338. package/src/hooks/tool-result-recovery-model.test.ts +744 -0
  339. package/src/hooks/tool-result-recovery-model.ts +418 -0
  340. package/src/hooks/use-tool-query.effects.test.tsx +205 -0
  341. package/src/hooks/use-tool-query.ts +156 -33
  342. package/src/hooks/use-tool-result-recovery.effects.test.tsx +141 -0
  343. package/src/hooks/use-tool-result-recovery.test.ts +3 -4
  344. package/src/hooks/use-tool-result-recovery.ts +84 -142
  345. package/src/hooks/use-view-data.test.tsx +118 -9
  346. package/src/hooks/use-view-data.ts +128 -18
  347. package/src/hooks/use-view-tool-query.ts +25 -19
  348. package/src/i18n-coverage.test.tsx +104 -2
  349. package/src/index.ts +48 -11
  350. package/src/lib/focus-return.test.ts +288 -0
  351. package/src/lib/focus-return.ts +96 -0
  352. package/src/lib/parse-tool-result.test.ts +46 -1
  353. package/src/lib/parse-tool-result.ts +40 -2
  354. package/src/lib/toast-model.test.ts +166 -1
  355. package/src/lib/toast-model.ts +104 -2
  356. package/src/lib/tone-utils.test.ts +86 -10
  357. package/src/lib/tone-utils.ts +89 -8
  358. package/src/lib/tool-query.test.ts +569 -0
  359. package/src/lib/tool-query.ts +253 -0
  360. package/src/lib/tool-result-observer.test.ts +296 -0
  361. package/src/lib/tool-result-observer.ts +211 -0
  362. package/src/lib/typography.test.ts +45 -0
  363. package/src/lib/typography.ts +47 -0
  364. package/src/lib/utils.ts +12 -1
  365. package/src/locale/index.ts +1 -0
  366. package/src/locale/message-types.ts +118 -2
  367. package/src/locale/messages-de.ts +49 -10
  368. package/src/locale/messages-en.ts +49 -10
  369. package/src/locale/toolkit-messages.test.ts +133 -0
  370. package/src/locale/toolkit-messages.ts +63 -2
  371. package/src/primitives/alert.tsx +3 -0
  372. package/src/primitives/badge.tsx +9 -4
  373. package/src/primitives/button.tsx +1 -1
  374. package/src/primitives/dialog.tsx +10 -1
  375. package/src/primitives/select.tsx +1 -1
  376. package/src/primitives/sheet.tsx +7 -0
  377. package/src/providers/query-provider.test.tsx +154 -0
  378. package/src/providers/query-provider.tsx +66 -5
  379. package/src/render-cases-primitives.tsx +1 -1
  380. package/src/render-cases.tsx +38 -6
  381. package/src/render-effects.tsx +96 -0
  382. package/src/theme/cd-tokens.test.ts +3 -1
  383. package/src/theme/create-theme.test.ts +10 -0
  384. package/src/theme/create-theme.ts +28 -0
  385. package/src/theme/theme-css.test.ts +51 -0
  386. package/src/theme.css +31 -1
  387. package/ui-catalog.json +448 -41
@@ -1,63 +1,186 @@
1
- import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
2
- import { useCallTool } from "../providers/query-provider.js"
1
+ import { useCallback, useEffect, useRef } from "react"
2
+ import {
3
+ hashKey,
4
+ useMutation,
5
+ useQuery,
6
+ useQueryClient,
7
+ type MutationFunctionContext,
8
+ type UseMutationOptions,
9
+ type UseMutationResult,
10
+ type UseQueryResult,
11
+ } from "@tanstack/react-query"
12
+ import { useCallTool, useToolNameInQueryKey } from "../providers/query-provider.js"
3
13
  import { parseToolResult } from "../lib/parse-tool-result.js"
14
+ import {
15
+ claimToolQueryKey,
16
+ invalidateToolQueries,
17
+ NoToolTransportError,
18
+ settleEachCall,
19
+ toolKeyCollisionMessage,
20
+ toolQueryKey,
21
+ toolQueryOptions,
22
+ type PerCallCallbacks,
23
+ type UseToolQueryOptions,
24
+ } from "../lib/tool-query.js"
4
25
 
5
- export interface UseToolQueryOptions<TData = unknown, TSelected = TData> {
6
- enabled?: boolean
7
- select?: (data: TData) => TSelected
26
+ export type { UseToolQueryOptions } from "../lib/tool-query.js"
27
+
28
+ /**
29
+ * The exact cache key `useToolQuery`, `useViewToolQuery` and `useViewData`
30
+ * use for this tool and args under the nearest `AppQueryProvider`:
31
+ * `toolQueryKey` with the provider's `toolNameInQueryKey` setting. For
32
+ * `getQueryData`, `setQueryData` or `cancelQueries({ exact: true })`; prefix
33
+ * invalidation needs only the caller's `queryKey`. A new array per render.
34
+ */
35
+ export function useToolQueryKey(
36
+ queryKey: readonly unknown[],
37
+ toolName: string,
38
+ args: object,
39
+ ): unknown[] {
40
+ return toolQueryKey(queryKey, toolName, args, useToolNameInQueryKey())
41
+ }
42
+
43
+ /**
44
+ * Warns when two tools use one cache key (toolkit#176 K18): their answers
45
+ * would overwrite each other. Only the 2.x key can collide: with the tool
46
+ * name in the key (`toolNameInQueryKey`), two tools never share one.
47
+ */
48
+ export function useToolKeyGuard(
49
+ queryKey: readonly unknown[],
50
+ toolName: string,
51
+ args: object,
52
+ ): void {
53
+ const client = useQueryClient()
54
+ const keyId = hashKey(useToolQueryKey(queryKey, toolName, args))
55
+ useEffect(() => {
56
+ const previous = claimToolQueryKey(client, keyId, toolName)
57
+ if (previous !== null) console.warn(toolKeyCollisionMessage(keyId, toolName, previous))
58
+ }, [client, keyId, toolName])
59
+ }
60
+
61
+ /**
62
+ * Fetches a query again once a tool transport arrives, when it failed for
63
+ * want of one ({@link NoToolTransportError}): a `callTool` that resolves
64
+ * after the first render. TanStack refetches on a new key or `enabled`,
65
+ * never on a new fetcher, so the query would keep the error until a remount.
66
+ */
67
+ export function useRefetchOnTransport(
68
+ callTool: unknown,
69
+ query: Pick<UseQueryResult<unknown, Error>, "error" | "refetch">,
70
+ ): void {
71
+ const { error, refetch } = query
72
+ useEffect(() => {
73
+ if (callTool && error instanceof NoToolTransportError) void refetch()
74
+ }, [callTool, error, refetch])
8
75
  }
9
76
 
10
77
  /**
11
78
  * Loads data from a widget tool via TanStack Query.
12
79
  *
13
- * `args` is appended to the effective query key so two callers that share
14
- * the supplied `queryKey` but pass different `args` get separate cache
15
- * entries (otherwise the second caller silently reads stale data from the
16
- * first — the classic queryKey-vs-args trap). `select` transforms data on
17
- * each read without mutating the cache.
80
+ * The effective cache key is `[...queryKey, args]` (`toolQueryKey`): two
81
+ * callers that share `queryKey` but pass different `args` get separate
82
+ * entries. Two tools must not share a key and args; the hook warns when they
83
+ * do. Under an `AppQueryProvider` with `toolNameInQueryKey` (opt-in in 2.x,
84
+ * the default from the next major) the key is `[...queryKey, toolName,
85
+ * args]` and two tools never collide; `useToolQueryKey` returns the key in
86
+ * force. `opts` passes TanStack's query options through (`retry`,
87
+ * `retryDelay`, `staleTime`, `placeholderData` / `keepPreviousData`,
88
+ * `initialData` + `initialDataUpdatedAt`, `enabled`, `select`, …), and the
89
+ * result exposes `refetch`, `failureCount` and `failureReason`. `retry`
90
+ * defaults to the client's; `retry: shouldRetryToolCall` stops retrying a
91
+ * tool's own `isError` answer.
92
+ *
93
+ * The tool goes through `AppQueryProvider`'s `callTool`, else the nearest
94
+ * `HostBridgeProvider`'s bridge; with neither, the query fails with a
95
+ * "no tool transport" error instead of idling, and fetches once one arrives.
18
96
  */
19
97
  export function useToolQuery<TData = unknown, TSelected = TData>(
20
- queryKey: unknown[],
98
+ queryKey: readonly unknown[],
21
99
  toolName: string,
22
100
  args: object,
23
101
  opts?: UseToolQueryOptions<TData, TSelected>,
24
- ) {
102
+ ): UseQueryResult<TSelected, Error> {
25
103
  const callTool = useCallTool()
104
+ const toolNameInKey = useToolNameInQueryKey()
105
+ useToolKeyGuard(queryKey, toolName, args)
106
+ const query = useQuery<TData, Error, TSelected>(
107
+ toolQueryOptions<TData, TSelected>(queryKey, toolName, args, callTool, opts, {
108
+ toolNameInKey,
109
+ }),
110
+ )
111
+ useRefetchOnTransport(callTool, query)
112
+ return query
113
+ }
26
114
 
27
- return useQuery<TData, Error, TSelected>({
28
- queryKey: [...queryKey, args],
29
- queryFn: async () => {
30
- if (!callTool) throw new Error("callTool not available")
31
- const result = await callTool(toolName, args)
32
- return parseToolResult<TData>(result)
33
- },
34
- select: opts?.select,
35
- enabled: !!callTool && (opts?.enabled ?? true),
36
- })
115
+ /** Options of {@link useToolMutation}: TanStack's mutation options plus `invalidateKeys`. */
116
+ export type UseToolMutationOptions<TData = unknown> = Omit<
117
+ UseMutationOptions<TData, Error, Record<string, unknown>, unknown>,
118
+ "mutationFn" | "onMutate"
119
+ > & {
120
+ /** Query keys (prefixes) to invalidate after a successful call. */
121
+ invalidateKeys?: unknown[][]
37
122
  }
38
123
 
39
124
  /**
40
- * Executes a mutation via a widget tool and invalidates configured query keys.
125
+ * Executes a mutation via a widget tool and invalidates `invalidateKeys`
126
+ * after each successful call (`invalidateQueries`: active queries refetch,
127
+ * inactive ones are marked stale and refetch when they are next used).
128
+ *
129
+ * `mutate(args, { onSuccess, onError, onSettled })` fires its per-call
130
+ * callbacks for EVERY call while the component is mounted, not only for the
131
+ * latest one (TanStack's own rule), so two quick calls each get their
132
+ * confirmation. The hook-level callbacks in `opts` run first; the
133
+ * invalidation runs before the hook-level `onSuccess`.
41
134
  */
42
135
  export function useToolMutation<TData = unknown>(
43
136
  toolName: string,
44
- opts?: { invalidateKeys?: unknown[][] },
45
- ) {
137
+ opts?: UseToolMutationOptions<TData>,
138
+ ): UseMutationResult<TData, Error, Record<string, unknown>> {
46
139
  const callTool = useCallTool()
47
140
  const queryClient = useQueryClient()
141
+ const { invalidateKeys, onSuccess, ...options } = opts ?? {}
48
142
 
49
- return useMutation<TData, Error, Record<string, unknown>>({
143
+ const mutation = useMutation<TData, Error, Record<string, unknown>>({
144
+ ...options,
50
145
  mutationFn: async (args: Record<string, unknown>) => {
51
- if (!callTool) throw new Error("callTool not available")
146
+ if (!callTool) throw new NoToolTransportError(toolName)
52
147
  const result = await callTool(toolName, args)
53
148
  return parseToolResult<TData>(result)
54
149
  },
55
- onSuccess: async () => {
56
- if (opts?.invalidateKeys) {
57
- await Promise.all(
58
- opts.invalidateKeys.map((key) => queryClient.refetchQueries({ queryKey: key })),
59
- )
60
- }
150
+ onSuccess: async (data, variables, onMutateResult, context) => {
151
+ await invalidateToolQueries(queryClient, invalidateKeys)
152
+ await onSuccess?.(data, variables, onMutateResult, context)
61
153
  },
62
154
  })
155
+
156
+ // Per-call callbacks fire only while mounted (TanStack's rule, kept), with
157
+ // the latest meta/mutationKey. Both refs are written in effects and read
158
+ // only when a call settles, so `mutate` stays stable across renders.
159
+ const mounted = useRef(false)
160
+ const scope = useRef<Pick<MutationFunctionContext, "meta" | "mutationKey">>({
161
+ meta: undefined,
162
+ mutationKey: undefined,
163
+ })
164
+ useEffect(() => {
165
+ scope.current = { meta: options.meta, mutationKey: options.mutationKey }
166
+ })
167
+ useEffect(() => {
168
+ mounted.current = true
169
+ return () => {
170
+ mounted.current = false
171
+ }
172
+ }, [])
173
+ const { mutateAsync } = mutation
174
+ const mutate = useCallback(
175
+ (
176
+ variables: Record<string, unknown>,
177
+ callbacks?: PerCallCallbacks<TData, Record<string, unknown>>,
178
+ ) => {
179
+ settleEachCall(mutateAsync(variables), variables, callbacks, () =>
180
+ mounted.current ? { client: queryClient, ...scope.current } : null,
181
+ )
182
+ },
183
+ [mutateAsync, queryClient],
184
+ )
185
+ return { ...mutation, mutate }
63
186
  }
@@ -0,0 +1,141 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"
3
+ import type { ToolResultObserver } from "../lib/tool-result-observer.js"
4
+ import { createToolResultObserver } from "../lib/tool-result-observer.js"
5
+ import { advanceTimers, hookProbe, inAct, renderWithEffects } from "../render-effects.js"
6
+ import {
7
+ DEFAULT_ASSUME_STRIPPED_AFTER_MS,
8
+ useToolResultRecovery,
9
+ type ToolResultRecoveryOptions,
10
+ } from "./use-tool-result-recovery.js"
11
+
12
+ /**
13
+ * The hook's wiring with its effects running (toolkit#176 K13): the default
14
+ * observer is the document's (`installToolResultObserver`), the controller
15
+ * subscribes to it on mount, and a stripped result re-runs the tool from the
16
+ * observer's notification alone, while a slow tool on a compliant host is
17
+ * never re-run. The decision itself is pinned in the recovery model's tests.
18
+ */
19
+
20
+ const installed = vi.hoisted(() => ({ observer: null as ToolResultObserver | null }))
21
+
22
+ // The document's observer, as a framed view would install it; the tests drive
23
+ // a fake host channel instead of window.parent.
24
+ vi.mock(import("../lib/tool-result-observer.js"), async (importOriginal) => {
25
+ const actual = await importOriginal()
26
+ return { ...actual, installToolResultObserver: () => installed.observer }
27
+ })
28
+
29
+ /** A host channel the observer listens on, with the SEP-1865 messages a host sends. */
30
+ function fakeHost() {
31
+ const channel = new EventTarget()
32
+ const observer = createToolResultObserver(channel)
33
+ const send = (data: unknown) =>
34
+ inAct(() => channel.dispatchEvent(new MessageEvent("message", { data })))
35
+ const notify = (method: string, params: Record<string, unknown>) =>
36
+ send({ jsonrpc: "2.0", method, params })
37
+ return {
38
+ observer,
39
+ handshake: () =>
40
+ send({
41
+ jsonrpc: "2.0",
42
+ id: 1,
43
+ result: { protocolVersion: "2026-01-26", hostCapabilities: {} },
44
+ }),
45
+ toolInput: (args: Record<string, unknown>) =>
46
+ notify("ui/notifications/tool-input", { arguments: args }),
47
+ strippedResult: () =>
48
+ notify("ui/notifications/tool-result", { content: [{ type: "text", text: "Rendered." }] }),
49
+ structuredResult: (structuredContent: unknown) =>
50
+ notify("ui/notifications/tool-result", { content: [], structuredContent }),
51
+ }
52
+ }
53
+
54
+ type CallTool = ToolResultRecoveryOptions["callTool"]
55
+
56
+ const PAYLOAD = { ok: true, rows: 3 }
57
+ const isValid = (value: unknown) => (value as { ok?: unknown } | null)?.ok === true
58
+
59
+ let callTool: Mock<CallTool>
60
+
61
+ /** Mount the hook with the view's inputs; the probe keeps its latest state. */
62
+ async function mount(over: Partial<ToolResultRecoveryOptions> = {}) {
63
+ const options: ToolResultRecoveryOptions = {
64
+ resultReady: false,
65
+ props: undefined,
66
+ isValid,
67
+ // The view runtime's input: a partial stream may have merged into it.
68
+ toolInput: { id: 7 },
69
+ hostContext: { toolInfo: { tool: { name: "show_list" } } },
70
+ callTool,
71
+ ...over,
72
+ }
73
+ const probe = hookProbe(() => useToolResultRecovery<typeof PAYLOAD>(options))
74
+ const view = await renderWithEffects(<probe.Probe />)
75
+ return { ...view, state: probe.current }
76
+ }
77
+
78
+ beforeEach(() => {
79
+ vi.useFakeTimers()
80
+ callTool = vi.fn<CallTool>(() => Promise.resolve({ structuredContent: PAYLOAD }))
81
+ })
82
+
83
+ afterEach(() => {
84
+ vi.useRealTimers()
85
+ installed.observer = null
86
+ })
87
+
88
+ describe("useToolResultRecovery with its effects (K13)", () => {
89
+ it("keep: a structured result the latch delivered is never re-run", async () => {
90
+ const host = fakeHost()
91
+ installed.observer = host.observer
92
+ await host.handshake()
93
+ await host.toolInput({ id: 7 })
94
+ await host.structuredResult(PAYLOAD)
95
+ const view = await mount({ resultReady: true, props: PAYLOAD })
96
+ await advanceTimers(vi.advanceTimersByTime, DEFAULT_ASSUME_STRIPPED_AFTER_MS * 4)
97
+ expect(callTool).not.toHaveBeenCalled()
98
+ expect(view.state().data).toBeNull()
99
+ await view.unmount()
100
+ })
101
+
102
+ it("slow: a compliant host still answering is never re-run, however long it takes", async () => {
103
+ const host = fakeHost()
104
+ installed.observer = host.observer
105
+ await host.handshake()
106
+ await host.toolInput({ id: 7 })
107
+ const view = await mount()
108
+ await advanceTimers(vi.advanceTimersByTime, DEFAULT_ASSUME_STRIPPED_AFTER_MS + 1)
109
+ await advanceTimers(vi.advanceTimersByTime, 10_000)
110
+ expect(callTool).not.toHaveBeenCalled()
111
+ expect(view.state()).toMatchObject({ data: null, failed: false, blocked: null })
112
+ await view.unmount()
113
+ })
114
+
115
+ it("strip: the observer's notification alone re-runs the tool once, with the complete input", async () => {
116
+ const host = fakeHost()
117
+ installed.observer = host.observer
118
+ await host.handshake()
119
+ const view = await mount()
120
+ await host.toolInput({ id: 7, page: 2 })
121
+ expect(callTool).not.toHaveBeenCalled()
122
+ // Nothing re-renders the view here: only the subscription can react.
123
+ await host.strippedResult()
124
+ expect(callTool).toHaveBeenCalledTimes(1)
125
+ expect(callTool).toHaveBeenCalledWith("show_list", { id: 7, page: 2 })
126
+ expect(view.state()).toMatchObject({ data: PAYLOAD, failed: false, args: { id: 7, page: 2 } })
127
+ await advanceTimers(vi.advanceTimersByTime, 10_000)
128
+ expect(callTool, "a single flight").toHaveBeenCalledTimes(1)
129
+ await view.unmount()
130
+ })
131
+
132
+ it("without an observer (observer: null) the 2.x grace timer re-runs a silent call", async () => {
133
+ const view = await mount({ observer: null })
134
+ await advanceTimers(vi.advanceTimersByTime, DEFAULT_ASSUME_STRIPPED_AFTER_MS - 1)
135
+ expect(callTool).not.toHaveBeenCalled()
136
+ await advanceTimers(vi.advanceTimersByTime, 1)
137
+ expect(callTool).toHaveBeenCalledTimes(1)
138
+ expect(callTool).toHaveBeenCalledWith("show_list", { id: 7 })
139
+ await view.unmount()
140
+ })
141
+ })
@@ -5,10 +5,9 @@ import {
5
5
  resolveRecoveryToolName,
6
6
  } from "./use-tool-result-recovery.js"
7
7
 
8
- // The hook's effect wiring (single-flight, StrictMode re-run, host-payload
9
- // precedence) is exercised end-to-end by the consumer's Playwright host
10
- // simulation (structuredContent keep/strip scenarios); these tests pin the
11
- // pure decision logic.
8
+ // The hook's effect wiring (default observer, subscription, keep/strip/slow)
9
+ // runs in use-tool-result-recovery.effects.test.tsx and end-to-end in the
10
+ // consumer's Playwright host simulation; these tests pin the pure helpers.
12
11
 
13
12
  describe("resolveRecoveryToolName", () => {
14
13
  it("reads the SEP-1865 shape toolInfo.tool.name first", () => {
@@ -1,44 +1,55 @@
1
- import { useEffect, useRef, useState } from "react"
2
- import { parseToolResult } from "../lib/parse-tool-result.js"
1
+ import { useEffect, useState, useSyncExternalStore } from "react"
2
+ import { installToolResultObserver, type ToolResultObserver } from "../lib/tool-result-observer.js"
3
+ import {
4
+ createRecoveryController,
5
+ type RecoveryPolicy,
6
+ type ToolResultRecoveryState,
7
+ } from "./tool-result-recovery-model.js"
8
+
9
+ export { decodeRecoveredResult } from "./tool-result-recovery-model.js"
10
+ export type {
11
+ RecoveryBlockReason,
12
+ RecoveryPolicy,
13
+ ToolResultRecoveryState,
14
+ ToolSafety,
15
+ } from "./tool-result-recovery-model.js"
3
16
 
4
17
  /**
5
18
  * Recovery for hosts that deliver the `ui/notifications/tool-result`
6
19
  * notification without `structuredContent` (observed on claude.ai and Claude
7
- * Desktop, which forward only the model-facing `content` blocks). Since
8
- * mcp-use 2.x such a result is not even observable: the `useToolContext`
9
- * latch ignores content-only successes, so the tool status stays `"pending"`
10
- * forever and the shell would idle on its loading skeleton.
20
+ * Desktop, which forward only the model-facing `content` blocks). mcp-use
21
+ * 2.x's `useToolContext` latch ignores such a content-only result, so its
22
+ * status stays `"pending"` forever and the shell would idle on its skeleton.
11
23
  *
12
- * Because the stripped notification is invisible, readiness is inferred two
13
- * ways: the hard `resultReady` signal (the latch fired but the payload is
14
- * invalid), or a grace timer — once the originating tool is known and no
15
- * result has latched within
16
- * {@link ToolResultRecoveryOptions.assumeStrippedAfterMs}, the result is
17
- * assumed delivered-but-stripped. Either way the hook re-executes the
18
- * originating tool once via the host bridge's official `callTool` — tool-call
19
- * RESPONSES carry `structuredContent` intact on those hosts — and exposes the
20
- * decoded payload. Spec-conforming hosts (e.g. the mcp-use inspector) deliver
21
- * a valid payload up front, so the recovery never fires there.
24
+ * The hook re-executes the originating tool once via the host bridge's
25
+ * `callTool` — tool-call RESPONSES carry `structuredContent` intact on those
26
+ * hosts — and exposes the decoded payload. When it does so (toolkit#176):
22
27
  *
23
- * A call the host cancelled is never recovered: since mcp-use 2.7.1 the
24
- * cancellation latches as `status: "error"` (a `ToolCancelledError`), which
25
- * would otherwise count as a ready-but-invalid result — re-executing it would
26
- * override the user's decision to stop the call. Neither is a call that
27
- * failed (`isError` or a transport error, {@link ToolResultRecoveryOptions.errored}):
28
- * re-running it only repeats the failure, so the shell renders the error and
29
- * offers an explicit retry instead (toolkit#176).
28
+ * - **Only when the host stripped the result.** A passive observer of the
29
+ * host's lifecycle notifications (`installToolResultObserver`) sees the
30
+ * content-only result itself, so the recovery fires as it arrives, and a
31
+ * slow tool on a compliant host is never re-run (K13): no result has
32
+ * arrived because none was sent. Where the observer cannot vouch for that
33
+ * (no browser frame, or installed after the handshake), the 2.x grace timer
34
+ * ({@link ToolResultRecoveryOptions.assumeStrippedAfterMs}) still infers a
35
+ * stripped result, once the call's arguments arrived.
36
+ * - **With the call's complete arguments** (N21): the observer's complete
37
+ * `tool-input`, never a partial stream; an equal re-delivery is the same run.
38
+ * - **Never for a cancelled or failed call:** re-running repeats the failure
39
+ * or overrides the user's stop; the shell renders that state and offers an
40
+ * explicit retry.
41
+ * - **Only for a tool the policy allows** ({@link ToolResultRecoveryOptions.policy}):
42
+ * by default never one the host reports as writing (`readOnlyHint: false`
43
+ * or `destructiveHint: true` in `hostContext.toolInfo.tool.annotations`).
44
+ * A blocked recovery is reported (`blocked`) so the view can show its error.
30
45
  *
31
- * Assumes the originating tool is a read-only render pipeline (true for all
32
- * toolkit `render-view`/`show_*` tools) — the re-execution must be safe to
33
- * repeat, and may rarely run concurrently with the original invocation when
34
- * the grace timer beats a slow but conforming host.
46
+ * The decision and its timer live in `createRecoveryController`; this hook
47
+ * only binds it to React.
35
48
  */
36
49
 
37
50
  /**
38
- * Default grace period before a silent latch counts as a stripped result.
39
- * Long enough for a conforming host to latch a typical render tool first,
40
- * short enough that users on stripping hosts are not left staring at the
41
- * skeleton noticeably.
51
+ * Default grace period before a silent latch counts as a stripped result —
52
+ * used only where the transport observer cannot vouch that nothing arrived.
42
53
  */
43
54
  export const DEFAULT_ASSUME_STRIPPED_AFTER_MS = 2500
44
55
 
@@ -46,8 +57,7 @@ export interface ToolResultRecoveryOptions {
46
57
  /**
47
58
  * The latch fired (`useToolContext().status !== "pending"`) — a structured
48
59
  * result, a tool error, or a host cancellation arrived. On hosts that strip
49
- * `structuredContent` this never turns true; the grace timer below covers
50
- * that case.
60
+ * `structuredContent` this never turns true.
51
61
  */
52
62
  resultReady: boolean
53
63
  /**
@@ -66,10 +76,7 @@ export interface ToolResultRecoveryOptions {
66
76
  errored?: boolean
67
77
  /** The payload the host delivered (`useToolContext().toolOutput`). */
68
78
  props: unknown
69
- /**
70
- * Validates a candidate payload. MUST be referentially stable (module-level
71
- * function or memoised) — it is an effect dependency.
72
- */
79
+ /** Validates a candidate payload. */
73
80
  isValid: (value: unknown) => boolean
74
81
  /** The tool arguments from `ui/notifications/tool-input` (`useToolContext().toolInput`). */
75
82
  toolInput: Record<string, unknown> | null | undefined
@@ -79,23 +86,21 @@ export interface ToolResultRecoveryOptions {
79
86
  callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>
80
87
  /**
81
88
  * Grace period in ms after which a silent latch counts as a stripped
82
- * result. Armed once the originating tool is known while `resultReady` is
83
- * still false; re-armed on every `toolInput` update, because streaming
84
- * arguments mean the invocation may not have finished yet. Defaults to
89
+ * result. Applies only where the transport observer cannot vouch that no
90
+ * result arrived (see above); restarts for new arguments. Defaults to
85
91
  * {@link DEFAULT_ASSUME_STRIPPED_AFTER_MS}.
86
92
  */
87
93
  assumeStrippedAfterMs?: number
94
+ /** Which tools may be re-executed on their own. Default `"auto"`. */
95
+ policy?: RecoveryPolicy
96
+ /**
97
+ * The transport observer. Defaults to the document's
98
+ * (`installToolResultObserver()`); `null` disables it, leaving the grace
99
+ * timer to decide alone (the 2.x behaviour).
100
+ */
101
+ observer?: ToolResultObserver | null
88
102
  }
89
103
 
90
- export interface ToolResultRecoveryState<T> {
91
- /** The recovered payload, `null` until the re-execution resolved (or when it failed). */
92
- data: T | null
93
- /** `true` when the single recovery attempt failed — the shell keeps its skeleton, no retry loop. */
94
- failed: boolean
95
- }
96
-
97
- const IDLE: ToolResultRecoveryState<never> = { data: null, failed: false }
98
-
99
104
  /**
100
105
  * Resolves the originating tool name from the host context's `toolInfo`.
101
106
  * Reads the SEP-1865 shape first (`toolInfo.tool.name`), then the flattened
@@ -119,26 +124,10 @@ export function resolveRecoveryToolName(hostContext: unknown): string | undefine
119
124
  }
120
125
 
121
126
  /**
122
- * Decodes a re-executed tool result and validates it. Exposed for tests;
123
- * the hook uses it on the `callTool` response.
124
- */
125
- export function decodeRecoveredResult<T>(
126
- result: unknown,
127
- isValid: (value: unknown) => boolean,
128
- ): T | null {
129
- let decoded: unknown
130
- try {
131
- decoded = parseToolResult<unknown>(result)
132
- } catch {
133
- return null
134
- }
135
- return decoded !== null && isValid(decoded) ? (decoded as T) : null
136
- }
137
-
138
- /**
139
- * Whether the recovery may fire: the result latched (or is assumed stripped)
140
- * and the call was neither cancelled nor failed. Exported for tests — this is
141
- * the #176 rule "never recover on an error status".
127
+ * Whether a latched or assumed-stripped result may be recovered at all: the
128
+ * call was neither cancelled nor failed. Kept for existing imports; the full
129
+ * rule (observer, policy, arguments) is `decideRecovery`.
130
+ * @deprecated Use `decideRecovery` from the recovery model.
142
131
  */
143
132
  export function isRecoveryReady(state: {
144
133
  resultReady: boolean
@@ -163,81 +152,34 @@ export function useToolResultRecovery<T>(
163
152
  hostContext,
164
153
  callTool,
165
154
  assumeStrippedAfterMs = DEFAULT_ASSUME_STRIPPED_AFTER_MS,
155
+ policy = "auto",
156
+ observer,
166
157
  } = options
167
- const [state, setState] = useState<ToolResultRecoveryState<T>>(IDLE)
168
- // Single-flight: the promise ref survives StrictMode's dev-mode
169
- // effect re-run (cleanup + setup on the same instance), so the re-run
170
- // re-subscribes to the SAME in-flight call instead of firing a second one.
171
- const flightRef = useRef<Promise<ToolResultRecoveryState<T>> | null>(null)
172
- const flightInputRef = useRef<unknown>(null)
173
-
174
- const toolName = resolveRecoveryToolName(hostContext)
175
-
176
- // A stripped result is unobservable (the 2.x latch ignores content-only
177
- // results), so infer it: once a recovery could fire but the latch has been
178
- // silent for the grace period, treat the result as delivered-but-stripped.
179
- // Re-armed on every toolInput identity change — streaming arguments mean
180
- // the invocation may still be running, so the grace period starts over.
181
- // The re-arm is a render-phase reset (not an effect) so the cascading-
182
- // render lint stays satisfied; the timer effect below restarts anyway
183
- // because toolInput is one of its dependencies.
184
- const [assumedReady, setAssumedReady] = useState(false)
185
- const [prevTimerInput, setPrevTimerInput] = useState<unknown>(toolInput)
186
- if (prevTimerInput !== toolInput) {
187
- setPrevTimerInput(toolInput)
188
- if (assumedReady) setAssumedReady(false)
189
- }
190
- const suppressed = cancelled || errored
158
+ // One controller per mount; it survives StrictMode's effect re-run, so an
159
+ // in-flight call is never fired twice.
160
+ const [controller] = useState(() =>
161
+ createRecoveryController<T>({
162
+ observer: observer === undefined ? installToolResultObserver() : observer,
163
+ }),
164
+ )
191
165
  useEffect(() => {
192
- if (resultReady || suppressed || toolName === undefined) return
193
- const timer = setTimeout(() => setAssumedReady(true), assumeStrippedAfterMs)
194
- return () => clearTimeout(timer)
195
- }, [resultReady, suppressed, toolName, toolInput, assumeStrippedAfterMs])
196
-
197
- // A cancelled or failed call never counts as ready, so the effect below
198
- // invalidates any in-flight attempt the same way it does for a superseded run.
199
- const ready = isRecoveryReady({ resultReady, assumedReady, cancelled, errored })
200
-
166
+ controller.start()
167
+ return () => controller.stop()
168
+ }, [controller])
201
169
  useEffect(() => {
202
- // A new tool run (result no longer ready, or fresh tool-input identity)
203
- // invalidates any previous recovery attempt and re-arms the single-flight.
204
- if (flightRef.current && (!ready || flightInputRef.current !== toolInput)) {
205
- flightRef.current = null
206
- setState((prev) => (prev.data === null && !prev.failed ? prev : IDLE))
207
- }
208
- // Never fire on conforming hosts (valid payload) or before the
209
- // originating tool is known.
210
- if (!ready || isValid(props) || toolName === undefined) return
211
-
212
- if (!flightRef.current) {
213
- flightInputRef.current = toolInput
214
- flightRef.current = (async (): Promise<ToolResultRecoveryState<T>> => {
215
- try {
216
- const result = await callTool(toolName, toolInput ?? {})
217
- const data = decodeRecoveredResult<T>(result, isValid)
218
- if (data !== null) return { data, failed: false }
219
- console.warn(
220
- `[mcp-toolkit] tool-result recovery: re-executing "${toolName}" returned no usable payload`,
221
- )
222
- return { data: null, failed: true }
223
- } catch (err) {
224
- console.warn(`[mcp-toolkit] tool-result recovery for "${toolName}" failed:`, err)
225
- return { data: null, failed: true }
226
- }
227
- })()
228
- }
229
-
230
- const flight = flightRef.current
231
- let stale = false
232
- void flight.then((outcome) => {
233
- // Drop the outcome when this subscription was superseded or the flight
234
- // was invalidated by a newer tool run.
235
- if (!stale && flightRef.current === flight) setState(outcome)
170
+ controller.update({
171
+ resultReady,
172
+ cancelled,
173
+ errored,
174
+ props,
175
+ isValid,
176
+ toolInput,
177
+ hostContext,
178
+ callTool,
179
+ policy,
180
+ graceMs: assumeStrippedAfterMs,
181
+ resolveToolName: resolveRecoveryToolName,
236
182
  })
237
- return () => {
238
- stale = true
239
- }
240
- }, [ready, props, isValid, toolName, toolInput, callTool])
241
-
242
- return state
183
+ })
184
+ return useSyncExternalStore(controller.subscribe, controller.getState, controller.getState)
243
185
  }