@mcp-ts/sdk 1.3.10 → 1.5.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 (86) hide show
  1. package/README.md +20 -27
  2. package/dist/adapters/agui-adapter.d.mts +16 -0
  3. package/dist/adapters/agui-adapter.d.ts +16 -0
  4. package/dist/adapters/agui-adapter.js +185 -0
  5. package/dist/adapters/agui-adapter.js.map +1 -1
  6. package/dist/adapters/agui-adapter.mjs +185 -0
  7. package/dist/adapters/agui-adapter.mjs.map +1 -1
  8. package/dist/adapters/agui-middleware.d.mts +2 -0
  9. package/dist/adapters/agui-middleware.d.ts +2 -0
  10. package/dist/adapters/agui-middleware.js.map +1 -1
  11. package/dist/adapters/agui-middleware.mjs.map +1 -1
  12. package/dist/adapters/ai-adapter.d.mts +21 -0
  13. package/dist/adapters/ai-adapter.d.ts +21 -0
  14. package/dist/adapters/ai-adapter.js +175 -0
  15. package/dist/adapters/ai-adapter.js.map +1 -1
  16. package/dist/adapters/ai-adapter.mjs +175 -0
  17. package/dist/adapters/ai-adapter.mjs.map +1 -1
  18. package/dist/adapters/langchain-adapter.d.mts +16 -0
  19. package/dist/adapters/langchain-adapter.d.ts +16 -0
  20. package/dist/adapters/langchain-adapter.js +179 -0
  21. package/dist/adapters/langchain-adapter.js.map +1 -1
  22. package/dist/adapters/langchain-adapter.mjs +179 -0
  23. package/dist/adapters/langchain-adapter.mjs.map +1 -1
  24. package/dist/client/index.d.mts +4 -190
  25. package/dist/client/index.d.ts +4 -190
  26. package/dist/client/index.js +218 -54
  27. package/dist/client/index.js.map +1 -1
  28. package/dist/client/index.mjs +215 -55
  29. package/dist/client/index.mjs.map +1 -1
  30. package/dist/client/react.d.mts +31 -17
  31. package/dist/client/react.d.ts +31 -17
  32. package/dist/client/react.js +447 -103
  33. package/dist/client/react.js.map +1 -1
  34. package/dist/client/react.mjs +443 -105
  35. package/dist/client/react.mjs.map +1 -1
  36. package/dist/client/vue.d.mts +5 -4
  37. package/dist/client/vue.d.ts +5 -4
  38. package/dist/client/vue.js +239 -63
  39. package/dist/client/vue.js.map +1 -1
  40. package/dist/client/vue.mjs +236 -64
  41. package/dist/client/vue.mjs.map +1 -1
  42. package/dist/index-DcYfpY3H.d.mts +295 -0
  43. package/dist/index-GfC_eNEv.d.ts +295 -0
  44. package/dist/index.d.mts +5 -3
  45. package/dist/index.d.ts +5 -3
  46. package/dist/index.js +1120 -59
  47. package/dist/index.js.map +1 -1
  48. package/dist/index.mjs +1097 -60
  49. package/dist/index.mjs.map +1 -1
  50. package/dist/server/index.d.mts +2 -2
  51. package/dist/server/index.d.ts +2 -2
  52. package/dist/server/index.js +18 -5
  53. package/dist/server/index.js.map +1 -1
  54. package/dist/server/index.mjs +18 -5
  55. package/dist/server/index.mjs.map +1 -1
  56. package/dist/shared/index.d.mts +86 -4
  57. package/dist/shared/index.d.ts +86 -4
  58. package/dist/shared/index.js +874 -0
  59. package/dist/shared/index.js.map +1 -1
  60. package/dist/shared/index.mjs +865 -1
  61. package/dist/shared/index.mjs.map +1 -1
  62. package/dist/tool-router-Bo8qZbsD.d.ts +325 -0
  63. package/dist/tool-router-XnWVxPzv.d.mts +325 -0
  64. package/dist/{types-CW6lghof.d.mts → types-CfCoIsWI.d.mts} +27 -1
  65. package/dist/{types-CW6lghof.d.ts → types-CfCoIsWI.d.ts} +27 -1
  66. package/package.json +15 -12
  67. package/src/adapters/agui-adapter.ts +79 -0
  68. package/src/adapters/ai-adapter.ts +75 -0
  69. package/src/adapters/langchain-adapter.ts +75 -1
  70. package/src/client/core/app-host.ts +252 -65
  71. package/src/client/core/constants.ts +30 -0
  72. package/src/client/index.ts +6 -1
  73. package/src/client/react/index.ts +3 -0
  74. package/src/client/react/use-app-host.ts +8 -15
  75. package/src/client/react/use-mcp-apps.tsx +262 -49
  76. package/src/client/react/use-mcp.ts +23 -12
  77. package/src/client/utils/app-host-utils.ts +62 -0
  78. package/src/client/vue/use-mcp.ts +23 -12
  79. package/src/server/index.ts +2 -0
  80. package/src/server/mcp/oauth-client.ts +34 -9
  81. package/src/shared/index.ts +36 -0
  82. package/src/shared/meta-tools.ts +387 -0
  83. package/src/shared/schema-compressor.ts +124 -0
  84. package/src/shared/tool-index.ts +499 -0
  85. package/src/shared/tool-router.ts +469 -0
  86. package/src/shared/types.ts +30 -0
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Default configuration values for the App Host.
3
+ *
4
+ * `SANDBOX_*_READY_METHOD` match `@modelcontextprotocol/ext-apps` (see
5
+ * https://github.com/modelcontextprotocol/ext-apps/blob/main/src/types.ts ).
6
+ * Duplicated here because the package root `app.d.ts` often omits these value exports under
7
+ * `moduleResolution: "NodeNext"`.
8
+ */
9
+ export const SANDBOX_PROXY_READY_METHOD = 'ui/notifications/sandbox-proxy-ready' as const;
10
+ export const SANDBOX_RESOURCE_READY_METHOD = 'ui/notifications/sandbox-resource-ready' as const;
11
+
12
+ export const APP_HOST_DEFAULTS = {
13
+ /** Default timeout for waiting for the sandbox proxy to be ready (ms). */
14
+ SANDBOX_TIMEOUT_MS: 10000,
15
+
16
+ /** Default host info reported to guest apps. */
17
+ HOST_INFO: { name: 'mcp-ts-host', version: '1.0.0' },
18
+
19
+ /** Supported MCP App URI schemes. */
20
+ URI_SCHEMES: ['ui://', 'mcp-app://'] as const,
21
+
22
+ /** Default theme for the host context. */
23
+ THEME: 'dark',
24
+
25
+ /** Default platform for the host context. */
26
+ PLATFORM: 'web',
27
+
28
+ /** Default max height for the iframe container (px). */
29
+ MAX_HEIGHT: 6000,
30
+ } as const;
@@ -5,7 +5,12 @@
5
5
 
6
6
  /** SSE client for real-time connections */
7
7
  export { SSEClient, type SSEClientOptions } from './core/sse-client';
8
- export { AppHost } from './core/app-host';
8
+ export { AppHost, DEFAULT_MCP_APP_CSP } from './core/app-host';
9
+ export {
10
+ APP_HOST_DEFAULTS,
11
+ SANDBOX_PROXY_READY_METHOD,
12
+ SANDBOX_RESOURCE_READY_METHOD,
13
+ } from './core/constants.js';
9
14
 
10
15
 
11
16
 
@@ -12,7 +12,10 @@ export { useAppHost } from './use-app-host.js';
12
12
  // Simplified MCP Apps Hook - the main API
13
13
  export {
14
14
  useMcpApps,
15
+ McpAppRenderer,
16
+ getMcpAppMetadata,
15
17
  type McpAppRendererProps,
18
+ type McpAppRendererHandle,
16
19
  type McpAppMetadata,
17
20
  } from './use-mcp-apps.js';
18
21
 
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useRef, useState, useCallback } from 'react';
2
- import type { SSEClient } from '../core/sse-client';
3
- import { AppHost } from '../core/app-host';
2
+ import type { AppHostClient } from '../core/types';
3
+ import { AppHost, type AppHostOptions } from '../core/app-host';
4
4
 
