@mcp-ts/sdk 1.3.10 → 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 (44) hide show
  1. package/dist/adapters/langchain-adapter.js.map +1 -1
  2. package/dist/adapters/langchain-adapter.mjs.map +1 -1
  3. package/dist/client/index.d.mts +3 -189
  4. package/dist/client/index.d.ts +3 -189
  5. package/dist/client/index.js +218 -54
  6. package/dist/client/index.js.map +1 -1
  7. package/dist/client/index.mjs +215 -55
  8. package/dist/client/index.mjs.map +1 -1
  9. package/dist/client/react.d.mts +21 -14
  10. package/dist/client/react.d.ts +21 -14
  11. package/dist/client/react.js +402 -83
  12. package/dist/client/react.js.map +1 -1
  13. package/dist/client/react.mjs +400 -85
  14. package/dist/client/react.mjs.map +1 -1
  15. package/dist/client/vue.d.mts +3 -2
  16. package/dist/client/vue.d.ts +3 -2
  17. package/dist/client/vue.js +239 -63
  18. package/dist/client/vue.js.map +1 -1
  19. package/dist/client/vue.mjs +236 -64
  20. package/dist/client/vue.mjs.map +1 -1
  21. package/dist/index-CQr9q0bF.d.mts +295 -0
  22. package/dist/index-nE_7Io0I.d.ts +295 -0
  23. package/dist/index.d.mts +2 -1
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.js +237 -58
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +230 -59
  28. package/dist/index.mjs.map +1 -1
  29. package/dist/server/index.js +15 -4
  30. package/dist/server/index.js.map +1 -1
  31. package/dist/server/index.mjs +15 -4
  32. package/dist/server/index.mjs.map +1 -1
  33. package/package.json +13 -11
  34. package/src/adapters/langchain-adapter.ts +1 -1
  35. package/src/client/core/app-host.ts +252 -65
  36. package/src/client/core/constants.ts +30 -0
  37. package/src/client/index.ts +6 -1
  38. package/src/client/react/index.ts +1 -0
  39. package/src/client/react/use-app-host.ts +8 -15
  40. package/src/client/react/use-mcp-apps.tsx +221 -26
  41. package/src/client/react/use-mcp.ts +23 -12
  42. package/src/client/utils/app-host-utils.ts +62 -0
  43. package/src/client/vue/use-mcp.ts +23 -12
  44. package/src/server/mcp/oauth-client.ts +31 -8
@@ -11,10 +11,14 @@ import React, {
11
11
  useRef,
12
12
  memo,
13
13
  useMemo,
14
+ forwardRef,
15
+ useImperativeHandle,
14
16
  type MutableRefObject,
15
17
  } from 'react';
16
- import { useAppHost } from './use-app-host.js';
18
+ import { useAppHost, type UseAppHostOptions } from './use-app-host.js';
17
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';
18
22
 
