@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.
- package/README.md +7 -7
- package/dist/app/adapt-data-widget.d.ts +26 -5
- package/dist/app/adapt-data-widget.d.ts.map +1 -1
- package/dist/app/adapt-data-widget.js +39 -5
- package/dist/app/adapt-data-widget.js.map +1 -1
- package/dist/app/host-bridge.d.ts +48 -42
- package/dist/app/host-bridge.d.ts.map +1 -1
- package/dist/app/host-bridge.js +28 -50
- package/dist/app/host-bridge.js.map +1 -1
- package/dist/app/index.d.ts +5 -4
- package/dist/app/index.d.ts.map +1 -1
- package/dist/app/index.js +9 -8
- package/dist/app/index.js.map +1 -1
- package/dist/app/mcp-app-view.d.ts.map +1 -1
- package/dist/app/mcp-app-view.js +37 -24
- package/dist/app/mcp-app-view.js.map +1 -1
- package/dist/app/mcp-toolkit-app.d.ts +21 -10
- package/dist/app/mcp-toolkit-app.d.ts.map +1 -1
- package/dist/app/mcp-toolkit-app.js +26 -12
- package/dist/app/mcp-toolkit-app.js.map +1 -1
- package/dist/app/mcp-use-host-bridge.d.ts +29 -0
- package/dist/app/mcp-use-host-bridge.d.ts.map +1 -0
- package/dist/app/mcp-use-host-bridge.js +120 -0
- package/dist/app/mcp-use-host-bridge.js.map +1 -0
- package/dist/app/use-host-actions.d.ts +6 -6
- package/dist/app/use-host-actions.js +6 -6
- package/dist/app/widget-fixture.d.ts +24 -21
- package/dist/app/widget-fixture.d.ts.map +1 -1
- package/dist/app/widget-fixture.js +27 -123
- package/dist/app/widget-fixture.js.map +1 -1
- package/dist/hooks/use-tool-result-recovery.d.ts +40 -15
- package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -1
- package/dist/hooks/use-tool-result-recovery.js +57 -4
- package/dist/hooks/use-tool-result-recovery.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +16 -8
- package/src/app/adapt-data-widget.tsx +50 -6
- package/src/app/host-bridge.test.ts +107 -0
- package/src/app/host-bridge.tsx +81 -78
- package/src/app/index.ts +15 -10
- package/src/app/mcp-app-view.tsx +41 -32
- package/src/app/mcp-toolkit-app.tsx +33 -16
- package/src/app/mcp-use-host-bridge.tsx +164 -0
- package/src/app/use-host-actions.tsx +6 -6
- package/src/app/widget-fixture.tsx +35 -156
- package/src/hooks/use-tool-result-recovery.ts +75 -19
- package/src/index.ts +1 -1
- 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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* `useHostBridge` directly in new portable
|
|
61
|
-
* its named affordances
|
|
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.
|
|
136
|
-
*
|
|
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
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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
|
|
266
|
-
* `
|
|
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
|
|
271
|
-
*
|
|
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
|
-
* -
|
|
317
|
-
*
|
|
318
|
-
* {@link FixtureCallToolRegistry} built from `tools`;
|
|
319
|
-
* `openExternal
|
|
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
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
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
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
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).
|
|
8
|
-
* mcp-use
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
* the
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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` (`
|
|
51
|
+
/** The tool arguments from `ui/notifications/tool-input` (`useToolContext().toolInput`). */
|
|
34
52
|
toolInput: Record<string, unknown> | null | undefined
|
|
35
|
-
/** Raw host context (`
|
|
53
|
+
/** Raw host context (`useHostContext().hostContext`); `toolInfo` names the originating tool. */
|
|
36
54
|
hostContext: Record<string, unknown> | null | undefined
|
|
37
|
-
/** The bridge's `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 {
|
|
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 && (!
|
|
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 (!
|
|
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
|
-
}, [
|
|
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
|
|
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"
|