@miragon/mcp-toolkit-ui 0.10.1 → 1.0.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.
Files changed (49) hide show
  1. package/README.md +7 -7
  2. package/dist/app/adapt-data-widget.d.ts +26 -5
  3. package/dist/app/adapt-data-widget.d.ts.map +1 -1
  4. package/dist/app/adapt-data-widget.js +39 -5
  5. package/dist/app/adapt-data-widget.js.map +1 -1
  6. package/dist/app/host-bridge.d.ts +48 -42
  7. package/dist/app/host-bridge.d.ts.map +1 -1
  8. package/dist/app/host-bridge.js +28 -50
  9. package/dist/app/host-bridge.js.map +1 -1
  10. package/dist/app/index.d.ts +5 -4
  11. package/dist/app/index.d.ts.map +1 -1
  12. package/dist/app/index.js +9 -8
  13. package/dist/app/index.js.map +1 -1
  14. package/dist/app/mcp-app-view.d.ts.map +1 -1
  15. package/dist/app/mcp-app-view.js +37 -24
  16. package/dist/app/mcp-app-view.js.map +1 -1
  17. package/dist/app/mcp-toolkit-app.d.ts +21 -10
  18. package/dist/app/mcp-toolkit-app.d.ts.map +1 -1
  19. package/dist/app/mcp-toolkit-app.js +26 -12
  20. package/dist/app/mcp-toolkit-app.js.map +1 -1
  21. package/dist/app/mcp-use-host-bridge.d.ts +29 -0
  22. package/dist/app/mcp-use-host-bridge.d.ts.map +1 -0
  23. package/dist/app/mcp-use-host-bridge.js +120 -0
  24. package/dist/app/mcp-use-host-bridge.js.map +1 -0
  25. package/dist/app/use-host-actions.d.ts +6 -6
  26. package/dist/app/use-host-actions.js +6 -6
  27. package/dist/app/widget-fixture.d.ts +24 -21
  28. package/dist/app/widget-fixture.d.ts.map +1 -1
  29. package/dist/app/widget-fixture.js +27 -123
  30. package/dist/app/widget-fixture.js.map +1 -1
  31. package/dist/hooks/use-tool-result-recovery.d.ts +40 -15
  32. package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -1
  33. package/dist/hooks/use-tool-result-recovery.js +57 -4
  34. package/dist/hooks/use-tool-result-recovery.js.map +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/index.js.map +1 -1
  37. package/package.json +16 -8
  38. package/src/app/adapt-data-widget.tsx +50 -6
  39. package/src/app/host-bridge.test.ts +107 -0
  40. package/src/app/host-bridge.tsx +81 -78
  41. package/src/app/index.ts +15 -10
  42. package/src/app/mcp-app-view.tsx +41 -32
  43. package/src/app/mcp-toolkit-app.tsx +33 -16
  44. package/src/app/mcp-use-host-bridge.tsx +164 -0
  45. package/src/app/use-host-actions.tsx +6 -6
  46. package/src/app/widget-fixture.tsx +35 -156
  47. package/src/hooks/use-tool-result-recovery.ts +75 -19
  48. package/src/index.ts +1 -1
  49. package/ui-catalog.json +80 -26
