@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
@@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"
2
2
  import {
3
3
  createChatGptHostBridge,
4
4
  createStandaloneHostBridge,
5
+ toHostBridge,
6
+ type McpUseWidgetSurface,
5
7
  type OpenAiAppsSdk,
6
8
  } from "./host-bridge.js"
7
9
 
@@ -9,6 +11,111 @@ afterEach(() => {
9
11
  vi.restoreAllMocks()
10
12
  })
11
13
 
14
+ /** A widget surface whose verbs all succeed; override per test. */
15
+ function surface(overrides: Partial<McpUseWidgetSurface> = {}): McpUseWidgetSurface {
16
+ return {
17
+ callTool: vi.fn().mockResolvedValue({ structuredContent: { ok: true } }),
18
+ sendFollowUpMessage: vi.fn().mockResolvedValue(undefined),
19
+ openExternal: vi.fn(),
20
+ setState: vi.fn().mockResolvedValue(undefined),
21
+ ...overrides,
22
+ }
23
+ }
24
+
25
+ /**
26
+ * The `mcp-use` adapter is the one bridge whose upstream shape is not ours, so
27
+ * these pin the mapping itself: every verb's fail-soft promise, and the exact
28
+ * wire shape of the model-context write. A widget renders through this — none
29
+ * of it may start throwing, and `__model_context` is what the host reads.
30
+ */
31
+ describe("toHostBridge — mcp-use widget surface mapping", () => {
32
+ it("passes callTool through untouched and resolves with the raw tool response", async () => {
33
+ const callTool = vi.fn().mockResolvedValue({ structuredContent: { rows: 3 } })
34
+ const bridge = toHostBridge(surface({ callTool }))
35
+
36
+ await expect(bridge.callTool("list_tasks", { limit: 10 })).resolves.toEqual({
37
+ structuredContent: { rows: 3 },
38
+ })
39
+ expect(callTool).toHaveBeenCalledWith("list_tasks", { limit: 10 })
40
+ })
41
+
42
+ it("swallows a rejected sendFollowUpMessage with a warning instead of an unhandled rejection", async () => {
43
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
44
+ const sendFollowUpMessage = vi.fn().mockRejectedValue(new Error("no conversation"))
45
+ const bridge = toHostBridge(surface({ sendFollowUpMessage }))
46
+
47
+ expect(() => bridge.sendFollowup("show me more")).not.toThrow()
48
+ await vi.waitFor(() => {
49
+ expect(warn).toHaveBeenCalledWith(
50
+ "[host-bridge] sendFollowUpMessage failed:",
51
+ expect.any(Error),
52
+ )
53
+ })
54
+ })
55
+
56
+ it("falls back to window.open when the host's openExternal throws", () => {
57
+ const openExternal = vi.fn(() => {
58
+ throw new Error("no host bridge")
59
+ })
60
+ const open = vi.fn()
61
+ vi.stubGlobal("window", { open })
62
+
63
+ const bridge = toHostBridge(surface({ openExternal }))
64
+ expect(() => bridge.openExternal("https://example.test")).not.toThrow()
65
+ expect(open).toHaveBeenCalledWith("https://example.test", "_blank", "noopener")
66
+
67
+ vi.unstubAllGlobals()
68
+ })
69
+
70
+ it("does not reach for window.open when the host handles the link", () => {
71
+ const open = vi.fn()
72
+ vi.stubGlobal("window", { open })
73
+
74
+ const bridge = toHostBridge(surface())
75
+ bridge.openExternal("https://example.test")
76
+ expect(open).not.toHaveBeenCalled()
77
+
78
+ vi.unstubAllGlobals()
79
+ })
80
+
81
+ it("returns the host-pushed output from getWidgetData, and null when there is none", () => {
82
+ expect(toHostBridge(surface({ output: { total: 7 } })).getWidgetData()).toEqual({ total: 7 })
83
+ expect(toHostBridge(surface({ output: undefined })).getWidgetData()).toBeNull()
84
+ // A host that pushes an explicit null must not surface as undefined.
85
+ expect(toHostBridge(surface({ output: null })).getWidgetData()).toBeNull()
86
+ })
87
+
88
+ it("writes model context as setState({ __model_context }) — the key the host reads", () => {
89
+ const setState = vi.fn().mockResolvedValue(undefined)
90
+ const bridge = toHostBridge(surface({ setState }))
91
+
92
+ bridge.setModelContext?.("selected invoice 42")
93
+ expect(setState).toHaveBeenCalledWith({ __model_context: "selected invoice 42" })
94
+ })
95
+
96
+ it("swallows a rejected setState with a warning", async () => {
97
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
98
+ const setState = vi.fn().mockRejectedValue(new Error("state unavailable"))
99
+ const bridge = toHostBridge(surface({ setState }))
100
+
101
+ expect(() => bridge.setModelContext?.("x")).not.toThrow()
102
+ await vi.waitFor(() => {
103
+ expect(warn).toHaveBeenCalledWith(
104
+ "[host-bridge] setState (model context) failed:",
105
+ expect.any(Error),
106
+ )
107
+ })
108
+ })
109
+
110
+ it("normalizes the theme: only an exact 'dark' is dark, everything else is light", () => {
111
+ expect(toHostBridge(surface({ theme: "dark" })).theme).toBe("dark")
112
+ expect(toHostBridge(surface({ theme: "light" })).theme).toBe("light")
113
+ // Unknown or absent host spellings must not leak through as-is.
114
+ expect(toHostBridge(surface({ theme: undefined })).theme).toBe("light")
115
+ expect(toHostBridge(surface({ theme: "Dark" })).theme).toBe("light")
116
+ })
117
+ })
118
+
12
119
  describe("createChatGptHostBridge — no window.openai (defensive guards)", () => {
13
120
  // Passing `null` exercises the fully-absent SDK path without touching window.
14
121
  it("rejects callTool with a clear error instead of throwing synchronously", async () => {
@@ -1,5 +1,4 @@
1
- import { createContext, useContext, useMemo, type ReactNode } from "react"
2
- import { useWidget } from "mcp-use/react"
1
+ import { createContext, useContext, type ReactNode } from "react"
3
2
 
4
3
  /**
5
4
  * The host capabilities a hand-built widget needs, abstracted away from any one
@@ -20,31 +19,31 @@ export interface HostBridge {
20
19
  * with `parseToolResult`. Rejects if the call fails so a widget's own error
21
20
  * state (TanStack Query, try/catch) can surface it.
22
21
  */
23
- callTool(name: string, args: Record<string, unknown>): Promise<unknown>
22
+ callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>
24
23
  /**
25
24
  * Hand a natural-language prompt back to the conversation to drive a new model
26
25
  * turn (navigate to another widget, ask the agent to analyse something). In
27
26
  * hosts without a conversation (standalone web app) this is a no-op unless an
28
27
  * `onFollowup` handler is supplied.
29
28
  */
30
- sendFollowup(prompt: string): void
29
+ sendFollowup: (prompt: string) => void
31
30
  /**
32
31
  * Ask the host to open a URL outside the (often sandboxed) widget frame.
33
32
  * Falls back to `window.open` only where no host bridge mediates links.
34
33
  */
35
- openExternal(url: string): void
34
+ openExternal: (url: string) => void
36
35
  /**
37
36
  * The structured data this widget was rendered with, if the host pushed any
38
37
  * (the tool result's `structuredContent` / `toolOutput`). Returns `null` when
39
38
  * the host delivers data another way (e.g. props) or has none.
40
39
  */
41
- getWidgetData<T>(): T | null
40
+ getWidgetData: <T>() => T | null
42
41
  /**
43
42
  * Persist a short text the model should see on the next turn (selection,
44
43
  * filter, current view). Optional: not every host exposes model context, and
45
44
  * a standalone web app has no model at all — callers must guard its absence.
46
45
  */
47
- setModelContext?(text: string): void
46
+ setModelContext?: (text: string) => void
48
47
  /** The host's colour-scheme preference, when it reports one. */
49
48
  theme?: "light" | "dark"
50
49
  }
@@ -54,12 +53,9 @@ const HostBridgeContext = createContext<HostBridge | null>(null)
54
53
  /**
55
54
  * Provide an explicit {@link HostBridge} to a subtree. Wrap a portable widget in
56
55
  * the adapter for the host it is running in — {@link createChatGptHostBridge},
57
- * {@link createStandaloneHostBridge}, or {@link createMcpUseHostBridge} — and
58
- * every `useHostBridge()` below resolves to it.
59
- *
60
- * Omitting the provider is supported: {@link useHostBridge} (and, for backward
61
- * compatibility, `useHostActions`) then fall back to the `mcp-use`/react default
62
- * bridge so existing widgets keep working without any wrapping.
56
+ * {@link createStandaloneHostBridge}, or (inside the toolkit's own mcp-use
57
+ * view) the `McpUseHostBridgeProvider` — and every `useHostBridge()` below
58
+ * resolves to it.
63
59
  */
64
60
  export function HostBridgeProvider({
65
61
  bridge,
@@ -82,86 +78,93 @@ export function useHostBridgeOrNull(): HostBridge | null {
82
78
  }
83
79
 
84
80
  /**
85
- * The {@link HostBridge} for the current subtree. Resolution order:
81
+ * The {@link HostBridge} for the current subtree — the nearest
82
+ * {@link HostBridgeProvider}'s bridge.
86
83
  *
87
- * 1. An explicit bridge from the nearest {@link HostBridgeProvider}.
88
- * 2. The default `mcp-use`/react bridge ({@link createMcpUseHostBridge}), so a
89
- * widget mounted in our own host needs no provider.
84
+ * A provider is always present in the supported mounts: the toolkit shell
85
+ * (`McpToolkitApp` via `McpUseHostBridgeProvider`) installs the `mcp-use`
86
+ * bridge for every widget it renders, the `WidgetFixtureHost` harness installs
87
+ * a standalone bridge, and ChatGPT / standalone apps wrap their root in
88
+ * {@link createChatGptHostBridge} / {@link createStandaloneHostBridge}.
90
89
  *
91
- * The `mcp-use` hooks the default bridge wraps must run under an `mcp-use`
92
- * provider/host context; outside one (e.g. a bare unit test render) wrap the
93
- * widget in a `HostBridgeProvider` with a {@link createStandaloneHostBridge}
94
- * instead. This hook never returns `null`: a portable widget can always assume a
95
- * bridge exists.
90
+ * Since mcp-use 2.x there is no provider-less fallback: the `mcp-use` hooks
91
+ * only work inside a `bootstrapView`-mounted view, so a bare render without a
92
+ * provider cannot silently degrade — this hook throws with a pointer to the
93
+ * adapters instead.
96
94
  */
97
95
  export function useHostBridge(): HostBridge {
98
96
  const explicit = useContext(HostBridgeContext)
99
- // Always call the default-bridge hook (Rules of Hooks): it's cheap and only
100
- // *used* when no explicit bridge is provided. The mcp-use hooks underneath are
101
- // host-context readers, safe to call even when their result goes unused.
102
- const fallback = useMcpUseHostBridge()
103
- return explicit ?? fallback
97
+ if (!explicit) {
98
+ throw new Error(
99
+ "[host-bridge] useHostBridge() found no HostBridgeProvider. Wrap the widget in " +
100
+ "McpToolkitApp / McpUseHostBridgeProvider (toolkit host), WidgetFixtureHost (dev), " +
101
+ "or a HostBridgeProvider with createChatGptHostBridge / createStandaloneHostBridge.",
102
+ )
103
+ }
104
+ return explicit
104
105
  }
105
106
 
106
107
  /**
107
- * Build a {@link HostBridge} backed by `mcp-use`/react — the default for widgets
108
- * running in the toolkit's own host. Wraps `useWidget()` (host actions,
109
- * `toolOutput`, `theme`) and `useCallTool` so it preserves exactly the behaviour
110
- * widgets had before the bridge abstraction:
111
- *
112
- * - `openExternal` routes through the host's `openExternal` and falls back to
113
- * `window.open` when no host bridge is wired (local dev preview) — mirroring
114
- * the original `useHostActions.openLink`.
115
- * - `sendFollowup` routes through `sendFollowUpMessage` and swallows rejections
116
- * with a `console.warn`, as `useHostActions` did.
108
+ * Exactly the slice of `mcp-use`'s widget surface {@link toHostBridge} reads —
109
+ * declared structurally rather than imported from `mcp-use/react`.
117
110
  *
118
- * Must be called inside an `mcp-use` host context (it calls `mcp-use` hooks).
119
- * Use {@link createMcpUseHostBridge} for the standalone factory variant.
111
+ * That indirection is the point: the mapping below is the behaviour widgets
112
+ * depend on, and pinning it against a local interface keeps it testable without
113
+ * a host, a DOM, or a React renderer. It also localises upstream churn — when
114
+ * the shape moves, this interface and the `McpUseHostBridgeProvider` (which
115
+ * composes the mcp-use 2.x view hooks into this surface) are what change, and
116
+ * {@link toHostBridge}'s tests still prove the bridge contract widgets see is
117
+ * unchanged. The 1.x `useWidget()` satisfied it directly; the 2.x composition
118
+ * lives in `mcp-use-host-bridge.tsx`.
120
119
  */
121
- export function useMcpUseHostBridge(): HostBridge {
122
- const widget = useWidget()
123
- const { callTool, sendFollowUpMessage, openExternal, output, theme, setState } = widget
124
-
125
- return useMemo<HostBridge>(
126
- () => ({
127
- callTool: (name, args) => callTool(name, args),
128
- sendFollowup: (prompt) => {
129
- void sendFollowUpMessage(prompt).catch((err: unknown) => {
130
- console.warn("[host-bridge] sendFollowUpMessage failed:", err)
131
- })
132
- },
133
- openExternal: (url) => {
134
- try {
135
- openExternal(url)
136
- } catch {
137
- // No host bridge (local dev preview) — fall back to window.open.
138
- if (typeof window !== "undefined") window.open(url, "_blank", "noopener")
139
- }
140
- },
141
- getWidgetData: <T,>() => (output as T | null) ?? null,
142
- setModelContext: (text) => {
143
- void setState({ __model_context: text }).catch((err: unknown) => {
144
- console.warn("[host-bridge] setState (model context) failed:", err)
145
- })
146
- },
147
- theme: theme === "dark" ? "dark" : "light",
148
- }),
149
- [callTool, sendFollowUpMessage, openExternal, output, theme, setState],
150
- )
120
+ export interface McpUseWidgetSurface {
121
+ /** Invoke a tool on the connected server. */
122
+ callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>
123
+ /** Hand a prompt back to the conversation. Rejects are swallowed by the mapping. */
124
+ sendFollowUpMessage: (prompt: string) => Promise<unknown>
125
+ /** Open a URL outside the widget frame. Throws when no host mediates links. */
126
+ openExternal: (url: string) => void
127
+ /** The tool output the host rendered this widget with, when it pushed one. */
128
+ output?: unknown
129
+ /** The host's reported colour scheme, in whatever spelling it uses. */
130
+ theme?: unknown
131
+ /** Persist widget state; the model-context write rides on this. */
132
+ setState: (state: Record<string, unknown>) => Promise<unknown>
151
133
  }
152
134
 
153
135
  /**
154
- * Factory form of {@link useMcpUseHostBridge} for symmetry with the other
155
- * adapters. Because the `mcp-use` bridge is hook-driven (it reads live host
156
- * context), the "factory" is really the hook itself — call it from a component.
136
+ * Map `mcp-use`'s widget surface onto the host-agnostic {@link HostBridge}.
157
137
  *
158
- * @example
159
- * function MyWidget() {
160
- * const bridge = createMcpUseHostBridge() // === useMcpUseHostBridge()
161
- * // …or rely on useHostBridge()'s default and skip the provider entirely.
162
- * }
138
+ * Pure and separately exported so the fail-soft behaviour is pinned by tests:
139
+ * every verb here has a documented failure mode that widgets rely on, and none
140
+ * of them may start throwing into widget render paths.
163
141
  */
164
- export const createMcpUseHostBridge = useMcpUseHostBridge
142
+ export function toHostBridge(widget: McpUseWidgetSurface): HostBridge {
143
+ const { callTool, sendFollowUpMessage, openExternal, output, theme, setState } = widget
144
+ return {
145
+ callTool: (name, args) => callTool(name, args),
146
+ sendFollowup: (prompt) => {
147
+ void sendFollowUpMessage(prompt).catch((err: unknown) => {
148
+ console.warn("[host-bridge] sendFollowUpMessage failed:", err)
149
+ })
150
+ },
151
+ openExternal: (url) => {
152
+ try {
153
+ openExternal(url)
154
+ } catch {
155
+ // No host bridge (local dev preview) — fall back to window.open.
156
+ if (typeof window !== "undefined") window.open(url, "_blank", "noopener")
157
+ }
158
+ },
159
+ getWidgetData: <T,>() => (output as T | null) ?? null,
160
+ setModelContext: (text) => {
161
+ void setState({ __model_context: text }).catch((err: unknown) => {
162
+ console.warn("[host-bridge] setState (model context) failed:", err)
163
+ })
164
+ },
165
+ theme: theme === "dark" ? "dark" : "light",
166
+ }
167
+ }
165
168
 
166
169
  /**
167
170
  * Minimal structural view of the OpenAI Apps SDK `window.openai` object this
package/src/app/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { McpAppView } from "./mcp-app-view.js"
2
2
  export type { McpAppViewProps, McpAppViewLabels } from "./mcp-app-view.js"
3
- export { McpToolkitApp } from "./mcp-toolkit-app.js"
3
+ export { McpToolkitApp, mountMcpToolkitApp } from "./mcp-toolkit-app.js"
4
4
  export {
5
5
  WidgetRenderer,
6
6
  WidgetErrorBoundary,
@@ -11,31 +11,36 @@ export type { WidgetRendererProps, WidgetComponent } from "./widget-renderer.js"
11
11
  export { LayoutBuilder } from "./layout-builder.js"
12
12
  export type { LayoutBuilderProps, LayoutBuilderLabels } from "./layout-builder.js"
13
13
 
14
- // App-level value modules that import `mcp-use/react` (ModelContext, useWidget)
15
- // — exported from this subpath only, never the root barrel.
16
- export { adaptDataWidget } from "./adapt-data-widget.js"
14
+ // App-level value modules that import `mcp-use/react` (ModelContext, the 2.x
15
+ // view hooks) — exported from this subpath only, never the root barrel.
16
+ export { adaptDataWidget, HostModelContext } from "./adapt-data-widget.js"
17
17
  export type { DescribeForModel } from "./adapt-data-widget.js"
18
18
  export { useHostActions, buildShowWidgetIntent } from "./use-host-actions.js"
19
19
  export type { HostActions } from "./use-host-actions.js"
20
20
 
21
21
  // Host-portability bridge: the seam that lets the same hand-built widget run in
22
22
  // our mcp-use host, ChatGPT (Apps SDK), or a standalone web app against an
23
- // existing MCP server. Lives under ./app because the default adapter imports
23
+ // existing MCP server. Lives under ./app because the mcp-use adapter imports
24
24
  // `mcp-use/react` — never the root barrel.
25
25
  export {
26
26
  HostBridgeProvider,
27
27
  useHostBridge,
28
28
  useHostBridgeOrNull,
29
- useMcpUseHostBridge,
30
- createMcpUseHostBridge,
29
+ toHostBridge,
31
30
  createChatGptHostBridge,
32
31
  createStandaloneHostBridge,
33
32
  } from "./host-bridge.js"
34
- export type { HostBridge, OpenAiAppsSdk, StandaloneHostBridgeOptions } from "./host-bridge.js"
33
+ export type {
34
+ HostBridge,
35
+ McpUseWidgetSurface,
36
+ OpenAiAppsSdk,
37
+ StandaloneHostBridgeOptions,
38
+ } from "./host-bridge.js"
39
+ export { McpUseHostBridgeProvider, useIsInsideMcpUseView } from "./mcp-use-host-bridge.js"
35
40
 
36
41
  // Storybook-style harness for developing widgets in isolation with fixture
37
- // data and a mocked host. Lives under ./app because it mocks the
38
- // `mcp-use/react` host bridge (window.openai) — never the root barrel.
42
+ // data and a simulated HostBridge. Lives under ./app to keep every host-facing
43
+ // surface on one subpath — never the root barrel.
39
44
  export {
40
45
  WidgetFixtureHost,
41
46
  FixtureCallToolRegistry,
@@ -1,11 +1,12 @@
1
1
  import { useState, useCallback } from "react"
2
- import { useWidget } from "mcp-use/react"
2
+ import { useDisplayMode, useHostContext, useToolContext } from "mcp-use/react"
3
3
  import { Maximize2, Minimize2, Pencil, RefreshCw } from "lucide-react"
4
4
  import type { LayoutConfig, PipelineStepRef } from "@miragon/mcp-toolkit-core"
5
5
  import { Skeleton } from "../primitives/skeleton.js"
6
6
  import { AppQueryProvider, queryClient } from "../providers/query-provider.js"
7
7
  import { useToolResultRecovery } from "../hooks/use-tool-result-recovery.js"
8
8
  import { parseToolResult } from "../lib/parse-tool-result.js"
9
+ import { useHostBridge } from "./host-bridge.js"
9
10
  import { LayoutBuilder } from "./layout-builder.js"
10
11
  import { WidgetRenderer, type WidgetComponent } from "./widget-renderer.js"
11
12
 
@@ -106,24 +107,24 @@ export function McpAppView({
106
107
  labels,
107
108
  }: McpAppViewProps) {
108
109
  const effectiveLabels = { ...DEFAULT_LABELS, ...labels }
109
- const {
110
- props: initialViewData,
111
- isPending,
112
- callTool,
113
- displayMode: currentDisplayMode,
114
- requestDisplayMode,
115
- safeArea,
116
- toolInput,
117
- hostContext,
118
- } = useWidget<ViewData>()
110
+ // Since mcp-use 2.x the shell reads the view channels through the dedicated
111
+ // hooks (all require the surrounding `bootstrapView` mount that
112
+ // `mountMcpToolkitApp` provides) and calls tools through the HostBridge that
113
+ // `McpUseHostBridgeProvider` installs.
114
+ const tool = useToolContext()
115
+ const { displayMode, requestDisplayMode } = useDisplayMode()
116
+ const { safeArea, hostContext } = useHostContext()
117
+ const bridge = useHostBridge()
118
+ const callTool = useCallback(
119
+ (name: string, args: Record<string, unknown>) => bridge.callTool(name, args),
120
+ [bridge],
121
+ )
122
+
123
+ const isPending = tool.status === "pending"
124
+ const initialViewData = tool.status === "ready" ? (tool.toolOutput as ViewData) : undefined
125
+ const toolInput = tool.toolInput
119
126
 
120
127
  const [viewData, setViewData] = useState<ViewData | null>(null)
121
- const [displayMode, setDisplayMode] = useState<string>(currentDisplayMode ?? "inline")
122
- const [prevCurrentDisplayMode, setPrevCurrentDisplayMode] = useState(currentDisplayMode)
123
- if (currentDisplayMode !== prevCurrentDisplayMode) {
124
- setPrevCurrentDisplayMode(currentDisplayMode)
125
- if (currentDisplayMode) setDisplayMode(currentDisplayMode)
126
- }
127
128
  const [isRefreshing, setIsRefreshing] = useState(false)
128
129
 
129
130
  // In-iframe build mode is purely a UI state — the LLM never sees the
@@ -152,10 +153,13 @@ export function McpAppView({
152
153
  }
153
154
 
154
155
  // Hosts that strip `structuredContent` from the tool-result notification
155
- // (claude.ai / Claude Desktop) leave `initialViewData` empty forever; the
156
- // recovery re-executes the originating tool once via `callTool` (responses
157
- // carry `structuredContent` intact) and fills the gap. Conforming hosts
158
- // deliver a valid payload up front, so this never fires there.
156
+ // (claude.ai / Claude Desktop) never latch a result under mcp-use 2.x — the
157
+ // `useToolContext` latch ignores content-only results, so `isPending` stays
158
+ // true forever. The recovery therefore also arms itself on a grace timer
159
+ // once the originating tool is known, re-executes it via `callTool`
160
+ // (responses carry `structuredContent` intact), and fills the gap.
161
+ // Conforming hosts deliver a valid payload up front, so it never fires
162
+ // there.
159
163
  const recovery = useToolResultRecovery<ViewData>({
160
164
  resultReady: !isPending,
161
165
  props: initialViewData,
@@ -174,8 +178,10 @@ export function McpAppView({
174
178
  const toggleFullscreen = useCallback(async () => {
175
179
  const newMode = displayMode === "fullscreen" ? "inline" : "fullscreen"
176
180
  try {
177
- const result = await requestDisplayMode(newMode)
178
- setDisplayMode(result.mode)
181
+ // Advisory since 2.x: the promise only confirms the host processed the
182
+ // request. `displayMode` from `useDisplayMode` updates reactively when
183
+ // (and only when) the host actually applies the change.
184
+ await requestDisplayMode({ mode: newMode })
179
185
  } catch (e) {
180
186
  console.error("Failed to toggle display mode:", e)
181
187
  }
@@ -245,7 +251,10 @@ export function McpAppView({
245
251
  [],
246
252
  )
247
253
 
248
- if (isPending || !viewData) {
254
+ // `isPending` must not gate the render: on hosts that strip
255
+ // `structuredContent` the 2.x latch stays "pending" forever, and recovered
256
+ // viewData still has to replace the skeleton.
257
+ if (!viewData) {
249
258
  return (
250
259
  <div className="flex flex-col gap-4 p-4">
251
260
  <Skeleton className="h-8 w-48" />
@@ -268,17 +277,17 @@ export function McpAppView({
268
277
  style={{
269
278
  // Fullscreen: the widget owns the viewport, so we take the full window
270
279
  // and handle our own scrolling. Inline: the host (Claude, ChatGPT) owns
271
- // sizing + scrolling and is notified of our intrinsic height via
272
- // `McpUseProvider`, so we let content dictate height and avoid setting
273
- // overflowY (which would clip our own content inside a host that's
274
- // already auto-sized to us).
280
+ // sizing + scrolling and observes our intrinsic height via
281
+ // `bootstrapView`'s auto-resize, so we let content dictate height and
282
+ // avoid setting overflowY (which would clip our own content inside a
283
+ // host that's already auto-sized to us).
275
284
  ...(displayMode === "fullscreen"
276
285
  ? { minHeight: "100vh", overflowY: "auto" as const }
277
286
  : null),
278
- paddingTop: safeArea?.insets?.top,
279
- paddingRight: safeArea?.insets?.right,
280
- paddingBottom: safeArea?.insets?.bottom,
281
- paddingLeft: safeArea?.insets?.left,
287
+ paddingTop: safeArea.top,
288
+ paddingRight: safeArea.right,
289
+ paddingBottom: safeArea.bottom,
290
+ paddingLeft: safeArea.left,
282
291
  }}
283
292
  >
284
293
  {showHeader && (
@@ -1,25 +1,42 @@
1
- import { McpUseProvider } from "mcp-use/react"
1
+ import { ThemeProvider, bootstrapView } from "mcp-use/react"
2
2
  import { McpAppView, type McpAppViewProps } from "./mcp-app-view.js"
3
+ import { McpUseHostBridgeProvider } from "./mcp-use-host-bridge.js"
3
4
 
4
5
  /**
5
- * Consumer-facing root for a toolkit-rendered MCP App. Wraps `McpAppView`
6
- * in `McpUseProvider` so the host (Claude, ChatGPT Apps SDK) receives
7
- * intrinsic-height notifications and the widget auto-sizes inside the
8
- * host's inline container.
6
+ * Consumer-facing root for a toolkit-rendered MCP App. Composes the mcp-use
7
+ * 2.x view plumbing around `McpAppView`: `ThemeProvider` applies the host's
8
+ * theme/style variables to the document, and `McpUseHostBridgeProvider`
9
+ * installs the `HostBridge` (and view-scope flag) every toolkit widget below
10
+ * resolves through `useHostBridge()`.
9
11
  *
10
- * Usage at the bundle entry point:
11
- *
12
- * ```tsx
13
- * createRoot(root).render(<McpToolkitApp widgets={widgets} />)
14
- * ```
15
- *
16
- * `McpUseProvider` also installs StrictMode, a default ErrorBoundary, and
17
- * the theme plumbing used by the toolkit primitives.
12
+ * Must render inside a `bootstrapView`-mounted tree — the mcp-use view hooks
13
+ * throw outside one. Use {@link mountMcpToolkitApp} at the bundle entry point,
14
+ * which wires exactly that; render `McpToolkitApp` directly only when
15
+ * composing a custom view under your own `bootstrapView` call.
18
16
  */
19
17
  export function McpToolkitApp(props: McpAppViewProps) {
20
18
  return (
21
- <McpUseProvider>
22
- <McpAppView {...props} />
23
- </McpUseProvider>
19
+ <ThemeProvider>
20
+ <McpUseHostBridgeProvider>
21
+ <McpAppView {...props} />
22
+ </McpUseHostBridgeProvider>
23
+ </ThemeProvider>
24
24
  )
25
25
  }
26
+
27
+ /**
28
+ * Mount the toolkit app as the document's MCP App view — the bundle entry
29
+ * point since mcp-use 2.x.
30
+ *
31
+ * `bootstrapView` owns the mount: it connects the ext-apps postMessage bridge
32
+ * to the host, installs an error boundary, and auto-reports size changes
33
+ * (replacing 1.x's `createRoot` + `McpUseProvider` pair).
34
+ *
35
+ * ```tsx
36
+ * // main.tsx of the widget bundle
37
+ * mountMcpToolkitApp({ widgets })
38
+ * ```
39
+ */
40
+ export function mountMcpToolkitApp(props: McpAppViewProps): void {
41
+ bootstrapView({ default: () => <McpToolkitApp {...props} /> })
42
+ }