@ggui-ai/mcp-apps-react-native 0.0.1-placeholder → 0.9.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.
@@ -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
+ );