@@ -0,0 +1,164 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from "react"
11
+ import {
12
+ useDynamicTool,
13
+ useHostContext,
14
+ useOpenExternal,
15
+ useSendFollowUp,
16
+ useToolContext,
17
+ useViewState,
18
+ useViewTheme,
19
+ } from "mcp-use/react"
20
+ import { HostBridgeProvider, toHostBridge, type HostBridge } from "./host-bridge.js"
21
+
22
+ /**
23
+ * `true` inside an mcp-use 2.x view — i.e. under a
24
+ * {@link McpUseHostBridgeProvider}, which itself only renders under a
25
+ * `bootstrapView`-mounted tree. Components that want to use `mcp-use/react`
26
+ * primitives (which throw outside a view) branch on this instead of probing
27
+ * mcp-use internals; see `adaptDataWidget`'s ModelContext handling.
28
+ */
29
+ const McpUseViewContext = createContext(false)
30
+
31
+ /** Whether the subtree runs inside an mcp-use view (see {@link McpUseViewContext}). */
32
+ export function useIsInsideMcpUseView(): boolean {
33
+ return useContext(McpUseViewContext)
34
+ }
35
+
36
+ type DynamicCallTool = (name: string, args: Record<string, unknown>) => Promise<unknown>
37
+ type BoundCall = (args: Record<string, unknown>) => Promise<unknown>
38
+
39
+ /**
40
+ * Mounts one `useDynamicTool` handle and hands its stable `callTool` up to the
41
+ * pool. This rendered indirection exists because mcp-use 2.x has no public
42
+ * "call any tool by name" API — `useDynamicTool(name)` is fixed per hook
43
+ * instance, so a generic `(name, args)` caller needs one mounted hook per
44
+ * distinct name.
45
+ */
46
+ function ToolBinder({
47
+ name,
48
+ onBind,
49
+ }: {
50
+ name: string
51
+ onBind: (name: string, call: BoundCall) => void
52
+ }): null {
53
+ const { callTool } = useDynamicTool<Record<string, unknown>, unknown>(name)
54
+ useEffect(() => {
55
+ onBind(name, callTool)
56
+ }, [name, callTool, onBind])
57
+ return null
58
+ }
59
+
60
+ /**
61
+ * A generic `(name, args)` tool caller over mcp-use 2.x's per-name hooks.
62
+ *
63
+ * The first call for a given name mounts a {@link ToolBinder} (one render
64
+ * tick) and resolves once its handle is bound; later calls hit the bound
65
+ * handle directly. Binders accumulate for the mount's lifetime — tool names in
66
+ * a view are a small closed set. Calls left waiting when the view unmounts
67
+ * never settle, which is moot because their callers unmounted with it.
68
+ */
69
+ function useDynamicToolPool(): { callTool: DynamicCallTool; binders: ReactNode } {
70
+ const [names, setNames] = useState<readonly string[]>([])
71
+ const boundRef = useRef(new Map<string, BoundCall>())
72
+ const waitersRef = useRef(new Map<string, ((call: BoundCall) => void)[]>())
73
+
74
+ const onBind = useCallback((name: string, call: BoundCall) => {
75
+ boundRef.current.set(name, call)
76
+ const waiters = waitersRef.current.get(name)
77
+ if (!waiters) return
78
+ waitersRef.current.delete(name)
79
+ for (const resolve of waiters) resolve(call)
80
+ }, [])
81
+
82
+ const callTool = useCallback<DynamicCallTool>((name, args) => {
83
+ const bound = boundRef.current.get(name)
84
+ if (bound) return bound(args)
85
+ const pending = new Promise<BoundCall>((resolve) => {
86
+ const waiters = waitersRef.current.get(name) ?? []
87
+ waiters.push(resolve)
88
+ waitersRef.current.set(name, waiters)
89
+ })
90
+ setNames((prev) => (prev.includes(name) ? prev : [...prev, name]))
91
+ return pending.then((call) => call(args))
92
+ }, [])
93
+
94
+ const binders = useMemo(
95
+ () => names.map((name) => <ToolBinder key={name} name={name} onBind={onBind} />),
96
+ [names, onBind],
97
+ )
98
+
99
+ return { callTool, binders }
100
+ }
101
+
102
+ /**
103
+ * Install the `mcp-use` {@link HostBridge} (plus the view-scope flag) for a
104
+ * subtree inside an mcp-use 2.x view.
105
+ *
106
+ * Composes the 2.x view hooks into the `McpUseWidgetSurface` shape and maps it
107
+ * through {@link toHostBridge}, so the fail-soft bridge contract widgets rely
108
+ * on is exactly the pinned one:
109
+ *
110
+ * - `callTool` — the dynamic-tool pool above; resolves with the raw
111
+ * `CallToolResult` envelope, rejects on tool errors.
112
+ * - `sendFollowup` / `openExternal` — `useSendFollowUp` / `useOpenExternal`;
113
+ * while the bridge is not connected (local `file://` preview), `openExternal`
114
+ * throws into `toHostBridge`'s documented `window.open` fallback.
115
+ * - `setModelContext` — writes `{ __model_context }` into the view state,
116
+ * which hosts receive via `ui/update-model-context` (ext-apps) or
117
+ * `widgetState` (ChatGPT). The key is a toolkit-owned convention since 2.x.
118
+ * - `getWidgetData` — the rendering tool's `structuredContent` once ready.
119
+ *
120
+ * Must render inside a `bootstrapView`-mounted tree (the mcp-use hooks throw
121
+ * otherwise) — `McpToolkitApp` does this for the standard bundle; use it
122
+ * directly only when composing a custom shell under `bootstrapView`.
123
+ */
124
+ export function McpUseHostBridgeProvider({ children }: { children: ReactNode }): ReactNode {
125
+ const { callTool, binders } = useDynamicToolPool()
126
+ const sendFollowUp = useSendFollowUp()
127
+ const openExternal = useOpenExternal()
128
+ const tool = useToolContext()
129
+ const theme = useViewTheme()
130
+ const { isAvailable } = useHostContext()
131
+ const [, setViewState] = useViewState<Record<string, unknown>>({})
132
+
133
+ const output = tool.status === "ready" ? tool.toolOutput : undefined
134
+
135
+ const bridge = useMemo<HostBridge>(
136
+ () =>
137
+ toHostBridge({
138
+ callTool,
139
+ sendFollowUpMessage: (prompt) => sendFollowUp({ prompt }),
140
+ openExternal: (url) => {
141
+ // Not connected (e.g. the bundle opened straight in a browser):
142
+ // throw synchronously so toHostBridge's window.open fallback fires.
143
+ if (!isAvailable) throw new Error("mcp-use view bridge is not connected")
144
+ void openExternal({ url }).catch((err: unknown) => {
145
+ console.warn("[host-bridge] openExternal failed:", err)
146
+ })
147
+ },
148
+ output,
149
+ theme,
150
+ setState: (state) => {
151
+ setViewState((prev) => ({ ...prev, ...state }))
152
+ return Promise.resolve()
153
+ },
154
+ }),
155
+ [callTool, sendFollowUp, openExternal, output, theme, isAvailable, setViewState],
156
+ )
157
+
158
+ return (
159
+ <McpUseViewContext.Provider value={true}>
160
+ {binders}
161
+ <HostBridgeProvider bridge={bridge}>{children}</HostBridgeProvider>
162
+ </McpUseViewContext.Provider>
163
+ )
164
+ }
@@ -53,12 +53,12 @@ export function buildShowWidgetIntent(toolName: string, description: string): st
53
53
  * because they cross the UI→chat boundary for different reasons — see above).
