@mcp-ts/sdk 1.5.0 → 1.5.2

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 (66) hide show
  1. package/dist/adapters/agui-adapter.d.mts +1 -1
  2. package/dist/adapters/agui-adapter.d.ts +1 -1
  3. package/dist/adapters/agui-adapter.js +43 -9
  4. package/dist/adapters/agui-adapter.js.map +1 -1
  5. package/dist/adapters/agui-adapter.mjs +43 -9
  6. package/dist/adapters/agui-adapter.mjs.map +1 -1
  7. package/dist/adapters/agui-middleware.d.mts +1 -1
  8. package/dist/adapters/agui-middleware.d.ts +1 -1
  9. package/dist/adapters/agui-middleware.js.map +1 -1
  10. package/dist/adapters/agui-middleware.mjs.map +1 -1
  11. package/dist/adapters/ai-adapter.d.mts +1 -1
  12. package/dist/adapters/ai-adapter.d.ts +1 -1
  13. package/dist/adapters/ai-adapter.js +42 -8
  14. package/dist/adapters/ai-adapter.js.map +1 -1
  15. package/dist/adapters/ai-adapter.mjs +42 -8
  16. package/dist/adapters/ai-adapter.mjs.map +1 -1
  17. package/dist/adapters/langchain-adapter.d.mts +1 -1
  18. package/dist/adapters/langchain-adapter.d.ts +1 -1
  19. package/dist/adapters/langchain-adapter.js +42 -8
  20. package/dist/adapters/langchain-adapter.js.map +1 -1
  21. package/dist/adapters/langchain-adapter.mjs +42 -8
  22. package/dist/adapters/langchain-adapter.mjs.map +1 -1
  23. package/dist/client/react.d.mts +91 -2
  24. package/dist/client/react.d.ts +91 -2
  25. package/dist/client/react.js +339 -3
  26. package/dist/client/react.js.map +1 -1
  27. package/dist/client/react.mjs +335 -4
  28. package/dist/client/react.mjs.map +1 -1
  29. package/dist/client/vue.d.mts +10 -0
  30. package/dist/client/vue.d.ts +10 -0
  31. package/dist/client/vue.js +28 -2
  32. package/dist/client/vue.js.map +1 -1
  33. package/dist/client/vue.mjs +28 -2
  34. package/dist/client/vue.mjs.map +1 -1
  35. package/dist/index.d.mts +1 -1
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.js +170 -37
  38. package/dist/index.js.map +1 -1
  39. package/dist/index.mjs +170 -37
  40. package/dist/index.mjs.map +1 -1
  41. package/dist/server/index.js +55 -11
  42. package/dist/server/index.js.map +1 -1
  43. package/dist/server/index.mjs +55 -11
  44. package/dist/server/index.mjs.map +1 -1
  45. package/dist/shared/index.d.mts +2 -2
  46. package/dist/shared/index.d.ts +2 -2
  47. package/dist/shared/index.js +115 -26
  48. package/dist/shared/index.js.map +1 -1
  49. package/dist/shared/index.mjs +115 -26
  50. package/dist/shared/index.mjs.map +1 -1
  51. package/dist/{tool-router-XnWVxPzv.d.mts → tool-router-DK0RJblO.d.mts} +3 -0
  52. package/dist/{tool-router-Bo8qZbsD.d.ts → tool-router-DsKhRmJm.d.ts} +3 -0
  53. package/package.json +1 -1
  54. package/src/adapters/agui-adapter.ts +7 -7
  55. package/src/adapters/ai-adapter.ts +5 -5
  56. package/src/adapters/langchain-adapter.ts +5 -5
  57. package/src/client/react/index.ts +14 -0
  58. package/src/client/react/oauth-popup.tsx +446 -0
  59. package/src/client/react/use-mcp.ts +84 -3
  60. package/src/client/vue/use-mcp.ts +80 -3
  61. package/src/server/handlers/sse-handler.ts +39 -0
  62. package/src/server/mcp/oauth-client.ts +32 -14
  63. package/src/shared/meta-tools.ts +62 -13
  64. package/src/shared/tool-index.ts +85 -12
  65. package/src/shared/tool-router.ts +8 -7
  66. package/supabase/migrations/20260421010000_add_session_cleanup_cron.sql +32 -0
