@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
|
@@ -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 () => {
|
package/src/app/host-bridge.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { createContext, useContext,
|
|
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>)
|
|
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)
|
|
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)
|
|
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>()
|
|
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
|
|
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
|
|
58
|
-
* every `useHostBridge()` below
|
|
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
|
|
81
|
+
* The {@link HostBridge} for the current subtree — the nearest
|
|
82
|
+
* {@link HostBridgeProvider}'s bridge.
|
|
86
83
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* instead.
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
*
|
|
108
|
-
*
|
|
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
|
-
*
|
|
119
|
-
*
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
30
|
-
createMcpUseHostBridge,
|
|
29
|
+
toHostBridge,
|
|
31
30
|
createChatGptHostBridge,
|
|
32
31
|
createStandaloneHostBridge,
|
|
33
32
|
} from "./host-bridge.js"
|
|
34
|
-
export type {
|
|
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
|
|
38
|
-
//
|
|
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,
|
package/src/app/mcp-app-view.tsx
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { useState, useCallback } from "react"
|
|
2
|
-
import {
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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)
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
|
272
|
-
// `
|
|
273
|
-
// overflowY (which would clip our own content inside a
|
|
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
|
|
279
|
-
paddingRight: safeArea
|
|
280
|
-
paddingBottom: safeArea
|
|
281
|
-
paddingLeft: safeArea
|
|
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 {
|
|
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.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
<
|
|
22
|
-
<
|
|
23
|
-
|
|
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
|
+
}
|