@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.
- 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,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UiFeedback — a small, dismissable rating affordance for the render
|
|
3
|
+
* shell chrome ("did this generated UI work for you?").
|
|
4
|
+
*
|
|
5
|
+
* Platform delta (vs `ggui-react/src/components/UiFeedback.tsx`):
|
|
6
|
+
* - DOM elements (`div` / `button` / `form` / `input`) become RN
|
|
7
|
+
* primitives (`View` / `Pressable` / `Text` / `TextInput`).
|
|
8
|
+
* - Inline styles with `--ggui-*` CSS-variable hooks become
|
|
9
|
+
* `StyleSheet.create` constants carrying the same neutral palette
|
|
10
|
+
* the web copy uses as its variable fallbacks (RN has no CSS
|
|
11
|
+
* variable layer).
|
|
12
|
+
* - `data-ggui-ui-feedback*` styling/test hooks become `testID`s
|
|
13
|
+
* (`ggui-ui-feedback`, `ggui-ui-feedback-verdict-<verdict>`, …).
|
|
14
|
+
* - The comment `<form>` submit becomes a Send `Pressable` plus the
|
|
15
|
+
* `TextInput`'s `onSubmitEditing` (keyboard return key).
|
|
16
|
+
* - `role` / `aria-label` become `accessibilityLabel` /
|
|
17
|
+
* `accessibilityRole` props.
|
|
18
|
+
* The exported surface is identical to the web copy — pinned by the
|
|
19
|
+
* twin-parity gate (`DOCUMENTED_DELTA_TWINS`).
|
|
20
|
+
*
|
|
21
|
+
* Three verdicts — Love / Dislike / Other (free-text comment) — plus a
|
|
22
|
+
* dismiss control. Entirely host-driven: the component renders NOTHING
|
|
23
|
+
* unless the host passes `onUiFeedback`, so deployments that don't
|
|
24
|
+
* collect feedback never show a dead affordance. Where the payload
|
|
25
|
+
* goes is the host's choice — a logger, an analytics client, a support
|
|
26
|
+
* inbox; this component only builds the typed {@link UiFeedbackPayload}
|
|
27
|
+
* and hands it over.
|
|
28
|
+
*
|
|
29
|
+
* Zero wire surface: feedback never crosses the agent ↔ UI contract.
|
|
30
|
+
* It is host-app chrome (Data vs Behavior — the agent cannot observe
|
|
31
|
+
* it from the wire, so it is not protocol data), which is why the
|
|
32
|
+
* payload leaves through a host callback rather than a contract spec.
|
|
33
|
+
*
|
|
34
|
+
* Two mount surfaces exist for this affordance (ggui#244):
|
|
35
|
+
*
|
|
36
|
+
* 1. THIS component — chrome in the host's own tree, next to a
|
|
37
|
+
* render surface the host controls; the payload leaves through
|
|
38
|
+
* `onUiFeedback`.
|
|
39
|
+
* 2. The in-iframe twin the `@ggui-ai/iframe-runtime` boot path
|
|
40
|
+
* mounts inside a served render iframe whenever a parent window
|
|
41
|
+
* exists (`window.parent !== window` — top-level tabs get
|
|
42
|
+
* neither surface); its payload leaves as a `ui-feedback`
|
|
43
|
+
* observability event on the `ggui:observe` postMessage seam,
|
|
44
|
+
* surfaced to hosts via `<McpAppIframe onObserve>`.
|
|
45
|
+
*
|
|
46
|
+
* Hosts wire exactly ONE surface — either pass `onUiFeedback` here
|
|
47
|
+
* and ignore the `ui-feedback` observe arm, or handle the observe arm
|
|
48
|
+
* and omit `onUiFeedback`. Wiring both shows the user two affordances
|
|
49
|
+
* for one render. The render-nothing-without-a-sink default makes the
|
|
50
|
+
* choice safe: omitting `onUiFeedback` fully disables this surface.
|
|
51
|
+
*/
|
|
52
|
+
import { useCallback, useState } from 'react';
|
|
53
|
+
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
54
|
+
|
|
55
|
+
/** The three feedback verdicts the affordance can emit. */
|
|
56
|
+
export type UiFeedbackVerdict = 'love' | 'dislike' | 'other';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Payload handed to {@link UiFeedbackProps.onUiFeedback}. Context
|
|
60
|
+
* fields are present exactly when the host supplied them as props;
|
|
61
|
+
* `comment` is present only for `verdict: 'other'` with a non-empty
|
|
62
|
+
* trimmed comment.
|
|
63
|
+
*/
|
|
64
|
+
export interface UiFeedbackPayload {
|
|
65
|
+
verdict: UiFeedbackVerdict;
|
|
66
|
+
comment?: string;
|
|
67
|
+
/** GguiSession id of the render the feedback is about. */
|
|
68
|
+
sessionId?: string;
|
|
69
|
+
/** Tool that produced the render (e.g. `ggui_render`). */
|
|
70
|
+
toolName?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface UiFeedbackProps {
|
|
74
|
+
/**
|
|
75
|
+
* Feedback sink. Absent = the affordance renders nothing at all —
|
|
76
|
+
* zero-config hosts never show a dead control.
|
|
77
|
+
*/
|
|
78
|
+
onUiFeedback?: (feedback: UiFeedbackPayload) => void;
|
|
79
|
+
/** GguiSession id to stamp onto every emitted payload. */
|
|
80
|
+
sessionId?: string;
|
|
81
|
+
/** Producing tool name to stamp onto every emitted payload. */
|
|
82
|
+
toolName?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type Phase = 'idle' | 'comment' | 'sent' | 'dismissed';
|
|
86
|
+
|
|
87
|
+
export function UiFeedback({ onUiFeedback, sessionId, toolName }: UiFeedbackProps) {
|
|
88
|
+
const [phase, setPhase] = useState<Phase>('idle');
|
|
89
|
+
const [comment, setComment] = useState('');
|
|
90
|
+
|
|
91
|
+
const emit = useCallback(
|
|
92
|
+
(verdict: UiFeedbackVerdict, commentText?: string) => {
|
|
93
|
+
if (!onUiFeedback) return;
|
|
94
|
+
const trimmed = commentText?.trim();
|
|
95
|
+
onUiFeedback({
|
|
96
|
+
verdict,
|
|
97
|
+
...(trimmed !== undefined && trimmed.length > 0 ? { comment: trimmed } : {}),
|
|
98
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
99
|
+
...(toolName !== undefined ? { toolName } : {}),
|
|
100
|
+
});
|
|
101
|
+
setPhase('sent');
|
|
102
|
+
},
|
|
103
|
+
[onUiFeedback, sessionId, toolName],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const onCommentSubmit = useCallback(() => {
|
|
107
|
+
emit('other', comment);
|
|
108
|
+
}, [emit, comment]);
|
|
109
|
+
|
|
110
|
+
// Hidden entirely: no sink wired, or the user dismissed it.
|
|
111
|
+
if (!onUiFeedback || phase === 'dismissed') return null;
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<View
|
|
115
|
+
testID="ggui-ui-feedback"
|
|
116
|
+
accessibilityLabel="Share feedback on this UI"
|
|
117
|
+
style={styles.root}
|
|
118
|
+
>
|
|
119
|
+
{phase === 'sent' ? (
|
|
120
|
+
<Text testID="ggui-ui-feedback-thanks" style={styles.thanksText}>
|
|
121
|
+
Thanks for the feedback
|
|
122
|
+
</Text>
|
|
123
|
+
) : phase === 'comment' ? (
|
|
124
|
+
<View style={styles.commentForm} testID="ggui-ui-feedback-comment-form">
|
|
125
|
+
<TextInput
|
|
126
|
+
value={comment}
|
|
127
|
+
onChangeText={setComment}
|
|
128
|
+
onSubmitEditing={onCommentSubmit}
|
|
129
|
+
placeholder="What happened?"
|
|
130
|
+
accessibilityLabel="Feedback comment"
|
|
131
|
+
testID="ggui-ui-feedback-comment"
|
|
132
|
+
style={styles.commentInput}
|
|
133
|
+
autoFocus
|
|
134
|
+
/>
|
|
135
|
+
<Pressable
|
|
136
|
+
accessibilityRole="button"
|
|
137
|
+
style={styles.verdictButton}
|
|
138
|
+
testID="ggui-ui-feedback-send"
|
|
139
|
+
onPress={onCommentSubmit}
|
|
140
|
+
>
|
|
141
|
+
<Text style={styles.verdictText}>Send</Text>
|
|
142
|
+
</Pressable>
|
|
143
|
+
</View>
|
|
144
|
+
) : (
|
|
145
|
+
<>
|
|
146
|
+
<Pressable
|
|
147
|
+
accessibilityRole="button"
|
|
148
|
+
style={styles.verdictButton}
|
|
149
|
+
testID="ggui-ui-feedback-verdict-love"
|
|
150
|
+
onPress={() => emit('love')}
|
|
151
|
+
>
|
|
152
|
+
<Text style={styles.verdictText}>Love</Text>
|
|
153
|
+
</Pressable>
|
|
154
|
+
<Pressable
|
|
155
|
+
accessibilityRole="button"
|
|
156
|
+
style={styles.verdictButton}
|
|
157
|
+
testID="ggui-ui-feedback-verdict-dislike"
|
|
158
|
+
onPress={() => emit('dislike')}
|
|
159
|
+
>
|
|
160
|
+
<Text style={styles.verdictText}>Dislike</Text>
|
|
161
|
+
</Pressable>
|
|
162
|
+
<Pressable
|
|
163
|
+
accessibilityRole="button"
|
|
164
|
+
style={styles.verdictButton}
|
|
165
|
+
testID="ggui-ui-feedback-verdict-other"
|
|
166
|
+
onPress={() => setPhase('comment')}
|
|
167
|
+
>
|
|
168
|
+
<Text style={styles.verdictText}>Other…</Text>
|
|
169
|
+
</Pressable>
|
|
170
|
+
</>
|
|
171
|
+
)}
|
|
172
|
+
<Pressable
|
|
173
|
+
accessibilityRole="button"
|
|
174
|
+
accessibilityLabel="Dismiss feedback"
|
|
175
|
+
style={styles.dismissButton}
|
|
176
|
+
testID="ggui-ui-feedback-dismiss"
|
|
177
|
+
onPress={() => setPhase('dismissed')}
|
|
178
|
+
>
|
|
179
|
+
<Text style={styles.dismissText}>{'×'}</Text>
|
|
180
|
+
</Pressable>
|
|
181
|
+
</View>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Neutral palette mirrors the web copy's CSS-variable fallbacks.
|
|
186
|
+
const styles = StyleSheet.create({
|
|
187
|
+
root: {
|
|
188
|
+
flexDirection: 'row',
|
|
189
|
+
alignItems: 'center',
|
|
190
|
+
alignSelf: 'flex-start',
|
|
191
|
+
gap: 4,
|
|
192
|
+
},
|
|
193
|
+
thanksText: {
|
|
194
|
+
fontSize: 12,
|
|
195
|
+
color: '#6b7280',
|
|
196
|
+
},
|
|
197
|
+
verdictButton: {
|
|
198
|
+
backgroundColor: '#f9fafb',
|
|
199
|
+
borderWidth: 1,
|
|
200
|
+
borderColor: '#e5e7eb',
|
|
201
|
+
borderRadius: 4,
|
|
202
|
+
paddingVertical: 2,
|
|
203
|
+
paddingHorizontal: 8,
|
|
204
|
+
},
|
|
205
|
+
verdictText: {
|
|
206
|
+
fontSize: 12,
|
|
207
|
+
color: '#4b5563',
|
|
208
|
+
},
|
|
209
|
+
dismissButton: {
|
|
210
|
+
paddingVertical: 2,
|
|
211
|
+
paddingHorizontal: 4,
|
|
212
|
+
},
|
|
213
|
+
dismissText: {
|
|
214
|
+
fontSize: 12,
|
|
215
|
+
color: '#9ca3af',
|
|
216
|
+
},
|
|
217
|
+
commentForm: {
|
|
218
|
+
flexDirection: 'row',
|
|
219
|
+
alignItems: 'center',
|
|
220
|
+
gap: 4,
|
|
221
|
+
},
|
|
222
|
+
commentInput: {
|
|
223
|
+
fontSize: 12,
|
|
224
|
+
paddingVertical: 2,
|
|
225
|
+
paddingHorizontal: 6,
|
|
226
|
+
minWidth: 140,
|
|
227
|
+
borderWidth: 1,
|
|
228
|
+
borderColor: '#e5e7eb',
|
|
229
|
+
borderRadius: 4,
|
|
230
|
+
backgroundColor: '#ffffff',
|
|
231
|
+
color: '#1f2937',
|
|
232
|
+
},
|
|
233
|
+
});
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic MCP-Apps host-role bridge helpers — the transport-agnostic
|
|
3
|
+
* JSON-RPC dispatcher plus the native WebView shim builders.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from the (now-deleted) `McpAppsGguiSessionRenderer` component
|
|
6
|
+
* so the spec-canonical `<McpAppIframe>` can still reuse the bridge
|
|
7
|
+
* surface without dragging in the render-bound legacy renderer.
|
|
8
|
+
*
|
|
9
|
+
* Both transports — the web iframe postMessage bridge and the native
|
|
10
|
+
* `react-native-webview` `onMessage`/`injectJavaScript` bridge —
|
|
11
|
+
* compute responses to embedded views' JSON-RPC requests via the
|
|
12
|
+
* single {@link handleHostBridgeRequest} switch. Keeping one dispatcher
|
|
13
|
+
* keeps the protocol surface identical across platforms; the
|
|
14
|
+
* transports differ, the methods do not.
|
|
15
|
+
*
|
|
16
|
+
* Adapter boundary rule — ENFORCED:
|
|
17
|
+
* - Lifecycle messages from the embedded view do NOT mutate outer
|
|
18
|
+
* ggui render state, actionSpec, streamSpec, or any core contract.
|
|
19
|
+
* - Everything the view can do reduces to (a) self-render, and
|
|
20
|
+
* (b) call source-server tools through the ggui server's
|
|
21
|
+
* `/mcp-apps/tools-call` proxy. No side channels.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Linking, Platform } from 'react-native';
|
|
25
|
+
import type { McpAppsGguiSession } from '@ggui-ai/protocol/integrations/mcp-apps';
|
|
26
|
+
import {
|
|
27
|
+
LATEST_PROTOCOL_VERSION,
|
|
28
|
+
McpUiUpdateModelContextRequestSchema,
|
|
29
|
+
type McpUiInitializeResult,
|
|
30
|
+
type McpUiUpdateModelContextRequest,
|
|
31
|
+
} from '@modelcontextprotocol/ext-apps';
|
|
32
|
+
|
|
33
|
+
interface JsonRpcRequest {
|
|
34
|
+
jsonrpc?: '2.0';
|
|
35
|
+
id?: number | string;
|
|
36
|
+
method?: string;
|
|
37
|
+
params?: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface JsonRpcResponse {
|
|
41
|
+
jsonrpc: '2.0';
|
|
42
|
+
id: number | string;
|
|
43
|
+
result?: Record<string, unknown>;
|
|
44
|
+
error?: { code: number; message: string };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface JsonRpcNotification {
|
|
48
|
+
jsonrpc: '2.0';
|
|
49
|
+
method: string;
|
|
50
|
+
params?: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface HostBridgeContext {
|
|
54
|
+
readonly sessionId: string;
|
|
55
|
+
readonly render: McpAppsGguiSession;
|
|
56
|
+
readonly toolsCallUrl: string;
|
|
57
|
+
readonly locale?: string;
|
|
58
|
+
readonly containerDimensions?: McpAppsGguiSession['containerDimensions'];
|
|
59
|
+
/**
|
|
60
|
+
* Optional `ui/update-model-context` sink — see the McpAppIframe
|
|
61
|
+
* prop of the same name. Absent ⇒ the honest `method_not_supported`.
|
|
62
|
+
*/
|
|
63
|
+
readonly onUpdateModelContext?: (
|
|
64
|
+
params: McpUiUpdateModelContextRequest['params'],
|
|
65
|
+
) => Promise<void> | void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Shared host-role method dispatcher. Returns a JSON-RPC response or
|
|
70
|
+
* `null` when the request is a malformed / untrusted frame the host
|
|
71
|
+
* should drop silently (no response).
|
|
72
|
+
*
|
|
73
|
+
* Exported for test coverage; production callers come through the web
|
|
74
|
+
* iframe listener or the native `onMessage` handler in
|
|
75
|
+
* {@link McpAppIframe}.
|
|
76
|
+
*/
|
|
77
|
+
export async function handleHostBridgeRequest(
|
|
78
|
+
req: JsonRpcRequest,
|
|
79
|
+
ctx: HostBridgeContext,
|
|
80
|
+
): Promise<JsonRpcResponse | null> {
|
|
81
|
+
if (!req || typeof req !== 'object') return null;
|
|
82
|
+
if (req.jsonrpc !== '2.0' || typeof req.method !== 'string') return null;
|
|
83
|
+
// Notifications (no `id`) get no response — they're fire-and-forget.
|
|
84
|
+
const id = req.id;
|
|
85
|
+
|
|
86
|
+
switch (req.method) {
|
|
87
|
+
case 'ping': {
|
|
88
|
+
return {
|
|
89
|
+
jsonrpc: '2.0',
|
|
90
|
+
id: id ?? 0,
|
|
91
|
+
result: { pong: true },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
case 'ui/initialize': {
|
|
95
|
+
// Spec-canonical `McpUiInitializeResult` — the ext-apps
|
|
96
|
+
// `App.connect` inside the embedded page zod-requires
|
|
97
|
+
// `protocolVersion` + `hostInfo` + `hostCapabilities` +
|
|
98
|
+
// `hostContext`; the pre-App draft shape fails that gate and
|
|
99
|
+
// kills the mount before the renderer boots. Adapter boundary —
|
|
100
|
+
// `hostContext` carries `{locale, containerDimensions}` ONLY,
|
|
101
|
+
// NO outer render state leaks here (ggui theming rides the
|
|
102
|
+
// `ai.ggui/render.theme` slice, never this handshake).
|
|
103
|
+
const requestedRaw = req.params?.protocolVersion;
|
|
104
|
+
const requested =
|
|
105
|
+
typeof requestedRaw === 'string' && requestedRaw.length > 0
|
|
106
|
+
? requestedRaw
|
|
107
|
+
: LATEST_PROTOCOL_VERSION;
|
|
108
|
+
const result: McpUiInitializeResult = {
|
|
109
|
+
protocolVersion: requested,
|
|
110
|
+
// Diagnostic-only; no build-stamp machinery in this tsc-built
|
|
111
|
+
// package — see the McpAppIframe dispatcher's identical note.
|
|
112
|
+
hostInfo: { name: 'ggui-react-native', version: 'unstamped' },
|
|
113
|
+
hostCapabilities: {},
|
|
114
|
+
hostContext: {
|
|
115
|
+
locale:
|
|
116
|
+
ctx.locale ??
|
|
117
|
+
(typeof navigator !== 'undefined' ? navigator.language : 'en-US'),
|
|
118
|
+
containerDimensions: ctx.containerDimensions ?? {},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
return { jsonrpc: '2.0', id: id ?? 0, result };
|
|
122
|
+
}
|
|
123
|
+
case 'ui/open-link': {
|
|
124
|
+
// iframe asks the host to open a URL out-of-band (system browser
|
|
125
|
+
// on native, new tab on web). ENFORCEMENT stays at the proxy —
|
|
126
|
+
// this method does NOT call any MCP tool and does NOT carry
|
|
127
|
+
// credentials; it's a platform-level link hand-off.
|
|
128
|
+
const url = typeof req.params?.url === 'string' ? req.params.url : '';
|
|
129
|
+
// Validate scheme. Anything other than http(s) is rejected so an
|
|
130
|
+
// iframe can't trick the host into opening `file://`, `javascript:`,
|
|
131
|
+
// arbitrary URI schemes, etc.
|
|
132
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
133
|
+
return {
|
|
134
|
+
jsonrpc: '2.0',
|
|
135
|
+
id: id ?? 0,
|
|
136
|
+
error: { code: -32602, message: 'ui/open-link requires http(s) url' },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
if (Platform.OS === 'web') {
|
|
141
|
+
if (typeof window !== 'undefined') {
|
|
142
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
await Linking.openURL(url);
|
|
146
|
+
}
|
|
147
|
+
return { jsonrpc: '2.0', id: id ?? 0, result: { opened: true } };
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return {
|
|
150
|
+
jsonrpc: '2.0',
|
|
151
|
+
id: id ?? 0,
|
|
152
|
+
error: { code: -32000, message: `open_link_failed: ${String(err)}` },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
case 'tools/call': {
|
|
157
|
+
const tool = typeof req.params?.name === 'string' ? req.params.name : '';
|
|
158
|
+
const args =
|
|
159
|
+
req.params?.arguments && typeof req.params.arguments === 'object'
|
|
160
|
+
? (req.params.arguments as Record<string, unknown>)
|
|
161
|
+
: {};
|
|
162
|
+
if (!tool) {
|
|
163
|
+
return {
|
|
164
|
+
jsonrpc: '2.0',
|
|
165
|
+
id: id ?? 0,
|
|
166
|
+
error: { code: -32602, message: 'tools/call requires params.name' },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const resp = await fetch(ctx.toolsCallUrl, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { 'Content-Type': 'application/json' },
|
|
173
|
+
body: JSON.stringify({
|
|
174
|
+
session: ctx.sessionId,
|
|
175
|
+
tool,
|
|
176
|
+
arguments: args,
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
if (!resp.ok) {
|
|
180
|
+
const body = (await resp.json().catch(() => ({}))) as {
|
|
181
|
+
error?: string;
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
jsonrpc: '2.0',
|
|
185
|
+
id: id ?? 0,
|
|
186
|
+
error: {
|
|
187
|
+
code: resp.status === 403 ? -32003 : -32000,
|
|
188
|
+
message: body.error ?? `proxy_error_${resp.status}`,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const result = (await resp.json()) as Record<string, unknown>;
|
|
193
|
+
return { jsonrpc: '2.0', id: id ?? 0, result };
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return {
|
|
196
|
+
jsonrpc: '2.0',
|
|
197
|
+
id: id ?? 0,
|
|
198
|
+
error: {
|
|
199
|
+
code: -32000,
|
|
200
|
+
message: `proxy_fetch_failed: ${String(err)}`,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
case 'ui/update-model-context': {
|
|
206
|
+
// Forwarded to the caller's sink when wired; without one the
|
|
207
|
+
// honest answer stays `method_not_supported` — acking context
|
|
208
|
+
// we did not carry anywhere would mislead the app
|
|
209
|
+
// (first-integrator report, ggui#425 fourth finding).
|
|
210
|
+
if (ctx.onUpdateModelContext === undefined) {
|
|
211
|
+
return {
|
|
212
|
+
jsonrpc: '2.0',
|
|
213
|
+
id: id ?? 0,
|
|
214
|
+
error: { code: -32601, message: 'method_not_supported' },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const parsed = McpUiUpdateModelContextRequestSchema.safeParse({
|
|
218
|
+
method: 'ui/update-model-context',
|
|
219
|
+
params: req.params,
|
|
220
|
+
});
|
|
221
|
+
if (!parsed.success) {
|
|
222
|
+
return {
|
|
223
|
+
jsonrpc: '2.0',
|
|
224
|
+
id: id ?? 0,
|
|
225
|
+
error: { code: -32602, message: 'invalid ui/update-model-context params' },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
await ctx.onUpdateModelContext(parsed.data.params);
|
|
229
|
+
return { jsonrpc: '2.0', id: id ?? 0, result: {} };
|
|
230
|
+
}
|
|
231
|
+
default: {
|
|
232
|
+
return {
|
|
233
|
+
jsonrpc: '2.0',
|
|
234
|
+
id: id ?? 0,
|
|
235
|
+
error: { code: -32601, message: 'method_not_supported' },
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// =============================================================================
|
|
242
|
+
// Native WebView bridge
|
|
243
|
+
// =============================================================================
|
|
244
|
+
//
|
|
245
|
+
// The injected shim aliases `window.parent.postMessage` onto
|
|
246
|
+
// `window.ReactNativeWebView.postMessage` so MCP Apps pages authored
|
|
247
|
+
// against the iframe-host contract work unchanged in a top-level
|
|
248
|
+
// WebView (where `window.parent === window`).
|
|
249
|
+
//
|
|
250
|
+
// Wire format — wrap the original payload so the RN side can
|
|
251
|
+
// distinguish MCP Apps traffic from other message events the embedded
|
|
252
|
+
// page might legitimately generate.
|
|
253
|
+
export const NATIVE_BRIDGE_ENVELOPE_KEY = '__ggui_mcp_apps';
|
|
254
|
+
|
|
255
|
+
/** Script injected BEFORE the page's own scripts run. */
|
|
256
|
+
export function buildInjectedBridgeScript(): string {
|
|
257
|
+
// The `true;` trailer is required by react-native-webview on iOS for
|
|
258
|
+
// injected strings; without it iOS's JS runtime returns an `undefined`
|
|
259
|
+
// result that confuses the loader.
|
|
260
|
+
return `
|
|
261
|
+
(function() {
|
|
262
|
+
try {
|
|
263
|
+
if (window.__gguiMcpAppsBridge) return;
|
|
264
|
+
window.__gguiMcpAppsBridge = { version: 1 };
|
|
265
|
+
|
|
266
|
+
function forward(msg) {
|
|
267
|
+
try {
|
|
268
|
+
if (window.ReactNativeWebView && typeof window.ReactNativeWebView.postMessage === 'function') {
|
|
269
|
+
window.ReactNativeWebView.postMessage(JSON.stringify({
|
|
270
|
+
${JSON.stringify(NATIVE_BRIDGE_ENVELOPE_KEY)}: true,
|
|
271
|
+
payload: msg,
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
} catch (_e) {}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Intercept page → host posts.
|
|
278
|
+
//
|
|
279
|
+
// MCP Apps hosted as iframes use \`window.parent.postMessage(msg, '*')\`.
|
|
280
|
+
// Inside react-native-webview \`window.parent === window\`, so calls
|
|
281
|
+
// hit \`window.postMessage\`. Override it to forward to the RN bridge
|
|
282
|
+
// instead of firing a same-window message event.
|
|
283
|
+
var origPostMessage = window.postMessage;
|
|
284
|
+
window.postMessage = function(data /*, targetOrigin */) {
|
|
285
|
+
forward(data);
|
|
286
|
+
};
|
|
287
|
+
// ONE stable parent proxy — first-integrator device finding
|
|
288
|
+
// (ggui#425): a getter that mints a fresh object per access breaks
|
|
289
|
+
// every transport that checks \`event.source === window.parent\`
|
|
290
|
+
// (two reads can never be equal).
|
|
291
|
+
var parentProxy = { postMessage: forward };
|
|
292
|
+
// Explicit \`parent.postMessage\` aliasing — some engines reify
|
|
293
|
+
// \`window.parent\` lazily, so overriding it on \`window\` is the
|
|
294
|
+
// canonical safety net.
|
|
295
|
+
try {
|
|
296
|
+
Object.defineProperty(window, 'parent', {
|
|
297
|
+
configurable: true,
|
|
298
|
+
get: function() {
|
|
299
|
+
return parentProxy;
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
} catch (_e) {
|
|
303
|
+
// If \`parent\` is non-configurable on this engine, leave the
|
|
304
|
+
// \`window.postMessage\` override in place — it covers the
|
|
305
|
+
// \`window.parent === window\` loopback for us.
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Host → page delivery channel. WebKit rejects a synthesized
|
|
309
|
+
// \`MessageEvent\` whose \`source\` is a plain object (must be a
|
|
310
|
+
// WindowProxy / MessagePort / null) — the TypeError was swallowed
|
|
311
|
+
// and no host response ever reached the renderer (first-integrator
|
|
312
|
+
// device finding, ggui#425). Sidestep event-construction legality
|
|
313
|
+
// entirely: capture \`message\` listeners at document-start (this
|
|
314
|
+
// script runs via injectedJavaScriptBeforeContentLoaded, so the
|
|
315
|
+
// wrappers are in place before any page script registers) and
|
|
316
|
+
// deliver by direct invocation with an event-shaped object whose
|
|
317
|
+
// \`source\` IS the stable parent proxy.
|
|
318
|
+
var messageListeners = [];
|
|
319
|
+
var origAdd = window.addEventListener ? window.addEventListener.bind(window) : null;
|
|
320
|
+
window.addEventListener = function(type, fn, opts) {
|
|
321
|
+
if (type === 'message' && typeof fn === 'function') messageListeners.push(fn);
|
|
322
|
+
if (origAdd) return origAdd(type, fn, opts);
|
|
323
|
+
};
|
|
324
|
+
var origRemove = window.removeEventListener ? window.removeEventListener.bind(window) : null;
|
|
325
|
+
window.removeEventListener = function(type, fn, opts) {
|
|
326
|
+
if (type === 'message') {
|
|
327
|
+
var i = messageListeners.indexOf(fn);
|
|
328
|
+
if (i !== -1) messageListeners.splice(i, 1);
|
|
329
|
+
}
|
|
330
|
+
if (origRemove) return origRemove(type, fn, opts);
|
|
331
|
+
};
|
|
332
|
+
window.__gguiDeliver = function(data) {
|
|
333
|
+
var ev = { type: 'message', data: data, source: parentProxy, origin: '' };
|
|
334
|
+
var snapshot = messageListeners.slice();
|
|
335
|
+
for (var i = 0; i < snapshot.length; i++) {
|
|
336
|
+
try { snapshot[i](ev); } catch (_e) {}
|
|
337
|
+
}
|
|
338
|
+
if (typeof window.onmessage === 'function') {
|
|
339
|
+
try { window.onmessage(ev); } catch (_e) {}
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// Mark that the shim loaded; tests and diagnostics may read this.
|
|
344
|
+
window.__gguiMcpAppsBridge.ready = true;
|
|
345
|
+
} catch (_e) {}
|
|
346
|
+
})();
|
|
347
|
+
true;
|
|
348
|
+
`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Build a script that delivers a host → WebView JSON-RPC message to the
|
|
353
|
+
* embedded page. Escaped for safe injection — every caller-controlled
|
|
354
|
+
* string lands inside a JSON.parse'd literal, never as a JS identifier.
|
|
355
|
+
*
|
|
356
|
+
* Delivery goes through the injected bridge's `window.__gguiDeliver`
|
|
357
|
+
* (direct listener invocation with `source` = the stable parent proxy)
|
|
358
|
+
* rather than a synthesized `MessageEvent`: WebKit rejects a
|
|
359
|
+
* `MessageEvent` whose `source` is a plain object, and the resulting
|
|
360
|
+
* TypeError was silently swallowed — no host response ever reached the
|
|
361
|
+
* renderer (first-integrator device finding, ggui#425). The
|
|
362
|
+
* `dispatchEvent` fallback below only runs when the bridge failed to
|
|
363
|
+
* inject; it omits `source` (null is legal everywhere) as a
|
|
364
|
+
* best-effort path for transports that don't check event provenance.
|
|
365
|
+
*/
|
|
366
|
+
export function buildDeliveryScript(
|
|
367
|
+
message: JsonRpcResponse | JsonRpcNotification,
|
|
368
|
+
): string {
|
|
369
|
+
// JSON.stringify produces valid JS when re-embedded as the argument
|
|
370
|
+
// to JSON.parse. This is the canonical safe-injection pattern for
|
|
371
|
+
// WebView.injectJavaScript — no string concatenation of untrusted
|
|
372
|
+
// fields into executable JS.
|
|
373
|
+
const json = JSON.stringify(JSON.stringify(message));
|
|
374
|
+
return `
|
|
375
|
+
(function() {
|
|
376
|
+
try {
|
|
377
|
+
var data = JSON.parse(${json});
|
|
378
|
+
if (typeof window.__gguiDeliver === 'function') {
|
|
379
|
+
window.__gguiDeliver(data);
|
|
380
|
+
} else {
|
|
381
|
+
var ev = new MessageEvent('message', { data: data });
|
|
382
|
+
window.dispatchEvent(ev);
|
|
383
|
+
}
|
|
384
|
+
} catch (_e) {}
|
|
385
|
+
})();
|
|
386
|
+
true;
|
|
387
|
+
`;
|
|
388
|
+
}
|