@ggui-ai/mcp-apps-react-native 0.10.0 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/mcp-apps-react-native",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "React Native SDK for ggui — components and hooks for embedding agent-generated UIs in mobile apps.",
5
5
  "keywords": [
6
6
  "ggui",
@@ -58,9 +58,9 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@modelcontextprotocol/ext-apps": "^1.7.2",
61
- "@ggui-ai/design": "0.10.0",
62
- "@ggui-ai/protocol": "0.10.0",
63
- "@ggui-ai/iframe-runtime": "0.10.0"
61
+ "@ggui-ai/iframe-runtime": "0.12.0",
62
+ "@ggui-ai/protocol": "0.12.0",
63
+ "@ggui-ai/design": "0.12.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@modelcontextprotocol/sdk": "^1.27.0",
@@ -68,6 +68,7 @@ import {
68
68
  classifyRendererEnvelope,
69
69
  deriveResourceMountSource,
70
70
  dispatchHostBridgeRequest,
71
+ readSizeChangedNotification,
71
72
  type HostBridgeContext,
72
73
  type HostBridgeRequest,
73
74
  type HostBridgeResponse,
@@ -107,6 +108,7 @@ export const McpAppIframe = forwardRef<McpAppIframeRef, McpAppIframeProps>(
107
108
  onError,
108
109
  onObserve,
109
110
  onLifecycle,
111
+ onSizeChanged,
110
112
  },
111
113
  ref,
112
114
  ) {
@@ -226,6 +228,16 @@ export const McpAppIframe = forwardRef<McpAppIframeRef, McpAppIframeProps>(
226
228
  return;
227
229
  }
228
230
  case 'jsonrpc': {
231
+ // `ui/notifications/size-changed` (ggui#589 rider 5) — an
232
+ // id-less NOTIFICATION, so it never reaches the request
233
+ // dispatcher below (which returns null for notifications;
234
+ // before this branch the announce was dropped and content
235
+ // taller than the container clipped). Surface it and stop.
236
+ const sizeChange = readSizeChangedNotification(payload);
237
+ if (sizeChange !== null) {
238
+ onSizeChanged?.(sizeChange);
239
+ return;
240
+ }
229
241
  const req = payload as HostBridgeRequest;
230
242
  const response = await dispatchHostBridgeRequest(req, ctxRef.current);
231
243
  if (response) deliverToWebView(response);
@@ -256,7 +268,7 @@ export const McpAppIframe = forwardRef<McpAppIframeRef, McpAppIframeProps>(
256
268
  return;
257
269
  }
258
270
  },
259
- [deliverToWebView, onError, onObserve, onLifecycle],
271
+ [deliverToWebView, onError, onObserve, onLifecycle, onSizeChanged],
260
272
  );
261
273
 
262
274
  // Mount-source null → caller gave an unmountable resource. Emit
@@ -306,7 +318,11 @@ export const McpAppIframe = forwardRef<McpAppIframeRef, McpAppIframeProps>(
306
318
 
307
319
  if (mountSource === null) {
308
320
  // Render an empty View so the caller still sees a slot; onError
309
- // already fired the bootstrap-failed error.
321
+ // already fired the bootstrap-failed error. NO card chrome —
322
+ // the HOST owns borders/radius entirely (ggui#589 round 6: the
323
+ // hardcoded #e5e5e5 border ran at its own radius inside the
324
+ // embedder's rounded rim — the two-radii mismatch was the
325
+ // store-frame rejection).
310
326
  return (
311
327
  <View
312
328
  testID="mcp-app-iframe-empty"
@@ -315,9 +331,6 @@ export const McpAppIframe = forwardRef<McpAppIframeRef, McpAppIframeProps>(
315
331
  height: dims.height ?? 480,
316
332
  ...(dims.maxWidth !== undefined ? { maxWidth: dims.maxWidth } : {}),
317
333
  ...(dims.maxHeight !== undefined ? { maxHeight: dims.maxHeight } : {}),
318
- borderWidth: 1,
319
- borderColor: '#e5e5e5',
320
- borderRadius: 8,
321
334
  }}
322
335
  />
323
336
  );
@@ -337,14 +350,15 @@ export const McpAppIframe = forwardRef<McpAppIframeRef, McpAppIframeProps>(
337
350
  {...(lifecycleState !== null
338
351
  ? { accessibilityValue: { text: lifecycleState } }
339
352
  : {})}
353
+ // NO card chrome (ggui#589 round 6) — the HOST owns borders/
354
+ // radius/silhouette entirely, the same doctrine as the round-5
355
+ // frameless ruling one layer down. `overflow: 'hidden'` stays:
356
+ // it is containment (clip the WebView to the slot), not chrome.
340
357
  style={{
341
358
  width: dims.width ?? '100%',
342
359
  height: dims.height ?? 480,
343
360
  ...(dims.maxWidth !== undefined ? { maxWidth: dims.maxWidth } : {}),
344
361
  ...(dims.maxHeight !== undefined ? { maxHeight: dims.maxHeight } : {}),
345
- borderWidth: 1,
346
- borderColor: '#e5e5e5',
347
- borderRadius: 8,
348
362
  overflow: 'hidden',
349
363
  }}
350
364
  >
@@ -164,7 +164,12 @@ export async function dispatchHostBridgeRequest(
164
164
  // `define`), and a hand-maintained literal would silently drift
165
165
  // from the published version at every release.
166
166
  hostInfo: { name: 'ggui-react-native', version: 'unstamped' },
167
- hostCapabilities: {},
167
+ // Truthful positive advertisement (kit R2 grade, ggui#600 OPEN
168
+ // FINDING closed): a wired tools/call relay advertises
169
+ // `serverTools` so the runtime never spends a failed user
170
+ // gesture probing a capability the host actually has.
171
+ hostCapabilities:
172
+ ctx.onToolCall !== undefined ? { serverTools: {} } : {},
168
173
  hostContext: {
169
174
  locale: ctx.locale,
170
175
  containerDimensions: ctx.containerDimensions,
@@ -307,6 +312,43 @@ export function classifyRendererEnvelope(data: unknown): RendererEnvelopeTag {
307
312
  return 'unknown';
308
313
  }
309
314
 
315
+ /**
316
+ * Read a spec-canonical `ui/notifications/size-changed` NOTIFICATION
317
+ * (ggui#589 rider 5). The view sends it when its rendered content
318
+ * changes size (ext-apps `App.sendSizeChanged` / its autoResize
319
+ * observer); before this reader existed, the jsonrpc branch dropped
320
+ * every id-less message (`dispatchHostBridgeRequest` returns null for
321
+ * notifications) and content taller than the container clipped.
322
+ *
323
+ * Returns the validated `{width?, height?}` params, or `null` when the
324
+ * payload is not this notification OR carries nothing usable — each
325
+ * dimension must be a finite non-negative number to pass; a payload
326
+ * where NEITHER dimension survives is `null` (nothing to resize on).
327
+ * Pure function — testable without a WebView present.
328
+ */
329
+ export function readSizeChangedNotification(
330
+ data: unknown,
331
+ ): { width?: number; height?: number } | null {
332
+ if (data === null || typeof data !== 'object') return null;
333
+ const d = data as { jsonrpc?: unknown; method?: unknown; params?: unknown };
334
+ if (d.jsonrpc !== '2.0' || d.method !== 'ui/notifications/size-changed') {
335
+ return null;
336
+ }
337
+ if (d.params === null || typeof d.params !== 'object' || Array.isArray(d.params)) {
338
+ return null;
339
+ }
340
+ const params = d.params as { width?: unknown; height?: unknown };
341
+ const dim = (v: unknown): number | undefined =>
342
+ typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined;
343
+ const width = dim(params.width);
344
+ const height = dim(params.height);
345
+ if (width === undefined && height === undefined) return null;
346
+ return {
347
+ ...(width !== undefined ? { width } : {}),
348
+ ...(height !== undefined ? { height } : {}),
349
+ };
350
+ }
351
+
310
352
  export function buildDispatchActionNotification(
311
353
  name: string,
312
354
  data: unknown,
@@ -211,6 +211,26 @@ export interface McpAppIframeProps {
211
211
  * neither mirrors nor blocks).
212
212
  */
213
213
  readonly onLifecycle?: (event: McpAppLifecycleEvent) => void;
214
+
215
+ /**
216
+ * Fired when the view announces a content-size change via the
217
+ * spec-canonical `ui/notifications/size-changed` notification
218
+ * (ext-apps `App.sendSizeChanged` / its autoResize observer).
219
+ * Both dimensions are optional per the spec — the common autoResize
220
+ * case announces `height` only. Values are validated finite
221
+ * non-negative numbers before they reach this callback; malformed
222
+ * announcements are dropped silently (the same tolerant posture as
223
+ * every other envelope).
224
+ *
225
+ * The host does NOT resize itself: `containerDimensions` stays the
226
+ * embedder's declaration. Bind this to drive your own container
227
+ * sizing (autoResize) — without it, content taller than the
228
+ * container clips.
229
+ */
230
+ readonly onSizeChanged?: (size: {
231
+ readonly width?: number;
232
+ readonly height?: number;
233
+ }) => void;
214
234
  }
215
235
 
216
236
  /**
@@ -93,9 +93,33 @@ export interface ThemeProviderProps {
93
93
  children: ReactNode;
94
94
  }
95
95
 
96
+ /**
97
+ * TERMINAL CONSUMER of the theme-mode ladder (adversarial-cycle
98
+ * ruling, ggui#598 leg 4): the RN provider paints the NATIVE chrome
99
+ * around the WebView — there is no downstream layer left to fill an
100
+ * absent opinion, and React Native must render with SOME concrete
101
+ * scheme. The `?? 'light'` here is therefore the documented
102
+ * terminal-consumer default (the same posture as the design package's
103
+ * css-tokens terminal default), NOT a mid-ladder defaulting: it fires
104
+ * only after the embedder's explicit prop AND the OS scheme are both
105
+ * absent (`useColorScheme()` → null, rare). Mid-ladder code — anything
106
+ * with a layer below it — must never default; it delegates to
107
+ * `@ggui-ai/protocol/integrations/theme-binding` and lets absence
108
+ * propagate (the composition law).
109
+ *
110
+ * @internal — exported for the precedence pin; production caller is
111
+ * `ThemeProvider`.
112
+ */
113
+ export function resolveNativeScheme(
114
+ prop: 'light' | 'dark' | undefined,
115
+ system: 'light' | 'dark' | null | undefined,
116
+ ): 'light' | 'dark' {
117
+ return prop ?? system ?? 'light';
118
+ }
119
+
96
120
  export function ThemeProvider({ colorScheme: colorSchemeProp, children }: ThemeProviderProps) {
97
121
  const systemScheme = useColorScheme();
98
- const effectiveScheme = colorSchemeProp ?? systemScheme ?? 'light';
122
+ const effectiveScheme = resolveNativeScheme(colorSchemeProp, systemScheme);
99
123
  const [reduceMotionEnabled, setReduceMotionEnabled] = useState(false);
100
124
 
101
125
  useEffect(() => {