@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.
Files changed (47) hide show
  1. package/README.md +0 -1
  2. package/dist/adapters/langchain-adapter.js.map +1 -1
  3. package/dist/adapters/langchain-adapter.mjs.map +1 -1
  4. package/dist/client/index.d.mts +3 -189
  5. package/dist/client/index.d.ts +3 -189
  6. package/dist/client/index.js +218 -54
  7. package/dist/client/index.js.map +1 -1
  8. package/dist/client/index.mjs +215 -55
  9. package/dist/client/index.mjs.map +1 -1
  10. package/dist/client/react.d.mts +29 -40
  11. package/dist/client/react.d.ts +29 -40
  12. package/dist/client/react.js +492 -147
  13. package/dist/client/react.js.map +1 -1
  14. package/dist/client/react.mjs +490 -149
  15. package/dist/client/react.mjs.map +1 -1
  16. package/dist/client/vue.d.mts +3 -2
  17. package/dist/client/vue.d.ts +3 -2
  18. package/dist/client/vue.js +239 -63
  19. package/dist/client/vue.js.map +1 -1
  20. package/dist/client/vue.mjs +236 -64
  21. package/dist/client/vue.mjs.map +1 -1
  22. package/dist/index-CQr9q0bF.d.mts +295 -0
  23. package/dist/index-nE_7Io0I.d.ts +295 -0
  24. package/dist/index.d.mts +2 -1
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.js +315 -64
  27. package/dist/index.js.map +1 -1
  28. package/dist/index.mjs +303 -65
  29. package/dist/index.mjs.map +1 -1
  30. package/dist/server/index.js +93 -10
  31. package/dist/server/index.js.map +1 -1
  32. package/dist/server/index.mjs +88 -10
  33. package/dist/server/index.mjs.map +1 -1
  34. package/package.json +13 -11
  35. package/src/adapters/langchain-adapter.ts +1 -1
  36. package/src/client/core/app-host.ts +252 -65
  37. package/src/client/core/constants.ts +30 -0
  38. package/src/client/index.ts +6 -1
  39. package/src/client/react/index.ts +6 -1
  40. package/src/client/react/use-app-host.ts +13 -19
  41. package/src/client/react/use-mcp-apps.tsx +297 -125
  42. package/src/client/react/use-mcp.ts +75 -36
  43. package/src/client/utils/app-host-utils.ts +62 -0
  44. package/src/client/vue/use-mcp.ts +23 -12
  45. package/src/server/mcp/oauth-client.ts +31 -8
  46. package/src/server/storage/crypto.ts +92 -0
  47. 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, { useState, useEffect, useCallback, useRef, memo } from 'react';
8
- import { useAppHost } from './use-app-host.js';
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
- interface McpAppRendererProps {
37
- mcpClient: McpClient | null;
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: 'executing' | 'inProgress' | 'complete' | 'idle';
42
- /** Custom CSS class for the container */
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
- * Simplified MCP App renderer - users just pass tool name and data
48
- * Internal hook handles metadata lookup and SSE client retrieval
49
- */
50
- const McpAppRenderer = memo(function McpAppRenderer({
51
- mcpClient,
52
- name,
53
- input,
54
- result,
55
- status,
56
- className,
57
- }: McpAppRendererProps) {
58
- const getAppMetadata = useCallback((): McpAppMetadata | undefined => {
59
- if (!mcpClient) return undefined;
60
-
61
- const extractedName = extractToolName(name);
62
-
63
- for (const conn of mcpClient.connections) {
64
- for (const tool of conn.tools) {
65
- const candidateName = extractToolName(tool.name);
66
- const resourceUri =
67
- tool.mcpApp?.resourceUri ??
68
- tool._meta?.ui?.resourceUri ??
69
- tool._meta?.['ui/resourceUri'];
70
-
71
- if (resourceUri && candidateName === extractedName) {
72
- return {
73
- toolName: candidateName,
74
- resourceUri,
75
- sessionId: conn.sessionId,
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
- return undefined;
82
- }, [mcpClient, name]);
83
-
84
- const metadata = getAppMetadata();
85
- const sseClient = mcpClient?.sseClient ?? null;
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
- if (!host || !metadata.resourceUri || !metadata.sessionId) return;
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(metadata.resourceUri, metadata.sessionId)
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, metadata.resourceUri, metadata.sessionId]);
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
- // Display errors
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 className={`w-full border border-gray-700 rounded overflow-hidden bg-white min-h-96 my-2 relative ${className || ''}`}>
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
- className="w-full h-full min-h-96"
166
- style={{ height: 'auto' }}
341
+ allow="fullscreen"
342
+ className={iframeClass}
167
343
  title="MCP App"
168
344
  />
169
- {!isLaunched && (
170
- <div className="absolute inset-0 bg-gray-900/50 flex items-center justify-center pointer-events-none">
171
- <div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
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
- * Simple hook to get MCP app metadata
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
- * Get MCP app metadata for a tool name
205
- * This is fast and can be called on every render
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
- return undefined;
233
- },
234
- [mcpClient]
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
- // Handle OAuth redirect
344
- if (event.authUrl) {
345
- onLog?.('info', `OAuth required - redirecting to ${event.authUrl}`, { authUrl: event.authUrl });
346
-
347
- // Suppress redirects/popups for auto-restore on page load.
348
- if (!suppressAuthRedirectSessionsRef.current.has(event.sessionId)) {
349
- if (onRedirect) {
350
- onRedirect(event.authUrl);
351
- } else if (typeof window !== 'undefined') {
352
- window.location.href = event.authUrl;
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: event.authUrl } : c
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
- connections,
620
- status,
621
- isInitializing,
622
- connect,
623
- disconnect,
624
- getConnection,
625
- getConnectionByServerId,
626
- isServerConnected,
627
- getTools,
628
- refresh,
629
- connectSSE,
630
- disconnectSSE,
631
- finishAuth,
632
- resumeAuth,
633
- callTool,
634
- listTools,
635
- listPrompts,
636
- getPrompt,
637
- listResources,
638
- readResource,
639
- sseClient: clientRef.current,
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
  }