54
54
  *
55
55
  * Relationship to {@link useHostBridge}: this hook resolves the active bridge
56
- * through `useHostBridge()`, so it honours an explicit `HostBridgeProvider`
57
- * (ChatGPT / standalone) when one is present, and otherwise falls back to the
58
- * default `mcp-use` bridge — exactly the behaviour widgets had before the bridge
59
- * abstraction existed, so no existing widget or test breaks. Prefer
60
- * `useHostBridge` directly in new portable widgets; `useHostActions` stays for
61
- * its named affordances and backward compatibility.
56
+ * through `useHostBridge()`, so it uses whatever the nearest
57
+ * `HostBridgeProvider` supplies — the toolkit shell installs the `mcp-use`
58
+ * bridge via `McpUseHostBridgeProvider`, ChatGPT / standalone mounts wrap
59
+ * their root themselves — and, like `useHostBridge`, it throws when no
60
+ * provider is present. Prefer `useHostBridge` directly in new portable
61
+ * widgets; `useHostActions` stays for its named affordances.
62
62
  */
63
63
  export function useHostActions(): HostActions {
64
64
  const bridge = useHostBridge()
@@ -1,26 +1,8 @@
1
- import {
2
- createContext,
3
- useContext,
4
- useEffect,
5
- useMemo,
6
- useRef,
7
- useSyncExternalStore,
8
- type ComponentType,
9
- type ReactNode,
10
- } from "react"
11
- import { useWidget } from "mcp-use/react"
1
+ import { createContext, useContext, useMemo, type ComponentType, type ReactNode } from "react"
12
2
  import type { PipelineContext, StepResult, WidgetProps } from "@miragon/mcp-toolkit-core"
13
3
  import { AppQueryProvider } from "../providers/query-provider.js"
14
4
  import { HostBridgeProvider, createStandaloneHostBridge } from "./host-bridge.js"
15
5
 
16
- /**
17
- * Key `mcp-use` writes the serialized model-context string under when it flushes
18
- * to `window.openai.setWidgetState`. Mirrors `MODEL_CONTEXT_KEY` in
19
- * `mcp-use/react` (not exported, so duplicated here). The harness intercepts
20
- * `setWidgetState` and reads this field to surface what the model would see.
21
- */
22
- const MODEL_CONTEXT_KEY = "__model_context"
23
-
24
6
  /**
25
7
  * Minimal shape of a tool-call response the harness hands back to widgets.
26
8
  * Mirrors `mcp-use`'s `CallToolResponse` closely enough that both the toolkit's
@@ -132,8 +114,11 @@ function toToolResult(raw: unknown): FixtureToolResult {
132
114
 
133
115
  /**
134
116
  * A host action a widget asked the host to perform, surfaced to the harness so
135
- * it can render an activity log. Mirrors the `mcp-use` bridge verbs that
136
- * {@link useHostActions} and `useWidget` route through `window.openai`.
117
+ * it can render an activity log. The `HostBridge` verbs (`callTool`,
118
+ * `sendFollowup`, `openExternal`) fire in the harness; `requestDisplayMode` /
119
+ * `setWidgetState` remain in the union for log-renderer compatibility but no
120
+ * longer fire — they belonged to the 1.x `window.openai` shim this harness
121
+ * used to install.
137
122
  */
138
123
  export type HostActionLog =
139
124
  | { type: "callTool"; name: string; args: Record<string, unknown> }
@@ -158,9 +143,10 @@ export function useFixtureHost(): FixtureHostValue | null {
158
143
  }
159
144
 
160
145
  /**
161
- * Default host-context globals a widget sees in the harness. Chosen to be
162
- * harmless and inert: inline display, light theme, desktop, no safe-area
163
- * insets. Overridable per-mount via {@link WidgetFixtureHostProps.globals}.
146
+ * Default host-context values the harness reports. Only `theme` is read since
147
+ * the mcp-use 2.x move (the other host globals were consumed by the 1.x
148
+ * `window.openai` shim); the object keeps its shape so existing playground
149
+ * setups that spread overrides keep compiling.
164
150
  */
165
151
  const DEFAULT_GLOBALS = {
166
152
  theme: "light",
@@ -174,67 +160,6 @@ const DEFAULT_GLOBALS = {
174
160
  },
175
161
  } as const
176
162
 
177
- /**
178
- * Build the `window.openai` shim the `mcp-use` React hooks read from. Routes
179
- * `callTool` through the fixture registry and reports every other bridge verb
180
- * to `onHostAction`. Returns `null` outside a browser-like environment (SSR)
181
- * so the harness degrades to a plain render rather than throwing.
182
- */
183
- function buildOpenAiShim(
184
- registry: FixtureCallToolRegistry,
185
- toolOutput: Record<string, unknown> | null,
186
- toolInput: Record<string, unknown>,
187
- globals: Record<string, unknown>,
188
- onHostAction?: (action: HostActionLog) => void,
189
- onModelContext?: (text: string) => void,
190
- ): Record<string, unknown> | null {
191
- if (typeof window === "undefined") return null
192
- return {
193
- ...DEFAULT_GLOBALS,
194
- ...globals,
195
- toolInput,
196
- toolOutput,
197
- toolResponseMetadata: null,
198
- widgetState: null,
199
- callTool: async (name: string, args: Record<string, unknown>) => {
200
- onHostAction?.({ type: "callTool", name, args })
201
- return registry.call(name, args)
202
- },
203
- sendFollowUpMessage: (args: { prompt: string }) => {
204
- onHostAction?.({ type: "sendFollowUpMessage", prompt: args.prompt })
205
- return Promise.resolve()
206
- },
207
- openExternal: (payload: { href: string }) => {
208
- onHostAction?.({ type: "openExternal", href: payload.href })
209
- },
210
- requestDisplayMode: (args: { mode: string }) => {
211
- onHostAction?.({ type: "requestDisplayMode", mode: args.mode })
212
- return Promise.resolve({ mode: args.mode })
213
- },
214
- setWidgetState: (state: Record<string, unknown>) => {
215
- onHostAction?.({ type: "setWidgetState", state })
216
- // `mcp-use` flushes the serialized `<ModelContext>` tree through
217
- // setWidgetState under MODEL_CONTEXT_KEY — surface it as the model view.
218
- const ctx = state?.[MODEL_CONTEXT_KEY]
219
- if (typeof ctx === "string") onModelContext?.(ctx)
220
- return Promise.resolve()
221
- },
222
- notifyIntrinsicHeight: () => Promise.resolve(),
223
- }
224
- }
225
-
226
- /**
227
- * Renders nothing but mounts a `useWidget()` so `mcp-use` registers its
228
- * model-context flush handler — even for widgets that never call `useWidget`
229
- * themselves (e.g. a simple `({ keys })` card). Without this, a widget's
230
- * `<ModelContext>` would render but never flush, and `onModelContext` would
231
- * stay silent. The flush routes through our `setWidgetState` shim above.
232
- */
233
- function ModelContextPump(): null {
234
- useWidget()
235
- return null
236
- }
237
-
238
163
  /**
239
164
  * Either an `adaptDataWidget`-style component (receives {@link WidgetProps}) or
240
165
  * a "raw" single-data component (`({ data }) | ({ keys })`). The harness builds
@@ -262,13 +187,14 @@ export interface WidgetFixtureHostProps {
262
187
  dataType?: string
263
188
  /**
264
189
  * Tool fixtures keyed by tool name. Each value is a static result or a
265
- * handler — see {@link FixtureToolEntry}. Drives both `mcp-use`'s
266
- * `useCallTool` and the toolkit's `useToolQuery`.
190
+ * handler — see {@link FixtureToolEntry}. Drives the simulated
191
+ * `HostBridge.callTool` and the toolkit's `useToolQuery`.
267
192
  */
268
193
  tools?: Record<string, FixtureToolEntry>
269
194
  /**
270
- * Host context globals to override (theme, displayMode, locale, …). Merged
271
- * over {@link DEFAULT_GLOBALS}.
195
+ * Host context overrides, merged over {@link DEFAULT_GLOBALS}. Since the
196
+ * mcp-use 2.x move only `theme` influences the simulated host; the other
197
+ * keys are accepted for compatibility with existing playground setups.
272
198
  */
273
199
  globals?: Record<string, unknown>
274
200
  /**
@@ -313,25 +239,24 @@ export function buildFixtureWidgetProps(
313
239
  * without booting a real MCP host or backend.
314
240
  *
315
241
  * ### What it mocks
316
- * - **`window.openai`** — the `mcp-use` Apps-SDK bridge `useWidget`/`useCallTool`
317
- * read from. Installed on mount, removed on unmount. `callTool` is routed to a
318
- * {@link FixtureCallToolRegistry} built from `tools`; `sendFollowUpMessage`,
319
- * `openExternal`, etc. are reported to `onHostAction`.
242
+ * - **A {@link HostBridge}** — a `createStandaloneHostBridge` over the fixture
243
+ * registry and callbacks, provided via `HostBridgeProvider`. `callTool`
244
+ * resolves against the {@link FixtureCallToolRegistry} built from `tools`;
245
+ * `sendFollowup` / `openExternal` are reported to `onHostAction`;
246
+ * `setModelContext` feeds `onModelContext`, so you can *see* what the model
247
+ * would (adapted widgets report through this bridge outside a real view).
320
248
  * - **The toolkit `AppQueryProvider`** — so widgets that fetch via `useToolQuery`
321
249
  * (e.g. generated `use*` hooks) resolve against the same registry.
322
- * - **`ModelContext`** — a flush handler captures the serialized context string
323
- * and forwards it to `onModelContext`, so you can *see* what the model would.
324
- * - **A {@link HostBridge}** — a `createStandaloneHostBridge` over the same
325
- * registry and callbacks, provided via `HostBridgeProvider`, so widgets
326
- * written against `useHostBridge` (the host-portable pattern) are simulated
327
- * from the same single source as the `window.openai` shim above.
328
250
  *
329
251
  * ### Boundaries
330
- * `mcp-use`'s hooks select their provider from `window.openai` presence; this
331
- * harness deliberately drives the Apps-SDK path (sets `window.openai`) rather
332
- * than the `postMessage` bridge, which only activates inside a cross-origin
333
- * iframe. The shim is intentionally minimal — it is not a host, and host
334
- * features beyond the bridge verbs above are out of scope.
252
+ * The harness simulates the **host-portable pattern** (`useHostBridge`,
253
+ * `useToolQuery`) — the surface the toolkit's skills teach. It does not
254
+ * simulate the mcp-use 2.x view runtime: since 2.x the `mcp-use/react` hooks
255
+ * only run under a `bootstrapView`-mounted view speaking the ext-apps
256
+ * postMessage protocol (the 1.x `window.openai` shim this harness used to
257
+ * install has no equivalent seam). A widget that calls `mcp-use/react` hooks
258
+ * directly must be exercised against a real host — or rewritten against
259
+ * `useHostBridge`, which is the portable contract anyway.
335
260
  */
336
261
  export function WidgetFixtureHost({
337
262
  widget: Widget,
@@ -346,54 +271,20 @@ export function WidgetFixtureHost({
346
271
  // edits in the playground take effect without a full remount.
347
272
  const registry = useMemo(() => new FixtureCallToolRegistry(tools), [tools])
348
273
 
349
- // Latest callbacks without re-installing the shim on every render. Updated in
350
- // an effect (not during render) so the shim's closures always see the newest
351
- // handler while the `window.openai` install effect stays stable.
352
- const onHostActionRef = useRef(onHostAction)
353
- const onModelContextRef = useRef(onModelContext)
354
- useEffect(() => {
355
- onHostActionRef.current = onHostAction
356
- onModelContextRef.current = onModelContext
357
- })
358
-
359
274
  const widgetProps = useMemo(() => buildFixtureWidgetProps(data, dataType), [data, dataType])
360
275
 
361
- // Install the `window.openai` shim for the lifetime of this mount. Wake any
362
- // already-mounted `mcp-use` hooks via the `set_globals` event they listen for.
363
- useEffect(() => {
364
- if (typeof window === "undefined") return
365
- const shim = buildOpenAiShim(
366
- registry,
367
- data ?? null,
368
- data ?? {},
369
- globals ?? {},
370
- (action) => onHostActionRef.current?.(action),
371
- (text) => onModelContextRef.current?.(text),
372
- )
373
- if (!shim) return
374
- const prev = (window as { openai?: unknown }).openai
375
- ;(window as { openai?: unknown }).openai = shim
376
- window.dispatchEvent(new CustomEvent("openai:set_globals", { detail: { globals: shim } }))
377
- return () => {
378
- ;(window as { openai?: unknown }).openai = prev
379
- }
380
- }, [registry, data, globals])
381
-
382
276
  const callTool = useMemo(
383
277
  () => async (name: string, args: object) =>
384
278
  registry.call(name, (args as Record<string, unknown>) ?? {}),
385
279
  [registry],
386
280
  )
387
281
 
388
- // A standalone host bridge over the same registry + callbacks, so portable
389
- // widgets written against `useHostBridge` (rather than the `mcp-use` host
390
- // hooks) are simulated from the *same* source as the `window.openai` shim.
391
- // It logs through the same `HostActionLog` channel, so the playground's
392
- // activity log shows tool calls, follow-ups, and links identically whether the
393
- // widget uses the mcp-use path or the host bridge. Reading globals.theme keeps
394
- // the bridge's reported theme in sync with the shim. Depends on the callback
395
- // props directly (not the refs) — a fresh Provider value on callback change is
396
- // fine for a dev harness, and keeps the bridge's closures pure of ref reads.
282
+ // The standalone host bridge is the harness's single simulation source: it
283
+ // logs every verb through the `HostActionLog` channel for the playground's
284
+ // activity log. Depends on the callback props directly — a fresh Provider
285
+ // value on callback change is fine for a dev harness. Only `globals.theme`
286
+ // still influences the simulated host (the remaining globals fed the 1.x
287
+ // `window.openai` shim).
397
288
  const bridgeTheme = (globals?.theme ?? DEFAULT_GLOBALS.theme) === "dark" ? "dark" : "light"
398
289
  const bridge = useMemo(
399
290
  () =>
@@ -413,24 +304,12 @@ export function WidgetFixtureHost({
413
304
 
414
305
  const value = useMemo<FixtureHostValue>(() => ({ registry }), [registry])
415
306
 
416
- // Mount the model-context pump only on the client. The pump calls `mcp-use`'s
417
- // `useWidget`, whose `useSyncExternalStore` lacks a server snapshot and throws
418
- // under `react-dom/server` — gating it keeps the harness (and any widget
419
- // rendered through it) SSR-safe. `useSyncExternalStore` with a `false` server
420
- // snapshot is the idiomatic "are we on the client?" check (no setState-in-effect).
421
- const isClient = useSyncExternalStore(
422
- () => () => {},
423
- () => true,
424
- () => false,
425
- )
426
-
427
307
  // Spread `widgetProps` (keys/context) AND a `data` alias so both adapted
428
308
  // widgets and raw `({ data })` widgets read their fixture from one mount.
429
309
  const RenderWidget = Widget as ComponentType<Record<string, unknown>>
430
310
 
431
311
  return (
432
312
  <FixtureHostContext.Provider value={value}>
433
- {isClient && <ModelContextPump />}
434
313
  <HostBridgeProvider bridge={bridge}>
435
314
  <AppQueryProvider callTool={callTool}>
436
315
  <RenderWidget {...widgetProps} data={data} />
@@ -4,38 +4,64 @@ import { parseToolResult } from "../lib/parse-tool-result.js"
4
4
  /**
5
5
  * Recovery for hosts that deliver the `ui/notifications/tool-result`
6
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.
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.
10
11
  *
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.
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.
17
22
  *
18
23
  * Assumes the originating tool is a read-only render pipeline (true for all
19
24
  * toolkit `render-view`/`show_*` tools) — the re-execution must be safe to
20
- * repeat.
25
+ * repeat, and may rarely run concurrently with the original invocation when
26
+ * the grace timer beats a slow but conforming host.
21
27
  */
22
28
 
29
+ /**
30
+ * Default grace period before a silent latch counts as a stripped result.
31
+ * Long enough for a conforming host to latch a typical render tool first,
32
+ * short enough that users on stripping hosts are not left staring at the
33
+ * skeleton noticeably.
34
+ */
35
+ export const DEFAULT_ASSUME_STRIPPED_AFTER_MS = 2500
36
+
23
37
  export interface ToolResultRecoveryOptions {
24
- /** `!isPending` from `useWidget` — the host reported a finished tool call. */
38
+ /**
39
+ * The latch fired (`useToolContext().status !== "pending"`) — a structured
40
+ * result or tool error arrived. On hosts that strip `structuredContent`
41
+ * this never turns true; the grace timer below covers that case.
42
+ */
25
43
  resultReady: boolean
26
- /** The props the host delivered (`useWidget().props`). */
44
+ /** The payload the host delivered (`useToolContext().toolOutput`). */
27
45
  props: unknown
28
46
  /**
29
47
  * Validates a candidate payload. MUST be referentially stable (module-level
30
48
  * function or memoised) — it is an effect dependency.
31
49
  */
32
50
  isValid: (value: unknown) => boolean
33
- /** The tool arguments from `ui/notifications/tool-input` (`useWidget().toolInput`). */
51
+ /** The tool arguments from `ui/notifications/tool-input` (`useToolContext().toolInput`). */
34
52
  toolInput: Record<string, unknown> | null | undefined
35
- /** Raw host context (`useWidget().hostContext`); `toolInfo` names the originating tool. */
53
+ /** Raw host context (`useHostContext().hostContext`); `toolInfo` names the originating tool. */
36
54
  hostContext: Record<string, unknown> | null | undefined
37
- /** The bridge's `callTool` (`useWidget().callTool`). */
55
+ /** The host bridge's `callTool`. */
38
56
  callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>
57
+ /**
58
+ * Grace period in ms after which a silent latch counts as a stripped
59
+ * result. Armed once the originating tool is known while `resultReady` is
60
+ * still false; re-armed on every `toolInput` update, because streaming
61
+ * arguments mean the invocation may not have finished yet. Defaults to
62
+ * {@link DEFAULT_ASSUME_STRIPPED_AFTER_MS}.
63
+ */
64
+ assumeStrippedAfterMs?: number
39
65
  }
40
66
 
41
67
  export interface ToolResultRecoveryState<T> {
@@ -89,7 +115,15 @@ export function decodeRecoveredResult<T>(
89
115
  export function useToolResultRecovery<T>(
90
116
  options: ToolResultRecoveryOptions,
91
117
  ): ToolResultRecoveryState<T> {
92
- const { resultReady, props, isValid, toolInput, hostContext, callTool } = options
118
+ const {
119
+ resultReady,
120
+ props,
121
+ isValid,
122
+ toolInput,
123
+ hostContext,
124
+ callTool,
125
+ assumeStrippedAfterMs = DEFAULT_ASSUME_STRIPPED_AFTER_MS,
126
+ } = options
93
127
  const [state, setState] = useState<ToolResultRecoveryState<T>>(IDLE)
94
128
  // Single-flight: the promise ref survives StrictMode's dev-mode
95
129
  // effect re-run (cleanup + setup on the same instance), so the re-run
@@ -99,16 +133,38 @@ export function useToolResultRecovery<T>(
99
133
 
100
134
  const toolName = resolveRecoveryToolName(hostContext)
101
135
 
136
+ // A stripped result is unobservable (the 2.x latch ignores content-only
137
+ // results), so infer it: once a recovery could fire but the latch has been
138
+ // silent for the grace period, treat the result as delivered-but-stripped.
139
+ // Re-armed on every toolInput identity change — streaming arguments mean
140
+ // the invocation may still be running, so the grace period starts over.
141
+ // The re-arm is a render-phase reset (not an effect) so the cascading-
142
+ // render lint stays satisfied; the timer effect below restarts anyway
143
+ // because toolInput is one of its dependencies.
144
+ const [assumedReady, setAssumedReady] = useState(false)
145
+ const [prevTimerInput, setPrevTimerInput] = useState<unknown>(toolInput)
146
+ if (prevTimerInput !== toolInput) {
147
+ setPrevTimerInput(toolInput)
148
+ if (assumedReady) setAssumedReady(false)
149
+ }
150
+ useEffect(() => {
151
+ if (resultReady || toolName === undefined) return
152
+ const timer = setTimeout(() => setAssumedReady(true), assumeStrippedAfterMs)
153
+ return () => clearTimeout(timer)
154
+ }, [resultReady, toolName, toolInput, assumeStrippedAfterMs])
155
+
156
+ const ready = resultReady || assumedReady
157
+
102
158
  useEffect(() => {
103
159
  // A new tool run (result no longer ready, or fresh tool-input identity)
104
160
  // invalidates any previous recovery attempt and re-arms the single-flight.
105
- if (flightRef.current && (!resultReady || flightInputRef.current !== toolInput)) {
161
+ if (flightRef.current && (!ready || flightInputRef.current !== toolInput)) {
106
162
  flightRef.current = null
107
163
  setState((prev) => (prev.data === null && !prev.failed ? prev : IDLE))
108
164
  }
109
165
  // Never fire on conforming hosts (valid payload) or before the
110
166
  // originating tool is known.
111
- if (!resultReady || isValid(props) || toolName === undefined) return
167
+ if (!ready || isValid(props) || toolName === undefined) return
112
168
 
113
169
  if (!flightRef.current) {
114
170
  flightInputRef.current = toolInput
@@ -138,7 +194,7 @@ export function useToolResultRecovery<T>(
138
194
  return () => {
139
195
  stale = true
140
196
  }
141
- }, [resultReady, props, isValid, toolName, toolInput, callTool])
197
+ }, [ready, props, isValid, toolName, toolInput, callTool])
142
198
 
143
199
  return state
144
200
  }
package/src/index.ts CHANGED
@@ -126,7 +126,7 @@ export type { LocaleContextValue, LocaleProviderProps, BoundTranslate } from "./
126
126
  // NOTE: MCP App shell (McpAppView, WidgetRenderer) is exported from the
127
127
  // `./app` subpath only. Keeping it out of the main barrel prevents consumers
128
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.
129
+ // — the 2.x view runtime and its ext-apps transitive — into their Vite bundle.
130
130
 
131
131
  // Utils
132
132
  export { cn } from "./lib/utils.js"