@@ -137,11 +137,11 @@ export class AIAdapter {
137
137
  // @ts-ignore: ToolSet type inference can be tricky with dynamic imports
138
138
  return Object.fromEntries(
139
139
  filteredTools.map((tool) => {
140
- const routedTool = tool as typeof tool & { sessionId?: string; serverName?: string };
141
- const namespace = routedTool.serverName ?? routedTool.sessionId;
140
+ const routedTool = tool as typeof tool & { sessionId?: string; serverId?: string; serverName?: string };
141
+ const namespace = routedTool.serverId ?? routedTool.sessionId;
142
142
  const toolKey = isMetaTool(tool.name)
143
143
  ? tool.name
144
- : this.getRouterToolKey(tool.name, routedTool.sessionId, routedTool.serverName);
144
+ : this.getRouterToolKey(tool.name, routedTool.sessionId, routedTool.serverId);
145
145
 
146
146
  return [
147
147
  toolKey,
@@ -172,8 +172,8 @@ export class AIAdapter {
172
172
  );
173
173
  }
174
174
 
175
- private getRouterToolKey(toolName: string, sessionId?: string, serverName?: string): string {
176
- const namespace = sessionId ?? serverName ?? 'mcp';
175
+ private getRouterToolKey(toolName: string, sessionId?: string, serverId?: string): string {
176
+ const namespace = sessionId ?? serverId ?? 'mcp';
177
177
  const normalized = namespace
178
178
  .toLowerCase()
179
179
  .replace(/[^a-z0-9]+/g, '_')
@@ -144,14 +144,14 @@ export class LangChainAdapter {
144
144
  const filteredTools = await router.getFilteredTools();
145
145
 
146
146
  return filteredTools.map((tool) => {
147
- const routedTool = tool as typeof tool & { sessionId?: string; serverName?: string };
148
- const namespace = routedTool.serverName ?? routedTool.sessionId;
147
+ const routedTool = tool as typeof tool & { sessionId?: string; serverId?: string; serverName?: string };
148
+ const namespace = routedTool.serverId ?? routedTool.sessionId;
149
149
  const schema = this.jsonSchemaToZod(tool.inputSchema);
150
150
 
151
151
  return new this.DynamicStructuredTool!({
152
152
  name: isMetaTool(tool.name)
153
153
  ? tool.name
154
- : this.getRouterToolKey(tool.name, routedTool.sessionId, routedTool.serverName),
154
+ : this.getRouterToolKey(tool.name, routedTool.sessionId, routedTool.serverId),
155
155
  description: tool.description || `Tool ${tool.name}`,
156
156
  schema: schema,
157
157
  func: async (args: any) => {
@@ -183,8 +183,8 @@ export class LangChainAdapter {
183
183
  });
184
184
  }
185
185
 
186
- private getRouterToolKey(toolName: string, sessionId?: string, serverName?: string): string {
187
- const namespace = sessionId ?? serverName ?? 'mcp';
186
+ private getRouterToolKey(toolName: string, sessionId?: string, serverId?: string): string {
187
+ const namespace = sessionId ?? serverId ?? 'mcp';
188
188
  const normalized = namespace
189
189
  .toLowerCase()
190
190
  .replace(/[^a-z0-9]+/g, '_')
@@ -6,6 +6,20 @@
6
6
  // Core MCP Hook
7
7
  export { useMcp, type UseMcpOptions, type McpClient, type McpConnection } from './use-mcp.js';
8
8
 
9
+ // Optional OAuth popup conveniences. These are not required for auth:
10
+ // consumers can still provide their own onRedirect handler, callback page UI,
11
+ // or complete `finishAuth(sessionId, code)` from a normal redirect flow.
12
+ export {
13
+ useMcpOAuthPopup,
14
+ openCenteredPopup,
15
+ createOAuthPopupRedirectHandler,
16
+ McpOAuthCallbackContent,
17
+ McpOAuthCallbackFallback,
18
+ type OAuthPopupConnectionLike,
19
+ type OAuthPopupRedirectOptions,
20
+ type McpOAuthCallbackContentProps,
21
+ } from './oauth-popup.js';
22
+
9
23
  // App Host (internal use)
10
24
  export { useAppHost } from './use-app-host.js';
11
25
 
@@ -0,0 +1,446 @@
1
+ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
2
+
3
+ export interface OAuthPopupConnectionLike {
4
+ sessionId: string;
5
+ state: string;
6
+ error?: string;
7
+ }
8
+
9
+ /**
10
+ * Optional helpers for popup-based OAuth UX.
11
+ *
12
+ * These utilities sit on top of the core MCP auth primitives:
13
+ * - `useMcp({ onRedirect })` to decide how auth navigation happens
14
+ * - `finishAuth(sessionId, code)` to complete code exchange
15
+ *
16
+ * Consumers are free to:
17
+ * - use these helpers as-is for a turnkey popup flow
18
+ * - build their own popup UI/message bridge
19
+ * - skip popups entirely and handle auth in a normal callback page
20
+ */
21
+ export interface OAuthPopupRedirectOptions {
22
+ width?: number;
23
+ height?: number;
24
+ windowName?: string;
25
+ features?: string[];
26
+ onBlocked?: (url: string) => void;
27
+ }
28
+
29
+ export interface McpOAuthCallbackContentProps {
30
+ code?: string | null;
31
+ sessionId?: string | null;
32
+ title?: ReactNode;
33
+ initialStatus?: string;
34
+ loadingFallback?: ReactNode;
35
+ rootStyle?: CSSProperties;
36
+ cardStyle?: CSSProperties;
37
+ titleStyle?: CSSProperties;
38
+ messageStyle?: CSSProperties;
39
+ renderContainer?: (content: ReactNode) => ReactNode;
40
+ debugPhase?: 'loading' | 'success' | 'error';
41
+ }
42
+
43
+ const AUTH_CODE_MESSAGE = 'MCP_AUTH_CODE';
44
+ const AUTH_RESULT_MESSAGE = 'MCP_AUTH_RESULT';
45
+
46
+ function postPopupResult(
47
+ popupWindow: WindowProxy | null,
48
+ result: {
49
+ sessionId?: string;
50
+ success: boolean;
51
+ error?: string;
52
+ }
53
+ ): void {
54
+ popupWindow?.postMessage(
55
+ {
56
+ type: AUTH_RESULT_MESSAGE,
57
+ ...result,
58
+ },
59
+ window.location.origin
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Opens a centered popup window for OAuth.
65
+ *
66
+ * Convenience only: callers can replace this entirely with their own popup,
67
+ * modal, redirect, or router-based navigation strategy.
68
+ */
69
+ export function openCenteredPopup(url: string, options: OAuthPopupRedirectOptions = {}): Window | null {
70
+ const {
71
+ width = 600,
72
+ height = 700,
73
+ windowName = 'mcp-auth-popup',
74
+ features = [],
75
+ onBlocked,
76
+ } = options;
77
+
78
+ const left = window.screenX + (window.outerWidth - width) / 2;
79
+ const top = window.screenY + (window.outerHeight - height) / 2;
80
+ const featureList = [
81
+ `width=${width}`,
82
+ `height=${height}`,
83
+ `left=${left}`,
84
+ `top=${top}`,
85
+ 'resizable=yes',
86
+ 'scrollbars=yes',
87
+ 'status=yes',
88
+ ...features,
89
+ ].join(',');
90
+
91
+ const popup = window.open(url, windowName, featureList);
92
+ if (!popup) {
93
+ onBlocked?.(url);
94
+ }
95
+
96
+ return popup;
97
+ }
98
+
99
+ /**
100
+ * Creates an `onRedirect` handler suitable for `useMcp({ onRedirect })`.
101
+ *
102
+ * This is the simplest popup entry point, but it is intentionally optional.
103
+ * Applications can provide any redirect handler they want, including full-page
104
+ * navigation or a completely custom popup implementation.
105
+ */
106
+ export function createOAuthPopupRedirectHandler(
107
+ options: OAuthPopupRedirectOptions = {}
108
+ ): (url: string) => void {
109
+ return (url: string) => {
110
+ openCenteredPopup(url, {
111
+ ...options,
112
+ onBlocked: options.onBlocked ?? ((blockedUrl) => {
113
+ window.alert('Popup blocked! Allow popups for this site to complete authentication.');
114
+ window.location.href = blockedUrl;
115
+ }),
116
+ });
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Handles opener-side popup coordination for OAuth code exchange.
122
+ *
123
+ * Use this when you want popup auth but do not want to reimplement the
124
+ * postMessage wiring between the main app window and the popup callback page.
125
+ *
126
+ * If you are not using a popup flow, you do not need this hook.
127
+ */
128
+ export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
129
+ connections: TConnection[],
130
+ finishAuth: (sessionId: string, code: string) => Promise<unknown>
131
+ ): void {
132
+ const pendingPopupsRef = useRef<Map<string, WindowProxy>>(new Map());
133
+
134
+ useEffect(() => {
135
+ const handleMessage = async (event: MessageEvent) => {
136
+ if (event.origin !== window.location.origin) {
137
+ return;
138
+ }
139
+
140
+ if (event.data?.type !== AUTH_CODE_MESSAGE || !event.data.code) {
141
+ return;
142
+ }
143
+
144
+ const popupWindow = event.source && 'postMessage' in event.source
145
+ ? event.source as WindowProxy
146
+ : null;
147
+ const targetSessionId = typeof event.data.sessionId === 'string' ? event.data.sessionId : '';
148
+
149
+ if (!targetSessionId) {
150
+ postPopupResult(popupWindow, {
151
+ success: false,
152
+ error: 'Missing OAuth session identifier',
153
+ });
154
+ return;
155
+ }
156
+
157
+ const targetSession = connections.find((connection) => connection.sessionId === targetSessionId);
158
+ if (!targetSession) {
159
+ postPopupResult(popupWindow, {
160
+ sessionId: targetSessionId,
161
+ success: false,
162
+ error: 'OAuth session not found in the current client state',
163
+ });
164
+ return;
165
+ }
166
+
167
+ if (popupWindow) {
168
+ pendingPopupsRef.current.set(targetSession.sessionId, popupWindow);
169
+ }
170
+
171
+ try {
172
+ await finishAuth(targetSession.sessionId, event.data.code);
173
+ } catch (error) {
174
+ pendingPopupsRef.current.delete(targetSession.sessionId);
175
+ postPopupResult(popupWindow, {
176
+ sessionId: targetSession.sessionId,
177
+ success: false,
178
+ error: error instanceof Error ? error.message : 'Failed to finish auth',
179
+ });
180
+ }
181
+ };
182
+
183
+ window.addEventListener('message', handleMessage);
184
+ return () => window.removeEventListener('message', handleMessage);
185
+ }, [connections, finishAuth]);
186
+
187
+ useEffect(() => {
188
+ for (const connection of connections) {
189
+ const popupWindow = pendingPopupsRef.current.get(connection.sessionId);
190
+ if (!popupWindow) {
191
+ continue;
192
+ }
193
+
194
+ if (connection.state === 'AUTHENTICATED') {
195
+ postPopupResult(popupWindow, {
196
+ sessionId: connection.sessionId,
197
+ success: true,
198
+ });
199
+ pendingPopupsRef.current.delete(connection.sessionId);
200
+ continue;
201
+ }
202
+
203
+ if (connection.state === 'FAILED') {
204
+ postPopupResult(popupWindow, {
205
+ sessionId: connection.sessionId,
206
+ success: false,
207
+ error: connection.error || 'Failed to complete authorization',
208
+ });
209
+ pendingPopupsRef.current.delete(connection.sessionId);
210
+ }
211
+ }
212
+ }, [connections]);
213
+ }
214
+
215
+ /**
216
+ * Default popup callback UI for popup-based OAuth flows.
217
+ *
218
+ * This component reads the OAuth `code` and `state/sessionId`, notifies the
219
+ * opener window, waits for success/failure, and closes the popup on success.
220
+ *
221
+ * It is intentionally optional: apps can replace it with their own callback
222
+ * page UI or skip popup auth entirely and call `finishAuth(sessionId, code)`
223
+ * from any callback route they control.
224
+ */
225
+ export function McpOAuthCallbackContent({
226
+ code,
227
+ sessionId,
228
+ title = 'Verifying Authorization',
229
+ initialStatus = 'Completing your authorization...',
230
+ loadingFallback = 'Loading...',
231
+ rootStyle,
232
+ cardStyle,
233
+ titleStyle,
234
+ messageStyle,
235
+ renderContainer,
236
+ debugPhase,
237
+ }: McpOAuthCallbackContentProps): JSX.Element {
238
+ const [phase, setPhase] = useState<'loading' | 'success' | 'error'>(debugPhase || 'loading');
239
+ const [errorMessage, setErrorMessage] = useState('');
240
+
241
+ const openerMissing = typeof window !== 'undefined' ? !window.opener : false;
242
+ const missingCode = !code;
243
+ const missingSessionId = !sessionId;
244
+ const blockingError = openerMissing
245
+ ? 'Error: No opener window found. This window should be opened from the app.'
246
+ : missingCode
247
+ ? 'Error: No authorization code received.'
248
+ : missingSessionId
249
+ ? 'Error: No OAuth state received.'
250
+ : null;
251
+
252
+ useEffect(() => {
253
+ if (debugPhase) {
254
+ setPhase(debugPhase);
255
+ if (debugPhase === 'error') setErrorMessage('Test error message representing a real failure.');
256
+ return;
257
+ }
258
+
259
+ if (blockingError) {
260
+ setPhase('error');
261
+ setErrorMessage(blockingError);
262
+ return;
263
+ }
264
+
265
+ let closed = false;
266
+
267
+ const handleResult = (event: MessageEvent) => {
268
+ if (event.origin !== window.location.origin) {
269
+ return;
270
+ }
271
+
272
+ if (event.data?.type !== AUTH_RESULT_MESSAGE) {
273
+ return;
274
+ }
275
+
276
+ if (event.data.sessionId !== sessionId) {
277
+ return;
278
+ }
279
+
280
+ if (event.data.success) {
281
+ setPhase('success');
282
+ window.removeEventListener('message', handleResult);
283
+ closed = true;
284
+ window.setTimeout(() => window.close(), 1200);
285
+ return;
286
+ }
287
+
288
+ const message =
289
+ typeof event.data.error === 'string' && event.data.error.length > 0
290
+ ? event.data.error
291
+ : 'Failed to complete authorization.';
292
+ setPhase('error');
293
+ setErrorMessage(message);
294
+ };
295
+
296
+ window.addEventListener('message', handleResult);
297
+
298
+ try {
299
+ window.opener.postMessage(
300
+ { type: AUTH_CODE_MESSAGE, code, sessionId },
301
+ window.location.origin
302
+ );
303
+ } catch (error) {
304
+ console.error('Failed to communicate with opener:', error);
305
+ window.setTimeout(() => {
306
+ setPhase('error');
307
+ setErrorMessage('Error: Could not communicate with main window.');
308
+ }, 0);
309
+ }
310
+
311
+ return () => {
312
+ if (!closed) {
313
+ window.removeEventListener('message', handleResult);
314
+ }
315
+ };
316
+ }, [blockingError, code, sessionId, debugPhase]);
317
+
318
+ const loadingBubbles = (
319
+ <div style={{ display: 'flex', gap: '8px', justifyContent: 'center', height: '12px', alignItems: 'center' }}>
320
+ {[0, 150, 300].map((delay) => (
321
+ <span
322
+ key={delay}
323
+ style={{
324
+ width: '8px',
325
+ height: '8px',
326
+ borderRadius: '50%',
327
+ backgroundColor: 'currentColor',
328
+ opacity: 0.3,
329
+ animation: `mcp-pulse 1.2s ease-in-out infinite`,
330
+ animationDelay: `${delay}ms`,
331
+ }}
332
+ />
333
+ ))}
334
+ </div>
335
+ );
336
+
337
+ const content = (
338
+ <div
339
+ style={{
340
+ display: 'flex',
341
+ justifyContent: 'center',
342
+ alignItems: 'center',
343
+ minHeight: '100vh',
344
+ fontFamily: 'system-ui, -apple-system, sans-serif',
345
+ flexDirection: 'column',
346
+ backgroundColor: '#fafafa',
347
+ color: '#18181b',
348
+ boxSizing: 'border-box',
349
+ padding: '1.5rem',
350
+ ...rootStyle,
351
+ }}
352
+ >
353
+ <style>
354
+ {`
355
+ @keyframes mcp-pulse { 0%, 100% { transform: scale(0.8); opacity: 0.4; } 50% { transform: scale(1.2); opacity: 1; } }
356
+ @keyframes mcp-fade-up { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
357
+ `}
358
+ </style>
359
+ <div
360
+ style={{
361
+ backgroundColor: '#fff',
362
+ borderRadius: '20px',
363
+ boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.05)',
364
+ width: '100%',
365
+ maxWidth: '400px',
366
+ overflow: 'hidden',
367
+ border: '1px solid #f4f4f5',
368
+ display: 'flex',
369
+ flexDirection: 'column',
370
+ ...cardStyle,
371
+ }}
372
+ >
373
+ <div style={{ padding: '3rem 2rem', textAlign: 'center', animation: 'mcp-fade-up 0.4s ease-out' }}>
374
+ {phase === 'loading' && (
375
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', alignItems: 'center' }}>
376
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '48px', width: '48px', background: '#f8fafc', borderRadius: '12px', border: '1px solid #f1f5f9', color: '#64748b' }}>
377
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
378
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
379
+ </svg>
380
+ </div>
381
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
382
+ <h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 600, ...titleStyle }}>{title}</h2>
383
+ <p style={{ margin: 0, fontSize: '0.9rem', color: '#71717a', lineHeight: 1.5, ...messageStyle }}>
384
+ {initialStatus}
385
+ </p>
386
+ </div>
387
+ {loadingBubbles}
388
+ </div>
389
+ )}
390
+
391
+ {phase === 'success' && (
392
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'center' }}>
393
+ <svg style={{ color: '#10b981' }} width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
394
+ <circle cx="12" cy="12" r="10" />
395
+ <path d="M8 12l3 3 5-5" />
396
+ </svg>
397
+ <h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600, ...titleStyle }}>Connected</h2>
398
+ <p style={{ margin: 0, fontSize: '0.9rem', color: '#71717a', lineHeight: 1.5, ...messageStyle }}>
399
+ Authorization complete. This window will close automatically.
400
+ </p>
401
+ </div>
402
+ )}
403
+
404
+ {phase === 'error' && (
405
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'center' }}>
406
+ <svg style={{ color: '#ef4444' }} width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
407
+ <circle cx="12" cy="12" r="10" />
408
+ <path d="M15 9l-6 6M9 9l6 6" />
409
+ </svg>
410
+ <h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600, ...titleStyle }}>Connection Failed</h2>
411
+ <p style={{ margin: 0, fontSize: '0.9rem', color: '#ef4444', fontWeight: 500, ...messageStyle }}>
412
+ {errorMessage}
413
+ </p>
414
+ <button
415
+ onClick={() => window.close()}
416
+ style={{
417
+ marginTop: '0.5rem', padding: '0.625rem 1.25rem', border: 'none', borderRadius: '8px',
418
+ backgroundColor: '#fef2f2', color: '#ef4444', cursor: 'pointer', fontWeight: 600, fontSize: '0.875rem'
419
+ }}
420
+ >
421
+ Close Window
422
+ </button>
423
+ </div>
424
+ )}
425
+ </div>
426
+ </div>
427
+ </div>
428
+ );
429
+
430
+ if (renderContainer) {
431
+ return <>{renderContainer(content)}</>;
432
+ }
433
+
434
+ return content;
435
+ }
436
+
437
+ /**
438
+ * Tiny fallback component for Suspense-wrapped callback pages.
439
+ */
440
+ export function McpOAuthCallbackFallback({
441
+ children = 'Loading...',
442
+ }: {
443
+ children?: ReactNode;
444
+ }): JSX.Element {
445
+ return <>{children || 'Loading...'}</>;
446
+ }
@@ -118,6 +118,17 @@ export interface McpClient {
118
118
  */
119
119
  disconnect: (sessionId: string) => Promise<void>;
120
120
 
121
+ /**
122
+ * Reconnect to an MCP server (disconnects existing session first)
123
+ */
124
+ reconnect: (params: {
125
+ serverId: string;
126
+ serverName: string;
127
+ serverUrl: string;
128
+ callbackUrl: string;
129
+ transportType?: 'sse' | 'streamable_http';
130
+ }) => Promise<string>;
131
+
121
132
  /**
122
133
  * Get connection by session ID
123
134
  */
@@ -290,17 +301,48 @@ export function useMcp(options: UseMcpOptions): McpClient {
290
301
  state === 'CONNECTED' ||
291
302
  state === 'DISCOVERING';
292
303
 
304
+ const getVisibleState = (
305
+ incomingState: McpConnectionState,
306
+ existingState?: McpConnectionState,
307
+ previousState?: McpConnectionState
308
+ ): McpConnectionState => {
309
+ // `INITIALIZING` has two meanings in practice:
310
+ // 1. genuine cold start / reconnect work
311
+ // 2. an internal setup step that happens mid-OAuth completion
312
+ //
313
+ // For case (2), showing raw `INITIALIZING` creates a confusing user-facing
314
+ // sequence like AUTHENTICATING -> INITIALIZING -> AUTHENTICATED.
315
+ // We keep the raw event stream intact for observability, but collapse the
316
+ // visible state back into the current auth phase in the UI.
317
+ if (
318
+ incomingState === 'INITIALIZING' &&
319
+ (existingState === 'AUTHENTICATING' ||
320
+ existingState === 'AUTHENTICATED' ||
321
+ previousState === 'AUTHENTICATING' ||
322
+ previousState === 'AUTHENTICATED')
323
+ ) {
324
+ return existingState === 'AUTHENTICATED' || previousState === 'AUTHENTICATED'
325
+ ? 'AUTHENTICATED'
326
+ : 'AUTHENTICATING';
327
+ }
328
+
329
+ return incomingState;
330
+ };
331
+
293
332
  setConnections((prev: McpConnection[]) => {
294
333
  switch (event.type) {
295
334
  case 'state_changed': {
296
335
  const existing = prev.find((c: McpConnection) => c.sessionId === event.sessionId);
297
336
  if (existing) {
337
+ // Normalize the incoming backend state into the smoother user-facing
338
+ // state we want to render for this existing connection.
339
+ const normalizedState = getVisibleState(event.state, existing.state, event.previousState);
298
340
  // In stateless per-request transport, tool calls can emit transient reconnect states.
299
341
  // Keep READY sticky to avoid UI flicker from READY -> CONNECTING -> CONNECTED.
300
342
  const nextState =
301
- existing.state === 'READY' && isTransientReconnectState(event.state)
343
+ existing.state === 'READY' && isTransientReconnectState(normalizedState)
302
344
  ? existing.state
303
- : event.state;
345
+ : normalizedState;
304
346
 
305
347
  return prev.map((c: McpConnection) =>
306
348
  c.sessionId === event.sessionId ? {
@@ -323,7 +365,9 @@ export function useMcp(options: UseMcpOptions): McpClient {
323
365
  serverId: event.serverId,
324
366
  serverName: event.serverName,
325
367
  serverUrl: event.serverUrl,
326
- state: event.state,
368
+ // New connections do not have prior local state, so we normalize
369
+ // only against the server-reported previous state.
370
+ state: getVisibleState(event.state, undefined, event.previousState),
327
371
  createdAt: event.createdAt ? new Date(event.createdAt) : undefined,
328
372
  tools: [],
329
373
  },
@@ -466,6 +510,41 @@ export function useMcp(options: UseMcpOptions): McpClient {
466
510
  []
467
511
  );
468
512
 
513
+ /**
514
+ * Reconnect to an MCP server (tears down existing session, then connects fresh)
515
+ */
516
+ const reconnect = useCallback(
517
+ async (params: {
518
+ serverId: string;
519
+ serverName: string;
520
+ serverUrl: string;
521
+ callbackUrl: string;
522
+ transportType?: 'sse' | 'streamable_http';
523
+ }): Promise<string> => {
524
+ if (!clientRef.current) {
525
+ throw new Error('SSE client not initialized');
526
+ }
527
+
528
+ // Find and disconnect existing session for the same server
529
+ const existing = connections.find(
530
+ (c: McpConnection) => c.serverId === params.serverId || c.serverUrl === params.serverUrl
531
+ );
532
+ if (existing) {
533
+ await clientRef.current.disconnectFromServer(existing.sessionId);
534
+ if (isMountedRef.current) {
535
+ setConnections((prev: McpConnection[]) =>
536
+ prev.filter((c: McpConnection) => c.sessionId !== existing.sessionId)
537
+ );
538
+ }
539
+ }
540
+
541
+ // Connect fresh
542
+ const result = await clientRef.current.connectToServer(params);
543
+ return result.sessionId;
544
+ },
545
+ [connections]
546
+ );
547
+
469
548
  /**
470
549
  * Disconnect from an MCP server
471
550
  */
@@ -635,6 +714,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
635
714
  status,
636
715
  isInitializing,
637
716
  connect,
717
+ reconnect,
638
718
  disconnect,
639
719
  getConnection,
640
720
  getConnectionByServerId,
@@ -658,6 +738,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
658
738
  status,
659
739
  isInitializing,
660
740
  connect,
741
+ reconnect,
661
742
  disconnect,
662
743
  getConnection,
663
744
  getConnectionByServerId,