@ggui-ai/mcp-apps-react-native 0.0.1-placeholder → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +58 -4
- package/package.json +84 -4
- package/src/McpAppIframe/McpAppIframe.tsx +368 -0
- package/src/McpAppIframe/dispatch.ts +418 -0
- package/src/McpAppIframe/index.ts +15 -0
- package/src/McpAppIframe/types.ts +227 -0
- package/src/chat-helpers/index.ts +12 -0
- package/src/chat-helpers/message-groups.ts +124 -0
- package/src/chat-helpers/render.ts +65 -0
- package/src/chat-helpers/useRafThrottled.ts +34 -0
- package/src/components/ErrorBoundary.tsx +96 -0
- package/src/components/GguiProvider.tsx +188 -0
- package/src/components/UiFeedback.tsx +233 -0
- package/src/components/mcp-apps-bridge.ts +388 -0
- package/src/context/GguiContext.ts +121 -0
- package/src/hooks/useAppState.ts +24 -0
- package/src/index.ts +150 -0
- package/src/invoke/index.ts +8 -0
- package/src/invoke/sse-parse.ts +79 -0
- package/src/invoke/useInvoke.ts +502 -0
- package/src/test-setup.ts +85 -0
- package/src/theme/ThemeProvider.tsx +129 -0
- package/src/theme/index.ts +29 -0
- package/src/theme/tokens.ts +201 -0
- package/src/theme/types.ts +94 -0
- package/src/types/react-test-renderer.d.ts +50 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<McpAppIframe>` — the generic MCP Apps host for React Native.
|
|
3
|
+
*
|
|
4
|
+
* Parity target is the MCP Apps host contract implemented on web by
|
|
5
|
+
* `<AppRenderer>` from `@mcp-ui/client` — NOT a web `<McpAppIframe>`
|
|
6
|
+
* (the web side retired that component; see `./types.ts` for the
|
|
7
|
+
* history). `@mcp-ui/client`'s two-iframe sandbox-proxy architecture
|
|
8
|
+
* has no WebView equivalent, so on RN this component IS the
|
|
9
|
+
* spec-canonical host primitive. The platform-specific parts are:
|
|
10
|
+
*
|
|
11
|
+
* - Underlying element: `react-native-webview` WebView (not iframe).
|
|
12
|
+
* - Page → host bridge: `ReactNativeWebView.postMessage` forwarded
|
|
13
|
+
* via an injected-before-content-loaded bridge script (wraps each
|
|
14
|
+
* page postMessage in the `__ggui_mcp_apps` envelope). Reuses the
|
|
15
|
+
* existing `buildInjectedBridgeScript` + `buildDeliveryScript`
|
|
16
|
+
* helpers from the sibling `components/mcp-apps-bridge`.
|
|
17
|
+
* - Host → page delivery: `WebView.injectJavaScript` synthesising
|
|
18
|
+
* MessageEvents on `window`.
|
|
19
|
+
* - `ui/open-link` delegates to `Linking.openURL`.
|
|
20
|
+
*
|
|
21
|
+
* Host obligations — ENFORCED here (parity with the web host):
|
|
22
|
+
*
|
|
23
|
+
* 1. Mount WebView with `source` derived from {@link
|
|
24
|
+
* McpAppIframeProps.resource}.
|
|
25
|
+
* 2. Respond to `ui/initialize` with `{theme, containerDimensions,
|
|
26
|
+
* locale}` ONLY. NO outer-app state leaks.
|
|
27
|
+
* 3. Respond to `ping`, `ui/open-link` (http(s) only), `tools/call`
|
|
28
|
+
* (forwards to `onToolCall`), default `method_not_supported`.
|
|
29
|
+
* 4. Send `ui/resource-teardown` from the effect cleanup BEFORE the
|
|
30
|
+
* WebView element unmounts.
|
|
31
|
+
* 5. Surface renderer-emitted typed postMessage events to the
|
|
32
|
+
* matching callback.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import React, {
|
|
36
|
+
forwardRef,
|
|
37
|
+
useCallback,
|
|
38
|
+
useEffect,
|
|
39
|
+
useImperativeHandle,
|
|
40
|
+
useMemo,
|
|
41
|
+
useRef,
|
|
42
|
+
useState,
|
|
43
|
+
} from 'react';
|
|
44
|
+
import { Linking, View } from 'react-native';
|
|
45
|
+
import WebView, {
|
|
46
|
+
type WebView as WebViewType,
|
|
47
|
+
type WebViewMessageEvent,
|
|
48
|
+
} from 'react-native-webview';
|
|
49
|
+
import {
|
|
50
|
+
fromBootstrapFailure,
|
|
51
|
+
type ObservabilityMessage,
|
|
52
|
+
type RendererBootFailedMessage,
|
|
53
|
+
} from '@ggui-ai/iframe-runtime';
|
|
54
|
+
import {
|
|
55
|
+
isMcpAppLifecycleMessage,
|
|
56
|
+
type McpAppLifecycleMessage,
|
|
57
|
+
type McpAppLifecycleState,
|
|
58
|
+
} from '@ggui-ai/protocol/integrations/mcp-apps';
|
|
59
|
+
import {
|
|
60
|
+
buildDeliveryScript,
|
|
61
|
+
buildInjectedBridgeScript,
|
|
62
|
+
NATIVE_BRIDGE_ENVELOPE_KEY,
|
|
63
|
+
} from '../components/mcp-apps-bridge';
|
|
64
|
+
import {
|
|
65
|
+
buildDispatchActionNotification,
|
|
66
|
+
buildResourceTeardownNotification,
|
|
67
|
+
buildToolResultNotification,
|
|
68
|
+
classifyRendererEnvelope,
|
|
69
|
+
deriveResourceMountSource,
|
|
70
|
+
dispatchHostBridgeRequest,
|
|
71
|
+
type HostBridgeContext,
|
|
72
|
+
type HostBridgeRequest,
|
|
73
|
+
type HostBridgeResponse,
|
|
74
|
+
type HostBridgeNotification,
|
|
75
|
+
} from './dispatch.js';
|
|
76
|
+
import type {
|
|
77
|
+
McpAppIframeDimensions,
|
|
78
|
+
McpAppIframeProps,
|
|
79
|
+
McpAppIframeRef,
|
|
80
|
+
} from './types.js';
|
|
81
|
+
|
|
82
|
+
function resolveContainerDimensions(
|
|
83
|
+
dims: McpAppIframeDimensions | undefined,
|
|
84
|
+
): McpAppIframeDimensions {
|
|
85
|
+
return dims ?? {};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveLocale(locale: string | undefined): string {
|
|
89
|
+
if (typeof locale === 'string' && locale.length > 0) return locale;
|
|
90
|
+
return 'en-US';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function openLinkNative(url: string): Promise<void> {
|
|
94
|
+
await Linking.openURL(url);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const McpAppIframe = forwardRef<McpAppIframeRef, McpAppIframeProps>(
|
|
98
|
+
function McpAppIframe(
|
|
99
|
+
{
|
|
100
|
+
resource,
|
|
101
|
+
locale,
|
|
102
|
+
containerDimensions,
|
|
103
|
+
permissions,
|
|
104
|
+
meta,
|
|
105
|
+
onToolCall,
|
|
106
|
+
onUpdateModelContext,
|
|
107
|
+
onError,
|
|
108
|
+
onObserve,
|
|
109
|
+
onLifecycle,
|
|
110
|
+
},
|
|
111
|
+
ref,
|
|
112
|
+
) {
|
|
113
|
+
const webViewRef = useRef<WebViewType | null>(null);
|
|
114
|
+
// Outer-View mirror of the most recent lifecycle state the renderer
|
|
115
|
+
// posted. `null` (default) renders no `accessibilityValue` —
|
|
116
|
+
// observers waiting on the value distinguish "renderer hasn't
|
|
117
|
+
// posted yet" from any classified state. Locked in
|
|
118
|
+
// `@ggui-ai/protocol/integrations/mcp-apps` — host MUST mirror the
|
|
119
|
+
// latest received state per the protocol bar's named-obligations
|
|
120
|
+
// criterion. RN equivalent of the web `data-ggui-mcp-app-iframe-
|
|
121
|
+
// lifecycle` outer-DOM attribute.
|
|
122
|
+
const [lifecycleState, setLifecycleState] =
|
|
123
|
+
useState<McpAppLifecycleState | null>(null);
|
|
124
|
+
const mountSource = useMemo(() => deriveResourceMountSource(resource), [resource]);
|
|
125
|
+
|
|
126
|
+
const ctxRef = useRef<HostBridgeContext>({
|
|
127
|
+
locale: resolveLocale(locale),
|
|
128
|
+
containerDimensions: resolveContainerDimensions(containerDimensions),
|
|
129
|
+
openLink: openLinkNative,
|
|
130
|
+
onToolCall,
|
|
131
|
+
onUpdateModelContext,
|
|
132
|
+
});
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
ctxRef.current = {
|
|
135
|
+
locale: resolveLocale(locale),
|
|
136
|
+
containerDimensions: resolveContainerDimensions(containerDimensions),
|
|
137
|
+
openLink: openLinkNative,
|
|
138
|
+
onToolCall,
|
|
139
|
+
onUpdateModelContext,
|
|
140
|
+
};
|
|
141
|
+
}, [locale, containerDimensions, onToolCall, onUpdateModelContext]);
|
|
142
|
+
|
|
143
|
+
// Track the current `meta` separately from `ctxRef`. `meta` no
|
|
144
|
+
// longer rides on `ui/initialize` (Reading-B retired); the host
|
|
145
|
+
// now delivers it via the spec-canonical
|
|
146
|
+
// `ui/notifications/tool-result` notification fired immediately
|
|
147
|
+
// after the initialize response. Stored in a ref so the
|
|
148
|
+
// initialize-branch dispatch can read the latest value without
|
|
149
|
+
// forcing a re-mount of the listener closure.
|
|
150
|
+
const metaRef = useRef<typeof meta>(meta);
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
metaRef.current = meta;
|
|
153
|
+
}, [meta]);
|
|
154
|
+
|
|
155
|
+
// Delivery helper — host → WebView. Reuses the existing synthesis
|
|
156
|
+
// pattern: escape payload through JSON.parse(JSON.stringify(...))
|
|
157
|
+
// + dispatch a `message` event on window with `source =
|
|
158
|
+
// window.parent`.
|
|
159
|
+
const deliverToWebView = useCallback(
|
|
160
|
+
(message: HostBridgeResponse | HostBridgeNotification) => {
|
|
161
|
+
webViewRef.current?.injectJavaScript(buildDeliveryScript(message));
|
|
162
|
+
},
|
|
163
|
+
[],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
useImperativeHandle(
|
|
167
|
+
ref,
|
|
168
|
+
() => ({
|
|
169
|
+
dispatchAction(name: string, data: unknown): void {
|
|
170
|
+
deliverToWebView(buildDispatchActionNotification(name, data));
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
[deliverToWebView],
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// Failure-path classification handlers — delegate to the matching
|
|
177
|
+
// caller callback. Kept inline for parity with the web host's
|
|
178
|
+
// listener switch.
|
|
179
|
+
const handleMessage = useCallback(
|
|
180
|
+
async (event: WebViewMessageEvent): Promise<void> => {
|
|
181
|
+
const raw = event.nativeEvent.data;
|
|
182
|
+
let parsed: unknown;
|
|
183
|
+
try {
|
|
184
|
+
parsed = JSON.parse(raw);
|
|
185
|
+
} catch {
|
|
186
|
+
// Non-JSON payloads come from other messaging paths the
|
|
187
|
+
// page might use (e.g., `console.log` forwarding). Drop
|
|
188
|
+
// them — they're not bridge traffic.
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (parsed === null || typeof parsed !== 'object') return;
|
|
192
|
+
const envelope = parsed as Record<string, unknown>;
|
|
193
|
+
// The injected bridge wraps every `window.postMessage` call in
|
|
194
|
+
// `{__ggui_mcp_apps: true, payload: <original>}`. Unwrap
|
|
195
|
+
// here; raw payloads outside the envelope (legacy / non-bridge
|
|
196
|
+
// messages) are dropped.
|
|
197
|
+
if (envelope[NATIVE_BRIDGE_ENVELOPE_KEY] !== true) return;
|
|
198
|
+
const payload = envelope.payload;
|
|
199
|
+
|
|
200
|
+
const tag = classifyRendererEnvelope(payload);
|
|
201
|
+
switch (tag) {
|
|
202
|
+
case 'bootstrap-failed': {
|
|
203
|
+
const msg = payload as RendererBootFailedMessage;
|
|
204
|
+
onError?.(fromBootstrapFailure(msg.reason, msg.message));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
case 'observability': {
|
|
208
|
+
const env = payload as ObservabilityMessage;
|
|
209
|
+
onObserve?.(env.event);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
case 'lifecycle': {
|
|
213
|
+
// Re-validate the envelope shape via the protocol's type
|
|
214
|
+
// guard — `classifyRendererEnvelope` only matched on
|
|
215
|
+
// `type === 'ggui:lifecycle'`, but a host MUST not trust
|
|
216
|
+
// `event.state` without confirming it's a known
|
|
217
|
+
// `McpAppLifecycleState`. A malformed envelope (unknown
|
|
218
|
+
// state, empty sessionId, malformed error) silently
|
|
219
|
+
// skips the mirror — the legacy attribute stays at its
|
|
220
|
+
// previous value, observers see the protocol violation
|
|
221
|
+
// as a stuck attribute.
|
|
222
|
+
if (!isMcpAppLifecycleMessage(payload)) return;
|
|
223
|
+
const env = payload as McpAppLifecycleMessage;
|
|
224
|
+
setLifecycleState(env.event.state);
|
|
225
|
+
onLifecycle?.(env.event);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
case 'jsonrpc': {
|
|
229
|
+
const req = payload as HostBridgeRequest;
|
|
230
|
+
const response = await dispatchHostBridgeRequest(req, ctxRef.current);
|
|
231
|
+
if (response) deliverToWebView(response);
|
|
232
|
+
// Spec-canonical render-meta delivery (Reading-B retired).
|
|
233
|
+
// When the renderer just completed its `ui/initialize`
|
|
234
|
+
// handshake AND the host was given a `meta` prop, fire the
|
|
235
|
+
// `ui/notifications/tool-result` notification right after
|
|
236
|
+
// the initialize response so the renderer's pre-handshake
|
|
237
|
+
// `awaitToolResultMeta` listener (Tier 2 of
|
|
238
|
+
// `bootSequence`) catches the slice. The renderer
|
|
239
|
+
// registers that listener BEFORE calling
|
|
240
|
+
// `app.connect(transport)`, so a notification sent
|
|
241
|
+
// immediately after we resolve `ui/initialize` arrives
|
|
242
|
+
// strictly after the listener is in place — no race.
|
|
243
|
+
//
|
|
244
|
+
// Filter: only fire on `ui/initialize` requests so a
|
|
245
|
+
// renderer that pings or re-issues an unrelated request
|
|
246
|
+
// doesn't re-trigger the delivery. (`meta` updates
|
|
247
|
+
// mid-mount are out of scope — the wire delivers a fresh
|
|
248
|
+
// tool-result on every `ggui_update` via the live channel.)
|
|
249
|
+
if (req.method === 'ui/initialize' && metaRef.current !== undefined) {
|
|
250
|
+
deliverToWebView(buildToolResultNotification(metaRef.current));
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
case 'unknown':
|
|
255
|
+
default:
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
[deliverToWebView, onError, onObserve, onLifecycle],
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// Mount-source null → caller gave an unmountable resource. Emit
|
|
263
|
+
// once per transition; refs keep the error surface idempotent
|
|
264
|
+
// across prop-identity changes.
|
|
265
|
+
const onErrorRef = useRef(onError);
|
|
266
|
+
const resourceUriRef = useRef(resource.uri);
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
onErrorRef.current = onError;
|
|
269
|
+
}, [onError]);
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
resourceUriRef.current = resource.uri;
|
|
272
|
+
}, [resource.uri]);
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (mountSource === null) {
|
|
275
|
+
onErrorRef.current?.(
|
|
276
|
+
fromBootstrapFailure(
|
|
277
|
+
'MALFORMED_BOOTSTRAP',
|
|
278
|
+
`McpAppIframe: resource uri '${resourceUriRef.current}' is not http(s) and has no inline text/blob content`,
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}, [mountSource]);
|
|
283
|
+
|
|
284
|
+
// Teardown handshake on unmount. Reuses the existing effect-
|
|
285
|
+
// cleanup pattern preserved from the original renderer — the WebView's
|
|
286
|
+
// JS context is still live during cleanup, so the synthesised
|
|
287
|
+
// MessageEvent reaches the embedded page.
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
const webView = webViewRef.current;
|
|
290
|
+
return () => {
|
|
291
|
+
webView?.injectJavaScript(
|
|
292
|
+
buildDeliveryScript(buildResourceTeardownNotification()),
|
|
293
|
+
);
|
|
294
|
+
};
|
|
295
|
+
}, []);
|
|
296
|
+
|
|
297
|
+
const injectedScript = useMemo(() => buildInjectedBridgeScript(), []);
|
|
298
|
+
const dims = resolveContainerDimensions(containerDimensions);
|
|
299
|
+
|
|
300
|
+
// Native WebView permission gating — media playback requires user
|
|
301
|
+
// gesture when no permission is granted, and pop-ups are disabled
|
|
302
|
+
// so embedded views cannot spawn new browser windows that bypass
|
|
303
|
+
// the `ui/open-link` validation path.
|
|
304
|
+
const mediaRequiresGesture =
|
|
305
|
+
permissions?.camera !== true && permissions?.microphone !== true;
|
|
306
|
+
|
|
307
|
+
if (mountSource === null) {
|
|
308
|
+
// Render an empty View so the caller still sees a slot; onError
|
|
309
|
+
// already fired the bootstrap-failed error.
|
|
310
|
+
return (
|
|
311
|
+
<View
|
|
312
|
+
testID="mcp-app-iframe-empty"
|
|
313
|
+
style={{
|
|
314
|
+
width: dims.width ?? '100%',
|
|
315
|
+
height: dims.height ?? 480,
|
|
316
|
+
...(dims.maxWidth !== undefined ? { maxWidth: dims.maxWidth } : {}),
|
|
317
|
+
...(dims.maxHeight !== undefined ? { maxHeight: dims.maxHeight } : {}),
|
|
318
|
+
borderWidth: 1,
|
|
319
|
+
borderColor: '#e5e5e5',
|
|
320
|
+
borderRadius: 8,
|
|
321
|
+
}}
|
|
322
|
+
/>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return (
|
|
327
|
+
<View
|
|
328
|
+
testID="mcp-app-iframe-host"
|
|
329
|
+
// Outer-View mirror of the renderer's lifecycle state. Set when
|
|
330
|
+
// the WebView child posts a `ggui:lifecycle` envelope; absent
|
|
331
|
+
// before the first envelope arrives. Observers (RN testing
|
|
332
|
+
// libraries, console inspectors) query `accessibilityValue.text`
|
|
333
|
+
// so they don't need to traverse the WebView boundary. RN
|
|
334
|
+
// equivalent of the web host's `data-ggui-mcp-app-iframe-
|
|
335
|
+
// lifecycle` data attribute. See `McpAppLifecycleMessage` in
|
|
336
|
+
// `@ggui-ai/protocol/integrations/mcp-apps`.
|
|
337
|
+
{...(lifecycleState !== null
|
|
338
|
+
? { accessibilityValue: { text: lifecycleState } }
|
|
339
|
+
: {})}
|
|
340
|
+
style={{
|
|
341
|
+
width: dims.width ?? '100%',
|
|
342
|
+
height: dims.height ?? 480,
|
|
343
|
+
...(dims.maxWidth !== undefined ? { maxWidth: dims.maxWidth } : {}),
|
|
344
|
+
...(dims.maxHeight !== undefined ? { maxHeight: dims.maxHeight } : {}),
|
|
345
|
+
borderWidth: 1,
|
|
346
|
+
borderColor: '#e5e5e5',
|
|
347
|
+
borderRadius: 8,
|
|
348
|
+
overflow: 'hidden',
|
|
349
|
+
}}
|
|
350
|
+
>
|
|
351
|
+
<WebView
|
|
352
|
+
ref={webViewRef}
|
|
353
|
+
testID="mcp-app-iframe-webview"
|
|
354
|
+
source={mountSource}
|
|
355
|
+
originWhitelist={['http://*', 'https://*']}
|
|
356
|
+
javaScriptEnabled={true}
|
|
357
|
+
domStorageEnabled={true}
|
|
358
|
+
setSupportMultipleWindows={false}
|
|
359
|
+
mediaPlaybackRequiresUserAction={mediaRequiresGesture}
|
|
360
|
+
injectedJavaScriptBeforeContentLoaded={injectedScript}
|
|
361
|
+
onMessage={(ev) => {
|
|
362
|
+
void handleMessage(ev);
|
|
363
|
+
}}
|
|
364
|
+
/>
|
|
365
|
+
</View>
|
|
366
|
+
);
|
|
367
|
+
},
|
|
368
|
+
);
|