@mcp-ts/sdk 1.3.9 → 1.4.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/README.md +0 -1
- package/dist/adapters/langchain-adapter.js.map +1 -1
- package/dist/adapters/langchain-adapter.mjs.map +1 -1
- package/dist/client/index.d.mts +3 -189
- package/dist/client/index.d.ts +3 -189
- package/dist/client/index.js +218 -54
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +215 -55
- package/dist/client/index.mjs.map +1 -1
- package/dist/client/react.d.mts +29 -40
- package/dist/client/react.d.ts +29 -40
- package/dist/client/react.js +492 -147
- package/dist/client/react.js.map +1 -1
- package/dist/client/react.mjs +490 -149
- package/dist/client/react.mjs.map +1 -1
- package/dist/client/vue.d.mts +3 -2
- package/dist/client/vue.d.ts +3 -2
- package/dist/client/vue.js +239 -63
- package/dist/client/vue.js.map +1 -1
- package/dist/client/vue.mjs +236 -64
- package/dist/client/vue.mjs.map +1 -1
- package/dist/index-CQr9q0bF.d.mts +295 -0
- package/dist/index-nE_7Io0I.d.ts +295 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +315 -64
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +303 -65
- package/dist/index.mjs.map +1 -1
- package/dist/server/index.js +93 -10
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +88 -10
- package/dist/server/index.mjs.map +1 -1
- package/package.json +13 -11
- package/src/adapters/langchain-adapter.ts +1 -1
- package/src/client/core/app-host.ts +252 -65
- package/src/client/core/constants.ts +30 -0
- package/src/client/index.ts +6 -1
- package/src/client/react/index.ts +6 -1
- package/src/client/react/use-app-host.ts +13 -19
- package/src/client/react/use-mcp-apps.tsx +297 -125
- package/src/client/react/use-mcp.ts +75 -36
- package/src/client/utils/app-host-utils.ts +62 -0
- package/src/client/vue/use-mcp.ts +23 -12
- package/src/server/mcp/oauth-client.ts +31 -8
- package/src/server/storage/crypto.ts +92 -0
- package/src/server/storage/supabase-backend.ts +7 -6
|
@@ -4,9 +4,21 @@
|
|
|
4
4
|
* Provides utilities for rendering interactive UI components from MCP servers.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import React, {
|
|
8
|
-
|
|
7
|
+
import React, {
|
|
8
|
+
useState,
|
|
9
|
+
useEffect,
|
|
10
|
+
useCallback,
|
|
11
|
+
useRef,
|
|
12
|
+
memo,
|
|
13
|
+
useMemo,
|
|
14
|
+
forwardRef,
|
|
15
|
+
useImperativeHandle,
|
|
16
|
+
type MutableRefObject,
|
|
17
|
+
} from 'react';
|
|
18
|
+
import { useAppHost, type UseAppHostOptions } from './use-app-host.js';
|
|
9
19
|
import type { SSEClient } from '../core/sse-client.js';
|
|
20
|
+
import { APP_HOST_DEFAULTS } from '../core/constants.js';
|
|
21
|
+
import type { SandboxConfig } from '../core/app-host.js';
|
|
10
22
|
|
|
11
23
|
export interface McpClient {
|
|
12
24
|
connections: Array<{
|
|
@@ -33,100 +45,211 @@ export interface McpAppMetadata {
|
|
|
33
45
|
sessionId: string;
|
|
34
46
|
}
|
|
35
47
|
|
|
36
|
-
|
|
37
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Imperative handle for {@link useMcpApps}'s `McpAppRenderer` (via `ref`),
|
|
50
|
+
* aligned with `@mcp-ui/client`'s `AppRendererHandle.teardownResource`.
|
|
51
|
+
*/
|
|
52
|
+
export interface McpAppRendererHandle {
|
|
53
|
+
teardownResource: (params?: Record<string, unknown>) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Props for {@link useMcpApps}'s `McpAppRenderer` (client is supplied via the hook). */
|
|
57
|
+
export interface McpAppRendererProps extends Pick<UseAppHostOptions, 'sandbox' | 'hostContext' | 'onCallTool' | 'onReadResource' | 'onFallbackRequest' | 'onMessage' | 'onOpenLink' | 'onLoggingMessage' | 'onSizeChanged' | 'onError'> {
|
|
38
58
|
name: string;
|
|
59
|
+
toolResourceUri?: string;
|
|
60
|
+
html?: string;
|
|
39
61
|
input?: Record<string, unknown>;
|
|
40
62
|
result?: unknown;
|
|
41
|
-
status
|
|
42
|
-
|
|
63
|
+
status?: 'executing' | 'inProgress' | 'complete' | 'idle';
|
|
64
|
+
toolInputPartial?: any;
|
|
65
|
+
toolCancelled?: boolean;
|
|
43
66
|
className?: string;
|
|
67
|
+
loader?: React.ReactNode;
|
|
44
68
|
}
|
|
45
69
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
type McpAppViewProps = McpAppRendererProps & {
|
|
71
|
+
/**
|
|
72
|
+
* Ref avoids tying `McpAppRenderer` identity to `mcpClient`: when `connections` updates, `useMcp()` still
|
|
73
|
+
* returns a new object (correct for `useEffect` deps), but the iframe must not remount.
|
|
74
|
+
*/
|
|
75
|
+
clientRef: MutableRefObject<McpClient | null>;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Renders one MCP App in a sandboxed iframe; reads the latest client from `clientRef` each render. */
|
|
79
|
+
const McpAppViewInner = forwardRef<McpAppRendererHandle, McpAppViewProps>(function McpAppView(
|
|
80
|
+
{
|
|
81
|
+
clientRef,
|
|
82
|
+
name,
|
|
83
|
+
toolResourceUri,
|
|
84
|
+
html,
|
|
85
|
+
input,
|
|
86
|
+
result,
|
|
87
|
+
status = 'idle',
|
|
88
|
+
toolInputPartial,
|
|
89
|
+
toolCancelled,
|
|
90
|
+
sandbox,
|
|
91
|
+
hostContext,
|
|
92
|
+
onCallTool,
|
|
93
|
+
onReadResource,
|
|
94
|
+
onFallbackRequest,
|
|
95
|
+
onMessage,
|
|
96
|
+
onOpenLink,
|
|
97
|
+
onLoggingMessage,
|
|
98
|
+
onSizeChanged,
|
|
99
|
+
onError: onHostError,
|
|
100
|
+
className,
|
|
101
|
+
loader,
|
|
102
|
+
},
|
|
103
|
+
ref,
|
|
104
|
+
) {
|
|
105
|
+
const mcpClient = clientRef.current;
|
|
106
|
+
const metadata = getMcpAppMetadata(mcpClient, name);
|
|
107
|
+
const sseClient = mcpClient?.sseClient ?? null;
|
|
108
|
+
const resourceUri = toolResourceUri || metadata?.resourceUri;
|
|
109
|
+
const appSessionId = metadata?.sessionId;
|
|
110
|
+
|
|
111
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
112
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
113
|
+
// Tracks the last height (px) reported by the guest before/after fullscreen so we
|
|
114
|
+
// can restore it when the native fullscreen API exits and the guest fires a stale
|
|
115
|
+
// resize event that would otherwise collapse the iframe to 0.
|
|
116
|
+
const preFullscreenHeightRef = useRef<number | null>(null);
|
|
117
|
+
const displayModeRef = useRef<'inline' | 'fullscreen'>('inline');
|
|
118
|
+
const [displayMode, setDisplayMode] = useState<'inline' | 'fullscreen'>('inline');
|
|
119
|
+
|
|
120
|
+
const setDisplayModeWithRef = (mode: 'inline' | 'fullscreen') => {
|
|
121
|
+
displayModeRef.current = mode;
|
|
122
|
+
setDisplayMode(mode);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const { host, error: hostError } = useAppHost(sseClient as any, iframeRef, {
|
|
126
|
+
sandbox,
|
|
127
|
+
hostContext,
|
|
128
|
+
onCallTool,
|
|
129
|
+
onReadResource,
|
|
130
|
+
onFallbackRequest,
|
|
131
|
+
onMessage,
|
|
132
|
+
onOpenLink,
|
|
133
|
+
onLoggingMessage,
|
|
134
|
+
// Intercept onSizeChanged: when exiting fullscreen, ignore guest resize events
|
|
135
|
+
// that arrive with the shrunken viewport dimensions, and restore the pre-fullscreen height.
|
|
136
|
+
onSizeChanged: (params) => {
|
|
137
|
+
if (displayModeRef.current === 'inline' && preFullscreenHeightRef.current !== null) {
|
|
138
|
+
// Guest fired a resize right after fullscreen exit – restore the saved height
|
|
139
|
+
const savedHeight = preFullscreenHeightRef.current;
|
|
140
|
+
preFullscreenHeightRef.current = null;
|
|
141
|
+
if (iframeRef.current) {
|
|
142
|
+
iframeRef.current.style.height = `${savedHeight}px`;
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
onSizeChanged?.(params);
|
|
147
|
+
},
|
|
148
|
+
onError: onHostError,
|
|
149
|
+
onRequestDisplayMode: async (params) => {
|
|
150
|
+
if (params.mode === 'fullscreen') {
|
|
151
|
+
// Snapshot current iframe height so we can restore on exit
|
|
152
|
+
if (iframeRef.current) {
|
|
153
|
+
const h = iframeRef.current.getBoundingClientRect().height;
|
|
154
|
+
if (h > 0) preFullscreenHeightRef.current = h;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
if (containerRef.current?.requestFullscreen) {
|
|
158
|
+
await containerRef.current.requestFullscreen();
|
|
159
|
+
} else if ((containerRef.current as any)?.webkitRequestFullscreen) {
|
|
160
|
+
await (containerRef.current as any).webkitRequestFullscreen();
|
|
161
|
+
}
|
|
162
|
+
setDisplayModeWithRef('fullscreen');
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.warn('[McpAppHost] requestFullscreen failed:', err);
|
|
165
|
+
preFullscreenHeightRef.current = null;
|
|
166
|
+
return { mode: 'inline' };
|
|
77
167
|
}
|
|
168
|
+
} else if (params.mode === 'inline') {
|
|
169
|
+
// Eagerly restore height — don't wait for a guest onsizechange that may never arrive
|
|
170
|
+
restoreHeightAfterFullscreen();
|
|
171
|
+
try {
|
|
172
|
+
if (document.fullscreenElement) {
|
|
173
|
+
await document.exitFullscreen();
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {}
|
|
176
|
+
setDisplayModeWithRef('inline');
|
|
78
177
|
}
|
|
178
|
+
return { mode: params.mode };
|
|
79
179
|
}
|
|
180
|
+
});
|
|
80
181
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
182
|
+
useImperativeHandle(
|
|
183
|
+
ref,
|
|
184
|
+
() => ({
|
|
185
|
+
teardownResource: (params?: Record<string, unknown>) => {
|
|
186
|
+
host?.teardownResource(params ?? {});
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
[host],
|
|
190
|
+
);
|
|
86
191
|
|
|
87
|
-
if (!metadata || !sseClient) {
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
91
|
-
const { host, error: hostError } = useAppHost(sseClient as SSEClient, iframeRef);
|
|
92
192
|
const [isLaunched, setIsLaunched] = useState(false);
|
|
93
193
|
const [error, setError] = useState<Error | null>(null);
|
|
94
194
|
|
|
95
|
-
// Track which data has been sent to prevent duplicates
|
|
96
195
|
const sentInputRef = useRef(false);
|
|
97
196
|
const sentResultRef = useRef(false);
|
|
98
197
|
const lastInputRef = useRef(input);
|
|
99
198
|
const lastResultRef = useRef(result);
|
|
100
199
|
const lastStatusRef = useRef(status);
|
|
101
200
|
|
|
102
|
-
// Launch the app when host is ready
|
|
103
201
|
useEffect(() => {
|
|
104
|
-
|
|
202
|
+
setIsLaunched(false);
|
|
203
|
+
setError(null);
|
|
204
|
+
}, [resourceUri, appSessionId]);
|
|
205
|
+
|
|
206
|
+
// Eagerly restore the iframe's pre-fullscreen height at every exit point.
|
|
207
|
+
// The guest app may NOT fire onSizeChanged after exiting fullscreen, so we cannot
|
|
208
|
+
// rely on the onSizeChanged interceptor to restore the height.
|
|
209
|
+
const restoreHeightAfterFullscreen = () => {
|
|
210
|
+
const savedHeight = preFullscreenHeightRef.current;
|
|
211
|
+
if (savedHeight && iframeRef.current) {
|
|
212
|
+
iframeRef.current.style.height = `${savedHeight}px`;
|
|
213
|
+
}
|
|
214
|
+
preFullscreenHeightRef.current = null;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
const onFullscreenChange = () => {
|
|
219
|
+
const isFullscreen = !!document.fullscreenElement;
|
|
220
|
+
// Use ref to avoid stale closure (ESC key exit path)
|
|
221
|
+
if (!isFullscreen && displayModeRef.current === 'fullscreen') {
|
|
222
|
+
restoreHeightAfterFullscreen();
|
|
223
|
+
setDisplayModeWithRef('inline');
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
document.addEventListener('fullscreenchange', onFullscreenChange);
|
|
227
|
+
return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
|
|
228
|
+
}, []); // stable – reads from refs only, no stale closure over state
|
|
229
|
+
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (!host || (!resourceUri && !html)) return;
|
|
105
232
|
|
|
106
233
|
host
|
|
107
|
-
.launch(
|
|
234
|
+
.launch({ uri: resourceUri, html }, appSessionId)
|
|
108
235
|
.then(() => setIsLaunched(true))
|
|
109
236
|
.catch((err) => setError(err instanceof Error ? err : new Error(String(err))));
|
|
110
|
-
}, [host,
|
|
237
|
+
}, [host, resourceUri, html, appSessionId]);
|
|
111
238
|
|
|
112
|
-
// Send tool input when available or when it changes
|
|
113
239
|
useEffect(() => {
|
|
114
|
-
if (!host || !isLaunched || !input) return;
|
|
115
|
-
|
|
116
|
-
// Send if never sent, or if input changed
|
|
240
|
+
if (!host || !isLaunched || !resourceUri || !appSessionId || !input) return;
|
|
241
|
+
|
|
117
242
|
if (!sentInputRef.current || JSON.stringify(input) !== JSON.stringify(lastInputRef.current)) {
|
|
118
243
|
sentInputRef.current = true;
|
|
119
244
|
lastInputRef.current = input;
|
|
120
245
|
host.sendToolInput(input);
|
|
121
246
|
}
|
|
122
|
-
}, [host, isLaunched, input]);
|
|
247
|
+
}, [host, isLaunched, input, resourceUri, appSessionId, name]);
|
|
123
248
|
|
|
124
|
-
// Send tool result when complete or when it changes
|
|
125
249
|
useEffect(() => {
|
|
126
|
-
if (!host || !isLaunched || result === undefined) return;
|
|
250
|
+
if (!host || !isLaunched || !resourceUri || !appSessionId || result === undefined) return;
|
|
127
251
|
if (status !== 'complete') return;
|
|
128
252
|
|
|
129
|
-
// Send if never sent, or if result changed
|
|
130
253
|
if (!sentResultRef.current || JSON.stringify(result) !== JSON.stringify(lastResultRef.current)) {
|
|
131
254
|
sentResultRef.current = true;
|
|
132
255
|
lastResultRef.current = result;
|
|
@@ -136,9 +259,8 @@ const McpAppRenderer = memo(function McpAppRenderer({
|
|
|
136
259
|
: result;
|
|
137
260
|
host.sendToolResult(formattedResult);
|
|
138
261
|
}
|
|
139
|
-
}, [host, isLaunched, result, status]);
|
|
262
|
+
}, [host, isLaunched, result, status, resourceUri, appSessionId, name]);
|
|
140
263
|
|
|
141
|
-
// Reset sent flags when tool status resets to executing (new tool call)
|
|
142
264
|
useEffect(() => {
|
|
143
265
|
if (status === 'executing' && lastStatusRef.current !== 'executing') {
|
|
144
266
|
sentInputRef.current = false;
|
|
@@ -147,7 +269,33 @@ const McpAppRenderer = memo(function McpAppRenderer({
|
|
|
147
269
|
lastStatusRef.current = status;
|
|
148
270
|
}, [status]);
|
|
149
271
|
|
|
150
|
-
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (!host) return;
|
|
274
|
+
// Merge user-provided hostContext with our internal displayMode, then notify the guest.
|
|
275
|
+
// This causes Excalidraw (and other MCP apps) to switch between inline/fullscreen UI mode.
|
|
276
|
+
const mergedCtx = {
|
|
277
|
+
theme: APP_HOST_DEFAULTS.THEME,
|
|
278
|
+
platform: APP_HOST_DEFAULTS.PLATFORM,
|
|
279
|
+
containerDimensions: { maxHeight: APP_HOST_DEFAULTS.MAX_HEIGHT },
|
|
280
|
+
availableDisplayModes: ['inline', 'fullscreen'],
|
|
281
|
+
...(hostContext || {}),
|
|
282
|
+
displayMode, // always override with our authoritative state
|
|
283
|
+
};
|
|
284
|
+
host.setHostContext(mergedCtx);
|
|
285
|
+
}, [host, hostContext, displayMode]);
|
|
286
|
+
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (host && toolInputPartial) host.sendToolInputPartial(toolInputPartial);
|
|
289
|
+
}, [host, toolInputPartial]);
|
|
290
|
+
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
if (host && toolCancelled) host.sendToolCancelled("User cancelled");
|
|
293
|
+
}, [host, toolCancelled]);
|
|
294
|
+
|
|
295
|
+
if (!metadata && !html && !toolResourceUri) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
151
299
|
const displayError = error || hostError;
|
|
152
300
|
if (displayError) {
|
|
153
301
|
return (
|
|
@@ -157,91 +305,115 @@ const McpAppRenderer = memo(function McpAppRenderer({
|
|
|
157
305
|
);
|
|
158
306
|
}
|
|
159
307
|
|
|
308
|
+
const opacityClass = isLaunched ? 'opacity-100' : 'opacity-0';
|
|
309
|
+
let containerClass = `w-full border border-gray-700 rounded bg-transparent my-2 relative ${className || ''}`;
|
|
310
|
+
let iframeClass = `w-full transition-opacity duration-300 ${opacityClass}`;
|
|
311
|
+
|
|
312
|
+
// When native fullscreen is active, the container naturally expands via the browser API.
|
|
313
|
+
// We only need to satisfy flex layout so the iframe fills 100% of the fullscreen viewport.
|
|
314
|
+
if (displayMode === 'fullscreen') {
|
|
315
|
+
containerClass = `w-full h-full bg-black m-0 p-0 flex flex-col relative`;
|
|
316
|
+
iframeClass = `w-full flex-1 transition-opacity duration-300 ${opacityClass}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
160
319
|
return (
|
|
161
|
-
<div
|
|
320
|
+
<div ref={containerRef} className={containerClass}>
|
|
321
|
+
{displayMode === 'fullscreen' && (
|
|
322
|
+
<div className="absolute top-0 right-0 p-2 z-[100000] w-full bg-gradient-to-b from-black/80 to-transparent flex justify-end">
|
|
323
|
+
<button
|
|
324
|
+
title="Exit Fullscreen"
|
|
325
|
+
onClick={() => {
|
|
326
|
+
// Eagerly restore height before the browser animation completes
|
|
327
|
+
restoreHeightAfterFullscreen();
|
|
328
|
+
if (document.fullscreenElement) document.exitFullscreen();
|
|
329
|
+
setDisplayModeWithRef('inline');
|
|
330
|
+
}}
|
|
331
|
+
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-white rounded-md shadow flex items-center gap-2 border border-gray-600 transition-colors"
|
|
332
|
+
>
|
|
333
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/></svg>
|
|
334
|
+
<span className="text-sm font-medium">Exit</span>
|
|
335
|
+
</button>
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
162
338
|
<iframe
|
|
163
339
|
ref={iframeRef}
|
|
164
340
|
sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-popups allow-downloads"
|
|
165
|
-
|
|
166
|
-
|
|
341
|
+
allow="fullscreen"
|
|
342
|
+
className={iframeClass}
|
|
167
343
|
title="MCP App"
|
|
168
344
|
/>
|
|
169
|
-
{!isLaunched && (
|
|
170
|
-
<div className="absolute inset-0 bg-
|
|
171
|
-
|
|
345
|
+
{!isLaunched && loader && (
|
|
346
|
+
<div className="absolute inset-0 bg-transparent flex items-center justify-center pointer-events-none z-10">
|
|
347
|
+
{loader}
|
|
172
348
|
</div>
|
|
173
349
|
)}
|
|
174
350
|
</div>
|
|
175
351
|
);
|
|
176
352
|
});
|
|
177
353
|
|
|
354
|
+
const McpAppView = memo(McpAppViewInner);
|
|
355
|
+
McpAppView.displayName = 'McpAppView';
|
|
356
|
+
|
|
178
357
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* @param mcpClient - The MCP client from useMcp() or context
|
|
182
|
-
* @returns Object with getAppMetadata function and McpAppRenderer component
|
|
358
|
+
* Helpers scoped to one `mcpClient`. Pass the client here once; `McpAppRenderer` only needs per-tool props (`name`, `input`, `result`, `status`).
|
|
183
359
|
*
|
|
184
|
-
* @example
|
|
185
|
-
* ```tsx
|
|
186
|
-
* function ToolRenderer(props) {
|
|
187
|
-
* const { getAppMetadata, McpAppRenderer } = useMcpApps(mcpClient);
|
|
188
|
-
* const metadata = getAppMetadata(props.name);
|
|
189
|
-
*
|
|
190
|
-
* if (!metadata) return null;
|
|
191
|
-
* return (
|
|
192
|
-
* <McpAppRenderer
|
|
193
|
-
* metadata={metadata}
|
|
194
|
-
* input={props.args}
|
|
195
|
-
* result={props.result}
|
|
196
|
-
* status={props.status}
|
|
197
|
-
* />
|
|
198
|
-
* );
|
|
199
|
-
* }
|
|
200
|
-
* ```
|
|
360
|
+
* @param mcpClient - From `useMcp()` or context (for example `useMcpContext()`).
|
|
201
361
|
*/
|
|
202
362
|
export function useMcpApps(mcpClient: McpClient | null) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
*/
|
|
207
|
-
const getAppMetadata = useCallback(
|
|
208
|
-
(toolName: string): McpAppMetadata | undefined => {
|
|
209
|
-
if (!mcpClient) return undefined;
|
|
210
|
-
|
|
211
|
-
const extractedName = extractToolName(toolName);
|
|
212
|
-
|
|
213
|
-
for (const conn of mcpClient.connections) {
|
|
214
|
-
for (const tool of conn.tools) {
|
|
215
|
-
const candidateName = extractToolName(tool.name);
|
|
216
|
-
// Check both locations: direct mcpApp or _meta.ui
|
|
217
|
-
const resourceUri =
|
|
218
|
-
tool.mcpApp?.resourceUri ??
|
|
219
|
-
tool._meta?.ui?.resourceUri ??
|
|
220
|
-
tool._meta?.['ui/resourceUri'];
|
|
221
|
-
|
|
222
|
-
if (resourceUri && candidateName === extractedName) {
|
|
223
|
-
return {
|
|
224
|
-
toolName: candidateName,
|
|
225
|
-
resourceUri,
|
|
226
|
-
sessionId: conn.sessionId,
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
363
|
+
// Stable `McpAppRenderer` type: parent re-renders and `connections` updates must not remount the iframe.
|
|
364
|
+
const clientRef = useRef(mcpClient);
|
|
365
|
+
clientRef.current = mcpClient;
|
|
231
366
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
[
|
|
367
|
+
const getAppMetadata = useCallback(
|
|
368
|
+
(toolName: string) => getMcpAppMetadata(clientRef.current, toolName),
|
|
369
|
+
[]
|
|
235
370
|
);
|
|
236
371
|
|
|
372
|
+
const McpAppRenderer = useMemo(() => {
|
|
373
|
+
const Inner = forwardRef<McpAppRendererHandle, McpAppRendererProps>(function McpAppRenderer(
|
|
374
|
+
props,
|
|
375
|
+
ref,
|
|
376
|
+
) {
|
|
377
|
+
return <McpAppView ref={ref} clientRef={clientRef} {...props} />;
|
|
378
|
+
});
|
|
379
|
+
const Renderer = memo(Inner);
|
|
380
|
+
Renderer.displayName = 'McpAppRenderer';
|
|
381
|
+
return Renderer;
|
|
382
|
+
}, []);
|
|
383
|
+
|
|
237
384
|
return { getAppMetadata, McpAppRenderer };
|
|
238
385
|
}
|
|
239
386
|
|
|
240
|
-
/**
|
|
241
|
-
* Extract the base tool name, removing any prefixes
|
|
242
|
-
*/
|
|
243
387
|
function extractToolName(fullName: string): string {
|
|
244
|
-
// Handle patterns like "tool_abc123_get-time" -> "get-time"
|
|
245
388
|
const match = fullName.match(/(?:tool_[^_]+_)?(.+)$/);
|
|
246
389
|
return match?.[1] || fullName;
|
|
247
390
|
}
|
|
391
|
+
|
|
392
|
+
function getMcpAppMetadata(
|
|
393
|
+
mcpClient: McpClient | null,
|
|
394
|
+
toolName: string
|
|
395
|
+
): McpAppMetadata | undefined {
|
|
396
|
+
if (!mcpClient) return undefined;
|
|
397
|
+
|
|
398
|
+
const extractedName = extractToolName(toolName);
|
|
399
|
+
|
|
400
|
+
for (const conn of mcpClient.connections) {
|
|
401
|
+
for (const tool of conn.tools) {
|
|
402
|
+
const candidateName = extractToolName(tool.name);
|
|
403
|
+
const resourceUri =
|
|
404
|
+
tool.mcpApp?.resourceUri ??
|
|
405
|
+
tool._meta?.ui?.resourceUri ??
|
|
406
|
+
tool._meta?.['ui/resourceUri'];
|
|
407
|
+
|
|
408
|
+
if (resourceUri && candidateName === extractedName) {
|
|
409
|
+
return {
|
|
410
|
+
toolName: candidateName,
|
|
411
|
+
resourceUri,
|
|
412
|
+
sessionId: conn.sessionId,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Based on Cloudflare's agents pattern
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
7
|
+
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
|
8
8
|
import { SSEClient, type SSEClientOptions } from '../core/sse-client';
|
|
9
9
|
import type { McpConnectionEvent, McpConnectionState } from '../../shared/events';
|
|
10
10
|
import type {
|
|
@@ -227,6 +227,8 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
227
227
|
'disconnected'
|
|
228
228
|
);
|
|
229
229
|
const [isInitializing, setIsInitializing] = useState(false);
|
|
230
|
+
/** Mirrored from `clientRef` so the public `McpClient` object can be memoized when the instance is ready. */
|
|
231
|
+
const [sseClient, setSseClient] = useState<SSEClient | null>(null);
|
|
230
232
|
|
|
231
233
|
/**
|
|
232
234
|
* Initialize SSE client
|
|
@@ -258,6 +260,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
258
260
|
|
|
259
261
|
const client = new SSEClient(clientOptions);
|
|
260
262
|
clientRef.current = client;
|
|
263
|
+
setSseClient(client);
|
|
261
264
|
|
|
262
265
|
if (autoConnect) {
|
|
263
266
|
client.connect();
|
|
@@ -340,21 +343,32 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
340
343
|
}
|
|
341
344
|
|
|
342
345
|
case 'auth_required': {
|
|
343
|
-
|
|
344
|
-
if (
|
|
345
|
-
onLog?.('
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
346
|
+
const url = (event.authUrl || '').trim();
|
|
347
|
+
if (!url) {
|
|
348
|
+
onLog?.('error', 'OAuth required but authorization URL is missing', { sessionId: event.sessionId });
|
|
349
|
+
return prev.map((c: McpConnection) =>
|
|
350
|
+
c.sessionId === event.sessionId
|
|
351
|
+
? {
|
|
352
|
+
...c,
|
|
353
|
+
state: 'FAILED',
|
|
354
|
+
error: 'OAuth authorization URL not available',
|
|
355
|
+
authUrl: undefined,
|
|
356
|
+
}
|
|
357
|
+
: c
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
onLog?.('info', `OAuth required - redirecting to ${url}`, { authUrl: url });
|
|
361
|
+
|
|
362
|
+
// Suppress redirects/popups for auto-restore on page load.
|
|
363
|
+
if (!suppressAuthRedirectSessionsRef.current.has(event.sessionId)) {
|
|
364
|
+
if (onRedirect) {
|
|
365
|
+
onRedirect(url);
|
|
366
|
+
} else if (typeof window !== 'undefined') {
|
|
367
|
+
window.location.href = url;
|
|
354
368
|
}
|
|
355
369
|
}
|
|
356
370
|
return prev.map((c: McpConnection) =>
|
|
357
|
-
c.sessionId === event.sessionId ? { ...c, state: 'AUTHENTICATING', authUrl:
|
|
371
|
+
c.sessionId === event.sessionId ? { ...c, state: 'AUTHENTICATING', authUrl: url } : c
|
|
358
372
|
);
|
|
359
373
|
}
|
|
360
374
|
|
|
@@ -615,27 +629,52 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
615
629
|
[getConnection]
|
|
616
630
|
);
|
|
617
631
|
|
|
618
|
-
return
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
632
|
+
return useMemo(
|
|
633
|
+
() => ({
|
|
634
|
+
connections,
|
|
635
|
+
status,
|
|
636
|
+
isInitializing,
|
|
637
|
+
connect,
|
|
638
|
+
disconnect,
|
|
639
|
+
getConnection,
|
|
640
|
+
getConnectionByServerId,
|
|
641
|
+
isServerConnected,
|
|
642
|
+
getTools,
|
|
643
|
+
refresh,
|
|
644
|
+
connectSSE,
|
|
645
|
+
disconnectSSE,
|
|
646
|
+
finishAuth,
|
|
647
|
+
resumeAuth,
|
|
648
|
+
callTool,
|
|
649
|
+
listTools,
|
|
650
|
+
listPrompts,
|
|
651
|
+
getPrompt,
|
|
652
|
+
listResources,
|
|
653
|
+
readResource,
|
|
654
|
+
sseClient,
|
|
655
|
+
}),
|
|
656
|
+
[
|
|
657
|
+
connections,
|
|
658
|
+
status,
|
|
659
|
+
isInitializing,
|
|
660
|
+
connect,
|
|
661
|
+
disconnect,
|
|
662
|
+
getConnection,
|
|
663
|
+
getConnectionByServerId,
|
|
664
|
+
isServerConnected,
|
|
665
|
+
getTools,
|
|
666
|
+
refresh,
|
|
667
|
+
connectSSE,
|
|
668
|
+
disconnectSSE,
|
|
669
|
+
finishAuth,
|
|
670
|
+
resumeAuth,
|
|
671
|
+
callTool,
|
|
672
|
+
listTools,
|
|
673
|
+
listPrompts,
|
|
674
|
+
getPrompt,
|
|
675
|
+
listResources,
|
|
676
|
+
readResource,
|
|
677
|
+
sseClient,
|
|
678
|
+
]
|
|
679
|
+
);
|
|
641
680
|
}
|