19
23
  export interface McpClient {
20
24
  connections: Array<{
@@ -41,14 +45,26 @@ export interface McpAppMetadata {
41
45
  sessionId: string;
42
46
  }
43
47
 
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
+
44
56
  /** Props for {@link useMcpApps}'s `McpAppRenderer` (client is supplied via the hook). */
45
- export interface McpAppRendererProps {
57
+ export interface McpAppRendererProps extends Pick<UseAppHostOptions, 'sandbox' | 'hostContext' | 'onCallTool' | 'onReadResource' | 'onFallbackRequest' | 'onMessage' | 'onOpenLink' | 'onLoggingMessage' | 'onSizeChanged' | 'onError'> {
46
58
  name: string;
59
+ toolResourceUri?: string;
60
+ html?: string;
47
61
  input?: Record<string, unknown>;
48
62
  result?: unknown;
49
- status: 'executing' | 'inProgress' | 'complete' | 'idle';
50
- /** Custom CSS class for the container */
63
+ status?: 'executing' | 'inProgress' | 'complete' | 'idle';
64
+ toolInputPartial?: any;
65
+ toolCancelled?: boolean;
51
66
  className?: string;
67
+ loader?: React.ReactNode;
52
68
  }
53
69
 
54
70
  type McpAppViewProps = McpAppRendererProps & {
@@ -60,22 +76,119 @@ type McpAppViewProps = McpAppRendererProps & {
60
76
  };
61
77
 
62
78
  /** 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) {
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
+ ) {
71
105
  const mcpClient = clientRef.current;
72
106
  const metadata = getMcpAppMetadata(mcpClient, name);
73
107
  const sseClient = mcpClient?.sseClient ?? null;
74
- const resourceUri = metadata?.resourceUri;
108
+ const resourceUri = toolResourceUri || metadata?.resourceUri;
75
109
  const appSessionId = metadata?.sessionId;
76
110
 
77
111
  const iframeRef = useRef<HTMLIFrameElement>(null);
78
- const { host, error: hostError } = useAppHost(sseClient as SSEClient, iframeRef);
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' };
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');
177
+ }
178
+ return { mode: params.mode };
179
+ }
180
+ });
181
+
182
+ useImperativeHandle(
183
+ ref,
184
+ () => ({
185
+ teardownResource: (params?: Record<string, unknown>) => {
186
+ host?.teardownResource(params ?? {});
187
+ },
188
+ }),
189
+ [host],
190
+ );
191
+
79
192
  const [isLaunched, setIsLaunched] = useState(false);
80
193
  const [error, setError] = useState<Error | null>(null);
81
194
 
@@ -90,14 +203,38 @@ const McpAppView = memo(function McpAppView({
90
203
  setError(null);
91
204
  }, [resourceUri, appSessionId]);
92
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
+
93
217
  useEffect(() => {
94
- if (!host || !resourceUri || !appSessionId) return;
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;
95
232
 
96
233
  host
97
- .launch(resourceUri, appSessionId)
234
+ .launch({ uri: resourceUri, html }, appSessionId)
98
235
  .then(() => setIsLaunched(true))
99
236
  .catch((err) => setError(err instanceof Error ? err : new Error(String(err))));
100
- }, [host, resourceUri, appSessionId]);
237
+ }, [host, resourceUri, html, appSessionId]);
101
238
 
102
239
  useEffect(() => {
103
240
  if (!host || !isLaunched || !resourceUri || !appSessionId || !input) return;
@@ -132,7 +269,30 @@ const McpAppView = memo(function McpAppView({
132
269
  lastStatusRef.current = status;
133
270
  }, [status]);
134
271
 
135
- if (!metadata || !sseClient) {
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) {
136
296
  return null;
137
297
  }
138
298
 
@@ -145,24 +305,55 @@ const McpAppView = memo(function McpAppView({
145
305
  );
146
306
  }
147
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
+
148
319
  return (
149
- <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
+ )}
150
338
  <iframe
151
339
  ref={iframeRef}
152
340
  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' }}
341
+ allow="fullscreen"
342
+ className={iframeClass}
155
343
  title="MCP App"
156
344
  />
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" />
345
+ {!isLaunched && loader && (
346
+ <div className="absolute inset-0 bg-transparent flex items-center justify-center pointer-events-none z-10">
347
+ {loader}
160
348
  </div>
161
349
  )}
162
350
  </div>
163
351
  );
164
352
  });
165
353
 
354
+ const McpAppView = memo(McpAppViewInner);
355
+ McpAppView.displayName = 'McpAppView';
356
+
166
357
  /**
167
358
  * Helpers scoped to one `mcpClient`. Pass the client here once; `McpAppRenderer` only needs per-tool props (`name`, `input`, `result`, `status`).
168
359
  *
@@ -179,9 +370,13 @@ export function useMcpApps(mcpClient: McpClient | null) {
179
370
  );
180
371
 
181
372
  const McpAppRenderer = useMemo(() => {
182
- const Renderer = memo(function McpAppRenderer(props: McpAppRendererProps) {
183
- return <McpAppView clientRef={clientRef} {...props} />;
373
+ const Inner = forwardRef<McpAppRendererHandle, McpAppRendererProps>(function McpAppRenderer(
374
+ props,
375
+ ref,
376
+ ) {
377
+ return <McpAppView ref={ref} clientRef={clientRef} {...props} />;
184
378
  });
379
+ const Renderer = memo(Inner);
185
380
  Renderer.displayName = 'McpAppRenderer';
186
381
  return Renderer;
187
382
  }, []);
@@ -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
+ }
@@ -285,22 +285,33 @@ export function useMcp(options: UseMcpOptions): McpClient {
285
285
  }
286
286
 
287
287
  case 'auth_required': {
288
- // Handle OAuth redirect
289
- if (event.authUrl) {
290
- onLog?.('info', `OAuth required - redirecting to ${event.authUrl}`, { authUrl: event.authUrl });
291
-
292
- // Suppress redirects/popups for background auto-restore on page load.
293
- if (!suppressAuthRedirectSessions.value.has(event.sessionId)) {
294
- if (onRedirect) {
295
- onRedirect(event.authUrl);
296
- } else if (typeof window !== 'undefined') {
297
- window.location.href = event.authUrl;
298
- }
288
+ const url = (event.authUrl || '').trim();
289
+ if (!url) {
290
+ onLog?.('error', 'OAuth required but authorization URL is missing', { sessionId: event.sessionId });
291
+ const index = connections.value.findIndex((c) => c.sessionId === event.sessionId);
292
+ if (index !== -1) {
293
+ connections.value[index] = {
294
+ ...connections.value[index],
295
+ state: 'FAILED',
296
+ error: 'OAuth authorization URL not available',
297
+ authUrl: undefined,
298
+ };
299
+ }
300
+ break;
301
+ }
302
+ onLog?.('info', `OAuth required - redirecting to ${url}`, { authUrl: url });
303
+
304
+ // Suppress redirects/popups for background auto-restore on page load.
305
+ if (!suppressAuthRedirectSessions.value.has(event.sessionId)) {
306
+ if (onRedirect) {
307
+ onRedirect(url);
308
+ } else if (typeof window !== 'undefined') {
309
+ window.location.href = url;
299
310
  }
300
311
  }
301
312
  const index = connections.value.findIndex((c) => c.sessionId === event.sessionId);
302
313
  if (index !== -1) {
303
- connections.value[index] = { ...connections.value[index], state: 'AUTHENTICATING', authUrl: event.authUrl };
314
+ connections.value[index] = { ...connections.value[index], state: 'AUTHENTICATING', authUrl: url };
304
315
  }
305
316
  break;
306
317
  }
@@ -51,12 +51,12 @@ export type TransportType = 'sse' | 'streamable_http';
51
51
  */
52
52
  import type { ClientCapabilities } from '@modelcontextprotocol/sdk/types.js';
53
53
 
54
- interface McpAppClientCapabilities extends ClientCapabilities {
54
+ interface McpAppClientCapabilities extends Omit<ClientCapabilities, 'extensions'> {
55
55
  extensions?: {
56
56
  'io.modelcontextprotocol/ui'?: {
57
57
  mimeTypes: string[];
58
58
  };
59
- [key: string]: unknown;
59
+ [key: string]: any;
60
60
  };
61
61
  }
62
62
 
@@ -516,17 +516,40 @@ export class MCPClient {
516
516
  error instanceof SDKUnauthorizedError ||
517
517
  (error instanceof Error && error.message.toLowerCase().includes('unauthorized'))
518
518
  ) {
519
+ /** Set when the SDK calls redirectToAuthorization on the OAuth provider */
520
+ let authUrl = '';
521
+ if (this.oauthProvider) {
522
+ authUrl = (this.oauthProvider.authUrl || '').trim();
523
+ }
524
+
525
+ /**
526
+ * 401 without a usable URL means metadata/DCR failed or the server never started
527
+ * an interactive OAuth flow — not recoverable as "pending OAuth".
528
+ */
529
+ if (!authUrl) {
530
+ const detail =
531
+ error instanceof Error && error.message.trim().length > 0
532
+ ? error.message.trim()
533
+ : 'Unauthorized';
534
+ const message =
535
+ detail.toLowerCase() === 'unauthorized'
536
+ ? 'OAuth authorization URL not available'
537
+ : `OAuth authorization URL not available: ${detail}`;
538
+ this.emitError(message, 'auth');
539
+ this.emitStateChange('FAILED');
540
+ try {
541
+ await storage.removeSession(this.identity, this.sessionId);
542
+ } catch {
543
+ // best-effort cleanup
544
+ }
545
+ throw new Error(message);
546
+ }
547
+
519
548
  this.emitStateChange('AUTHENTICATING');
520
549
  // Save session with 10min TTL for OAuth pending state
521
550
  console.log(`[MCPClient] Saving session ${this.sessionId} with 10min TTL (OAuth pending)`);
522
551
  await this.saveSession(Math.floor(STATE_EXPIRATION_MS / 1000), false);
523
552
 
524
- /** Get OAuth authorization URL if available */
525
- let authUrl = '';
526
- if (this.oauthProvider) {
527
- authUrl = this.oauthProvider.authUrl || '';
528
- }
529
-
530
553
  if (this.serverId) {
531
554
  this._onConnectionEvent.fire({
532
555
  type: 'auth_required',