5
5
  /**
6
6
  * Hook to host an MCP App in a React component
@@ -16,19 +16,17 @@ import { AppHost } from '../core/app-host';
16
16
  * @param options - Optional configuration
17
17
  * @returns Object containing the AppHost instance (or null) and error state
18
18
  */
19
+ export type UseAppHostOptions = AppHostOptions;
20
+
19
21
  export function useAppHost(
20
- client: SSEClient,
22
+ client: AppHostClient | null,
21
23
  iframeRef: React.RefObject<HTMLIFrameElement>,
22
- options?: {
23
- /** Callback when the App sends a message (e.g. to chat) */
24
- onMessage?: (params: { role: string; content: unknown }) => void;
25
- }
24
+ options?: UseAppHostOptions
26
25
  ) {
27
26
  const [host, setHost] = useState<AppHost | null>(null);
28
27
  const [error, setError] = useState<Error | null>(null);
29
28
  const initializingRef = useRef(false);
30
29
 
31
- // Store latest callback in ref to avoid re-initializing AppHost on callback change
32
30
  const onMessageRef = useRef(options?.onMessage);
33
31
  useEffect(() => {
34
32
  onMessageRef.current = options?.onMessage;
@@ -42,13 +40,8 @@ export function useAppHost(
42
40
 
43
41
  const initHost = async () => {
44
42
  try {
45
- // Initialize AppHost with security enforcement
46
- const appHost = new AppHost(client, iframeRef.current!);
47
-
48
- // Register message handler
49
- appHost.onAppMessage = (params) => {
50
- onMessageRef.current?.(params);
51
- };
43
+ // Initialize AppHost with security enforcement and options
44
+ const appHost = new AppHost(client, iframeRef.current!, options);
52
45
 
53
46
  // Set host immediately so launch can be called
54
47
  // (launch will wait for bridge if needed)
@@ -11,10 +11,16 @@ import React, {
11
11
  useRef,
12
12
  memo,
13
13
  useMemo,
14
+ forwardRef,
15
+ useImperativeHandle,
14
16
  type MutableRefObject,
15
17
  } from 'react';
18
+ import type { UseAppHostOptions } from './use-app-host.js';
16
19
  import { useAppHost } from './use-app-host.js';
20
+ import { resolveMetaToolProxy } from '../../shared/meta-tools.js';
17
21
  import type { SSEClient } from '../core/sse-client.js';
22
+ import { APP_HOST_DEFAULTS } from '../core/constants.js';
23
+ import type { SandboxConfig } from '../core/app-host.js';
18
24
 
19
25
  export interface McpClient {
20
26
  connections: Array<{
@@ -41,14 +47,27 @@ export interface McpAppMetadata {
41
47
  sessionId: string;
42
48
  }
43
49
 
50
+ /**
51
+ * Imperative handle for {@link useMcpApps}'s `McpAppRenderer` (via `ref`),
52
+ * aligned with `@mcp-ui/client`'s `AppRendererHandle.teardownResource`.
53
+ */
54
+ export interface McpAppRendererHandle {
55
+ teardownResource: (params?: Record<string, unknown>) => void;
56
+ }
57
+
44
58
  /** Props for {@link useMcpApps}'s `McpAppRenderer` (client is supplied via the hook). */
45
- export interface McpAppRendererProps {
59
+ export interface McpAppRendererProps extends Pick<UseAppHostOptions, 'sandbox' | 'hostContext' | 'onCallTool' | 'onReadResource' | 'onFallbackRequest' | 'onMessage' | 'onOpenLink' | 'onLoggingMessage' | 'onSizeChanged' | 'onError'> {
46
60
  name: string;
47
- input?: Record<string, unknown>;
61
+ client?: McpClient | null;
62
+ toolResourceUri?: string;
63
+ html?: string;
64
+ input?: Record<string, unknown> | null;
48
65
  result?: unknown;
49
- status: 'executing' | 'inProgress' | 'complete' | 'idle';
50
- /** Custom CSS class for the container */
66
+ status?: 'executing' | 'inProgress' | 'complete' | 'idle';
67
+ toolInputPartial?: any;
68
+ toolCancelled?: boolean;
51
69
  className?: string;
70
+ loader?: React.ReactNode;
52
71
  }
53
72
 
54
73
  type McpAppViewProps = McpAppRendererProps & {
@@ -60,28 +79,127 @@ type McpAppViewProps = McpAppRendererProps & {
60
79
  };
61
80
 
62
81
  /** Renders one MCP App in a sandboxed iframe; reads the latest client from `clientRef` each render. */
63
- const McpAppView = memo(function McpAppView({
64
- clientRef,
65
- name,
66
- input,
67
- result,
68
- status,
69
- className,
70
- }: McpAppViewProps) {
82
+ const McpAppViewInner = forwardRef<McpAppRendererHandle, McpAppViewProps>(function McpAppView(
83
+ {
84
+ clientRef,
85
+ name,
86
+ toolResourceUri,
87
+ html,
88
+ input,
89
+ result,
90
+ status = 'idle',
91
+ toolInputPartial,
92
+ toolCancelled,
93
+ sandbox,
94
+ hostContext,
95
+ onCallTool,
96
+ onReadResource,
97
+ onFallbackRequest,
98
+ onMessage,
99
+ onOpenLink,
100
+ onLoggingMessage,
101
+ onSizeChanged,
102
+ onError: onHostError,
103
+ className,
104
+ loader,
105
+ },
106
+ ref,
107
+ ) {
108
+
71
109
  const mcpClient = clientRef.current;
72
- const metadata = getMcpAppMetadata(mcpClient, name);
110
+ const { toolName: resolvedToolName, args: resolvedInput } = resolveMetaToolProxy(name, input);
111
+ const metadata = getMcpAppMetadata(mcpClient, resolvedToolName, resolvedInput);
73
112
  const sseClient = mcpClient?.sseClient ?? null;
74
- const resourceUri = metadata?.resourceUri;
113
+ const resourceUri = toolResourceUri || metadata?.resourceUri;
75
114
  const appSessionId = metadata?.sessionId;
76
115
 
77
116
  const iframeRef = useRef<HTMLIFrameElement>(null);
78
- const { host, error: hostError } = useAppHost(sseClient as SSEClient, iframeRef);
117
+ const containerRef = useRef<HTMLDivElement>(null);
118
+ // Tracks the last height (px) reported by the guest before/after fullscreen so we
119
+ // can restore it when the native fullscreen API exits and the guest fires a stale
120
+ // resize event that would otherwise collapse the iframe to 0.
121
+ const preFullscreenHeightRef = useRef<number | null>(null);
122
+ const displayModeRef = useRef<'inline' | 'fullscreen'>('inline');
123
+ const [displayMode, setDisplayMode] = useState<'inline' | 'fullscreen'>('inline');
124
+
125
+ const setDisplayModeWithRef = (mode: 'inline' | 'fullscreen') => {
126
+ displayModeRef.current = mode;
127
+ setDisplayMode(mode);
128
+ };
129
+
130
+ const { host, error: hostError } = useAppHost(sseClient as any, iframeRef, {
131
+ sandbox,
132
+ hostContext,
133
+ onCallTool,
134
+ onReadResource,
135
+ onFallbackRequest,
136
+ onMessage,
137
+ onOpenLink,
138
+ onLoggingMessage,
139
+ // Intercept onSizeChanged: when exiting fullscreen, ignore guest resize events
140
+ // that arrive with the shrunken viewport dimensions, and restore the pre-fullscreen height.
141
+ onSizeChanged: (params) => {
142
+ if (displayModeRef.current === 'inline' && preFullscreenHeightRef.current !== null) {
143
+ // Guest fired a resize right after fullscreen exit – restore the saved height
144
+ const savedHeight = preFullscreenHeightRef.current;
145
+ preFullscreenHeightRef.current = null;
146
+ if (iframeRef.current) {
147
+ iframeRef.current.style.height = `${savedHeight}px`;
148
+ }
149
+ return;
150
+ }
151
+ onSizeChanged?.(params);
152
+ },
153
+ onError: onHostError,
154
+ onRequestDisplayMode: async (params) => {
155
+ if (params.mode === 'fullscreen') {
156
+ // Snapshot current iframe height so we can restore on exit
157
+ if (iframeRef.current) {
158
+ const h = iframeRef.current.getBoundingClientRect().height;
159
+ if (h > 0) preFullscreenHeightRef.current = h;
160
+ }
161
+ try {
162
+ if (containerRef.current?.requestFullscreen) {
163
+ await containerRef.current.requestFullscreen();
164
+ } else if ((containerRef.current as any)?.webkitRequestFullscreen) {
165
+ await (containerRef.current as any).webkitRequestFullscreen();
166
+ }
167
+ setDisplayModeWithRef('fullscreen');
168
+ } catch (err) {
169
+ console.warn('[McpAppHost] requestFullscreen failed:', err);
170
+ preFullscreenHeightRef.current = null;
171
+ return { mode: 'inline' };
172
+ }
173
+ } else if (params.mode === 'inline') {
174
+ // Eagerly restore height — don't wait for a guest onsizechange that may never arrive
175
+ restoreHeightAfterFullscreen();
176
+ try {
177
+ if (document.fullscreenElement) {
178
+ await document.exitFullscreen();
179
+ }
180
+ } catch (err) {}
181
+ setDisplayModeWithRef('inline');
182
+ }
183
+ return { mode: params.mode };
184
+ }
185
+ });
186
+
187
+ useImperativeHandle(
188
+ ref,
189
+ () => ({
190
+ teardownResource: (params?: Record<string, unknown>) => {
191
+ host?.teardownResource(params ?? {});
192
+ },
193
+ }),
194
+ [host],
195
+ );
196
+
79
197
  const [isLaunched, setIsLaunched] = useState(false);
80
198
  const [error, setError] = useState<Error | null>(null);
81
199
 
82
200
  const sentInputRef = useRef(false);
83
201
  const sentResultRef = useRef(false);
84
- const lastInputRef = useRef(input);
202
+ const lastInputRef = useRef(resolvedInput);
85
203
  const lastResultRef = useRef(result);
86
204
  const lastStatusRef = useRef(status);
87
205
 
@@ -90,24 +208,49 @@ const McpAppView = memo(function McpAppView({
90
208
  setError(null);
91
209
  }, [resourceUri, appSessionId]);
92
210
 
211
+ // Eagerly restore the iframe's pre-fullscreen height at every exit point.
212
+ // The guest app may NOT fire onSizeChanged after exiting fullscreen, so we cannot
213
+ // rely on the onSizeChanged interceptor to restore the height.
214
+ const restoreHeightAfterFullscreen = () => {
215
+ const savedHeight = preFullscreenHeightRef.current;
216
+ if (savedHeight && iframeRef.current) {
217
+ iframeRef.current.style.height = `${savedHeight}px`;
218
+ }
219
+ preFullscreenHeightRef.current = null;
220
+ };
221
+
93
222
  useEffect(() => {
94
- if (!host || !resourceUri || !appSessionId) return;
223
+ const onFullscreenChange = () => {
224
+ const isFullscreen = !!document.fullscreenElement;
225
+ // Use ref to avoid stale closure (ESC key exit path)
226
+ if (!isFullscreen && displayModeRef.current === 'fullscreen') {
227
+ restoreHeightAfterFullscreen();
228
+ setDisplayModeWithRef('inline');
229
+ }
230
+ };
231
+ document.addEventListener('fullscreenchange', onFullscreenChange);
232
+ return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
233
+ }, []); // stable – reads from refs only, no stale closure over state
234
+
235
+ useEffect(() => {
236
+ if (!host || (!resourceUri && !html)) return;
95
237
 
96
238
  host
97
- .launch(resourceUri, appSessionId)
239
+ .launch({ uri: resourceUri, html }, appSessionId)
98
240
  .then(() => setIsLaunched(true))
99
241
  .catch((err) => setError(err instanceof Error ? err : new Error(String(err))));
100
- }, [host, resourceUri, appSessionId]);
242
+ }, [host, resourceUri, html, appSessionId]);
101
243
 
244
+ // Send tool inputs
102
245
  useEffect(() => {
103
- if (!host || !isLaunched || !resourceUri || !appSessionId || !input) return;
246
+ if (!host || !isLaunched || !resourceUri || !appSessionId || !resolvedInput) return;
104
247
 
105
- if (!sentInputRef.current || JSON.stringify(input) !== JSON.stringify(lastInputRef.current)) {
248
+ if (!sentInputRef.current || JSON.stringify(resolvedInput) !== JSON.stringify(lastInputRef.current)) {
106
249
  sentInputRef.current = true;
107
- lastInputRef.current = input;
108
- host.sendToolInput(input);
250
+ lastInputRef.current = resolvedInput;
251
+ host.sendToolInput(resolvedInput);
109
252
  }
110
- }, [host, isLaunched, input, resourceUri, appSessionId, name]);
253
+ }, [host, isLaunched, resolvedInput, resourceUri, appSessionId, resolvedToolName]);
111
254
 
112
255
  useEffect(() => {
113
256
  if (!host || !isLaunched || !resourceUri || !appSessionId || result === undefined) return;
@@ -122,7 +265,7 @@ const McpAppView = memo(function McpAppView({
122
265
  : result;
123
266
  host.sendToolResult(formattedResult);
124
267
  }
125
- }, [host, isLaunched, result, status, resourceUri, appSessionId, name]);
268
+ }, [host, isLaunched, result, status, resourceUri, appSessionId, resolvedToolName]);
126
269
 
127
270
  useEffect(() => {
128
271
  if (status === 'executing' && lastStatusRef.current !== 'executing') {
@@ -132,7 +275,30 @@ const McpAppView = memo(function McpAppView({
132
275
  lastStatusRef.current = status;
133
276
  }, [status]);
134
277
 
135
- if (!metadata || !sseClient) {
278
+ useEffect(() => {
279
+ if (!host) return;
280
+ // Merge user-provided hostContext with our internal displayMode, then notify the guest.
281
+ // This causes Excalidraw (and other MCP apps) to switch between inline/fullscreen UI mode.
282
+ const mergedCtx = {
283
+ theme: APP_HOST_DEFAULTS.THEME,
284
+ platform: APP_HOST_DEFAULTS.PLATFORM,
285
+ containerDimensions: { maxHeight: APP_HOST_DEFAULTS.MAX_HEIGHT },
286
+ availableDisplayModes: ['inline', 'fullscreen'],
287
+ ...(hostContext || {}),
288
+ displayMode, // always override with our authoritative state
289
+ };
290
+ host.setHostContext(mergedCtx);
291
+ }, [host, hostContext, displayMode]);
292
+
293
+ useEffect(() => {
294
+ if (host && toolInputPartial) host.sendToolInputPartial(toolInputPartial);
295
+ }, [host, toolInputPartial]);
296
+
297
+ useEffect(() => {
298
+ if (host && toolCancelled) host.sendToolCancelled("User cancelled");
299
+ }, [host, toolCancelled]);
300
+
301
+ if (!metadata && !html && !toolResourceUri) {
136
302
  return null;
137
303
  }
138
304
 
@@ -145,48 +311,93 @@ const McpAppView = memo(function McpAppView({
145
311
  );
146
312
  }
147
313
 
314
+ const opacityClass = isLaunched ? 'opacity-100' : 'opacity-0';
315
+ let containerClass = `w-full border border-gray-700 rounded bg-transparent my-2 relative ${className || ''}`;
316
+ let iframeClass = `w-full transition-opacity duration-300 ${opacityClass}`;
317
+
318
+ // When native fullscreen is active, the container naturally expands via the browser API.
319
+ // We only need to satisfy flex layout so the iframe fills 100% of the fullscreen viewport.
320
+ if (displayMode === 'fullscreen') {
321
+ containerClass = `w-full h-full bg-black m-0 p-0 flex flex-col relative`;
322
+ iframeClass = `w-full flex-1 transition-opacity duration-300 ${opacityClass}`;
323
+ }
324
+
148
325
  return (
149
- <div className={`w-full border border-gray-700 rounded overflow-hidden bg-white min-h-96 my-2 relative ${className || ''}`}>
326
+ <div ref={containerRef} className={containerClass}>
327
+ {displayMode === 'fullscreen' && (
328
+ <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">
329
+ <button
330
+ title="Exit Fullscreen"
331
+ onClick={() => {
332
+ // Eagerly restore height before the browser animation completes
333
+ restoreHeightAfterFullscreen();
334
+ if (document.fullscreenElement) document.exitFullscreen();
335
+ setDisplayModeWithRef('inline');
336
+ }}
337
+ 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"
338
+ >
339
+ <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>
340
+ <span className="text-sm font-medium">Exit</span>
341
+ </button>
342
+ </div>
343
+ )}
150
344
  <iframe
151
345
  ref={iframeRef}
152
346
  sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-popups allow-downloads"
153
- className="w-full h-full min-h-96"
154
- style={{ height: 'auto' }}
347
+ allow="fullscreen"
348
+ className={iframeClass}
155
349
  title="MCP App"
156
350
  />
157
- {!isLaunched && (
158
- <div className="absolute inset-0 bg-gray-900/50 flex items-center justify-center pointer-events-none">
159
- <div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
351
+ {!isLaunched && loader && (
352
+ <div className="absolute inset-0 bg-transparent flex items-center justify-center pointer-events-none z-10">
353
+ {loader}
160
354
  </div>
161
355
  )}
162
356
  </div>
163
357
  );
164
358
  });
165
359
 
360
+ const McpAppView = memo(McpAppViewInner);
361
+ McpAppView.displayName = 'McpAppView';
362
+
363
+ /**
364
+ * Renders an interactive MCP application inside a sandboxed iframe.
365
+ */
366
+ export const McpAppRenderer = memo(
367
+ forwardRef<McpAppRendererHandle, McpAppRendererProps>(function McpAppRenderer(
368
+ { client, ...props },
369
+ ref
370
+ ) {
371
+ const clientRef = useRef(client || null);
372
+ clientRef.current = client || null;
373
+
374
+ return <McpAppView ref={ref} clientRef={clientRef} {...props} />;
375
+ })
376
+ );
377
+
166
378
  /**
167
379
  * Helpers scoped to one `mcpClient`. Pass the client here once; `McpAppRenderer` only needs per-tool props (`name`, `input`, `result`, `status`).
168
380
  *
169
381
  * @param mcpClient - From `useMcp()` or context (for example `useMcpContext()`).
382
+ * @deprecated Use the standalone `<McpAppRenderer>` component and `getMcpAppMetadata` utility directly.
170
383
  */
171
384
  export function useMcpApps(mcpClient: McpClient | null) {
172
- // Stable `McpAppRenderer` type: parent re-renders and `connections` updates must not remount the iframe.
173
- const clientRef = useRef(mcpClient);
174
- clientRef.current = mcpClient;
175
-
176
385
  const getAppMetadata = useCallback(
177
- (toolName: string) => getMcpAppMetadata(clientRef.current, toolName),
178
- []
386
+ (toolName: string) => getMcpAppMetadata(mcpClient, toolName),
387
+ [mcpClient]
179
388
  );
180
389
 
181
- const McpAppRenderer = useMemo(() => {
182
- const Renderer = memo(function McpAppRenderer(props: McpAppRendererProps) {
183
- return <McpAppView clientRef={clientRef} {...props} />;
184
- });
185
- Renderer.displayName = 'McpAppRenderer';
186
- return Renderer;
187
- }, []);
390
+ const BoundMcpAppRenderer = useMemo(() => {
391
+ const Renderer = forwardRef<McpAppRendererHandle, Omit<McpAppRendererProps, 'client'>>(
392
+ function BoundMcpAppRenderer(props, ref) {
393
+ return <McpAppRenderer ref={ref} client={mcpClient} {...props} />;
394
+ }
395
+ );
396
+ Renderer.displayName = 'BoundMcpAppRenderer';
397
+ return memo(Renderer);
398
+ }, [mcpClient]);
188
399
 
189
- return { getAppMetadata, McpAppRenderer };
400
+ return { getAppMetadata, McpAppRenderer: BoundMcpAppRenderer };
190
401
  }
191
402
 
192
403
  function extractToolName(fullName: string): string {
@@ -194,13 +405,15 @@ function extractToolName(fullName: string): string {
194
405
  return match?.[1] || fullName;
195
406
  }
196
407
 
197
- function getMcpAppMetadata(
408
+ export function getMcpAppMetadata(
198
409
  mcpClient: McpClient | null,
199
- toolName: string
410
+ toolName: string,
411
+ input?: Record<string, unknown> | null
200
412
  ): McpAppMetadata | undefined {
201
413
  if (!mcpClient) return undefined;
202
414
 
203
- const extractedName = extractToolName(toolName);
415
+ const { toolName: proxyToolName } = resolveMetaToolProxy(toolName, input);
416
+ const extractedName = extractToolName(proxyToolName);
204
417
 
205
418
  for (const conn of mcpClient.connections) {
206
419
  for (const tool of conn.tools) {
@@ -343,21 +343,32 @@ export function useMcp(options: UseMcpOptions): McpClient {
343
343
  }
344
344
 
345
345
  case 'auth_required': {
346
- // Handle OAuth redirect
347
- if (event.authUrl) {
348
- onLog?.('info', `OAuth required - redirecting to ${event.authUrl}`, { authUrl: event.authUrl });
349
-
350
- // Suppress redirects/popups for auto-restore on page load.
351
- if (!suppressAuthRedirectSessionsRef.current.has(event.sessionId)) {
352
- if (onRedirect) {
353
- onRedirect(event.authUrl);
354
- } else if (typeof window !== 'undefined') {
355
- window.location.href = event.authUrl;
356
- }
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;
357
368
  }
358
369
  }
359
370
  return prev.map((c: McpConnection) =>
360
- c.sessionId === event.sessionId ? { ...c, state: 'AUTHENTICATING', authUrl: event.authUrl } : c
371
+ c.sessionId === event.sessionId ? { ...c, state: 'AUTHENTICATING', authUrl: url } : c
361
372
  );
362
373
  }
363
374
 
@@ -0,0 +1,62 @@
1
+ import { APP_HOST_DEFAULTS, SANDBOX_PROXY_READY_METHOD } from '../core/constants.js';
2
+
3
+ const DEFAULT_SANDBOX_TIMEOUT_MS = APP_HOST_DEFAULTS.SANDBOX_TIMEOUT_MS;
4
+
5
+ export async function setupSandboxProxyIframe(
6
+ iframe: HTMLIFrameElement,
7
+ sandboxProxyUrl: URL
8
+ ): Promise<{
9
+ onReady: Promise<void>;
10
+ }> {
11
+ iframe.style.width = '100%';
12
+ iframe.style.height = '100%';
13
+ iframe.style.border = 'none';
14
+ iframe.style.backgroundColor = 'transparent';
15
+ iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-modals allow-popups allow-downloads');
16
+
17
+ const onReady = new Promise<void>((resolve, reject) => {
18
+ let settled = false;
19
+
20
+ const cleanup = () => {
21
+ window.removeEventListener('message', messageListener);
22
+ iframe.removeEventListener('error', errorListener);
23
+ };
24
+
25
+ const timeoutId = setTimeout(() => {
26
+ if (!settled) {
27
+ settled = true;
28
+ cleanup();
29
+ reject(new Error('Timed out waiting for sandbox proxy iframe to be ready'));
30
+ }
31
+ }, DEFAULT_SANDBOX_TIMEOUT_MS);
32
+
33
+ const messageListener = (event: MessageEvent) => {
34
+ if (event.source === iframe.contentWindow) {
35
+ if (event.data?.method === SANDBOX_PROXY_READY_METHOD) {
36
+ if (!settled) {
37
+ settled = true;
38
+ clearTimeout(timeoutId);
39
+ cleanup();
40
+ resolve();
41
+ }
42
+ }
43
+ }
44
+ };
45
+
46
+ const errorListener = () => {
47
+ if (!settled) {
48
+ settled = true;
49
+ clearTimeout(timeoutId);
50
+ cleanup();
51
+ reject(new Error('Failed to load sandbox proxy iframe'));
52
+ }
53
+ };
54
+
55
+ window.addEventListener('message', messageListener);
56
+ iframe.addEventListener('error', errorListener);
57
+ });
58
+
59
+ iframe.src = sandboxProxyUrl.href;
60
+
61
+ return { onReady };
62
+ }