@mcp-ts/sdk 1.3.7 → 1.3.10

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/LICENSE +21 -21
  2. package/README.md +397 -404
  3. package/dist/adapters/agui-middleware.js.map +1 -1
  4. package/dist/adapters/agui-middleware.mjs.map +1 -1
  5. package/dist/bin/mcp-ts.js +0 -0
  6. package/dist/bin/mcp-ts.js.map +1 -1
  7. package/dist/bin/mcp-ts.mjs +0 -0
  8. package/dist/bin/mcp-ts.mjs.map +1 -1
  9. package/dist/client/index.js.map +1 -1
  10. package/dist/client/index.mjs.map +1 -1
  11. package/dist/client/react.d.mts +10 -28
  12. package/dist/client/react.d.ts +10 -28
  13. package/dist/client/react.js +101 -52
  14. package/dist/client/react.js.map +1 -1
  15. package/dist/client/react.mjs +102 -53
  16. package/dist/client/react.mjs.map +1 -1
  17. package/dist/client/vue.js.map +1 -1
  18. package/dist/client/vue.mjs.map +1 -1
  19. package/dist/index.js +78 -6
  20. package/dist/index.js.map +1 -1
  21. package/dist/index.mjs +73 -6
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/server/index.js +78 -6
  24. package/dist/server/index.js.map +1 -1
  25. package/dist/server/index.mjs +73 -6
  26. package/dist/server/index.mjs.map +1 -1
  27. package/dist/shared/index.js.map +1 -1
  28. package/dist/shared/index.mjs.map +1 -1
  29. package/package.json +185 -185
  30. package/src/adapters/agui-middleware.ts +382 -382
  31. package/src/bin/mcp-ts.ts +102 -102
  32. package/src/client/core/app-host.ts +417 -417
  33. package/src/client/core/sse-client.ts +371 -371
  34. package/src/client/core/types.ts +31 -31
  35. package/src/client/index.ts +27 -27
  36. package/src/client/react/index.ts +20 -16
  37. package/src/client/react/use-app-host.ts +74 -73
  38. package/src/client/react/use-mcp-apps.tsx +224 -214
  39. package/src/client/react/use-mcp.ts +669 -641
  40. package/src/client/vue/index.ts +10 -10
  41. package/src/client/vue/use-mcp.ts +617 -617
  42. package/src/index.ts +11 -11
  43. package/src/server/handlers/nextjs-handler.ts +204 -204
  44. package/src/server/handlers/sse-handler.ts +631 -631
  45. package/src/server/index.ts +57 -57
  46. package/src/server/mcp/multi-session-client.ts +228 -228
  47. package/src/server/mcp/oauth-client.ts +1188 -1188
  48. package/src/server/mcp/storage-oauth-provider.ts +272 -272
  49. package/src/server/storage/crypto.ts +92 -0
  50. package/src/server/storage/file-backend.ts +157 -157
  51. package/src/server/storage/index.ts +176 -176
  52. package/src/server/storage/memory-backend.ts +123 -123
  53. package/src/server/storage/redis-backend.ts +276 -276
  54. package/src/server/storage/redis.ts +160 -160
  55. package/src/server/storage/sqlite-backend.ts +182 -182
  56. package/src/server/storage/supabase-backend.ts +229 -228
  57. package/src/server/storage/types.ts +116 -116
  58. package/src/shared/constants.ts +29 -29
  59. package/src/shared/errors.ts +133 -133
  60. package/src/shared/event-routing.ts +28 -28
  61. package/src/shared/events.ts +180 -180
  62. package/src/shared/index.ts +75 -75
  63. package/src/shared/tool-utils.ts +61 -61
  64. package/src/shared/types.ts +282 -282
  65. package/src/shared/utils.ts +38 -38
  66. package/supabase/migrations/20260330195700_install_mcp_sessions.sql +84 -84
@@ -1,641 +1,669 @@
1
- /**
2
- * useMcp React Hook
3
- * Manages MCP connections with SSE-based real-time updates
4
- * Based on Cloudflare's agents pattern
5
- */
6
-
7
- import { useEffect, useRef, useState, useCallback } from 'react';
8
- import { SSEClient, type SSEClientOptions } from '../core/sse-client';
9
- import type { McpConnectionEvent, McpConnectionState } from '../../shared/events';
10
- import type {
11
- ToolInfo,
12
- FinishAuthResult,
13
- ListToolsRpcResult,
14
- ListPromptsResult,
15
- ListResourcesResult,
16
- SessionInfo,
17
- } from '../../shared/types';
18
-
19
- export interface UseMcpOptions {
20
- /**
21
- * SSE endpoint URL
22
- */
23
- url: string;
24
-
25
- /**
26
- * User/Client identifier
27
- */
28
- identity: string;
29
-
30
- /**
31
- * Optional auth token
32
- */
33
- authToken?: string;
34
-
35
- /**
36
- * Auto-connect on mount
37
- * @default true
38
- */
39
- autoConnect?: boolean;
40
-
41
- /**
42
- * Auto-initialize sessions on mount
43
- * @default true
44
- */
45
- autoInitialize?: boolean;
46
-
47
- /**
48
- * Connection event callback
49
- */
50
- onConnectionEvent?: (event: McpConnectionEvent) => void;
51
-
52
- /**
53
- * Debug logging callback
54
- */
55
- onLog?: (level: string, message: string, metadata?: Record<string, unknown>) => void;
56
- /**
57
- * Optional callback to handle OAuth redirects (e.g. for popup flow)
58
- * If provided, this will be called instead of window.location.href assignment
59
- */
60
- onRedirect?: (url: string) => void;
61
-
62
- /**
63
- * Request timeout in milliseconds
64
- * @default 60000
65
- */
66
- requestTimeout?: number;
67
-
68
- /**
69
- * Enable client debug logs.
70
- * @default false
71
- */
72
- debug?: boolean;
73
-
74
- }
75
-
76
- export interface McpConnection {
77
- sessionId: string;
78
- serverId: string;
79
- serverName: string;
80
- serverUrl?: string;
81
- transport?: string;
82
- state: McpConnectionState;
83
- tools: ToolInfo[];
84
- authUrl?: string;
85
- error?: string;
86
- createdAt?: Date;
87
- }
88
-
89
- export interface McpClient {
90
- /**
91
- * All connections
92
- */
93
- connections: McpConnection[];
94
-
95
- /**
96
- * SSE connection status
97
- */
98
- status: 'connecting' | 'connected' | 'disconnected' | 'error';
99
-
100
- /**
101
- * Whether initializing
102
- */
103
- isInitializing: boolean;
104
-
105
- /**
106
- * Connect to an MCP server
107
- */
108
- connect: (params: {
109
- serverId: string;
110
- serverName: string;
111
- serverUrl: string;
112
- callbackUrl: string;
113
- transportType?: 'sse' | 'streamable_http';
114
- }) => Promise<string>;
115
-
116
- /**
117
- * Disconnect from an MCP server
118
- */
119
- disconnect: (sessionId: string) => Promise<void>;
120
-
121
- /**
122
- * Get connection by session ID
123
- */
124
- getConnection: (sessionId: string) => McpConnection | undefined;
125
-
126
- /**
127
- * Get connection by server ID
128
- */
129
- getConnectionByServerId: (serverId: string) => McpConnection | undefined;
130
-
131
- /**
132
- * Check if server is connected
133
- */
134
- isServerConnected: (serverId: string) => boolean;
135
-
136
- /**
137
- * Get tools for a session
138
- */
139
- getTools: (sessionId: string) => ToolInfo[];
140
-
141
- /**
142
- * Refresh all connections
143
- */
144
- refresh: () => Promise<void>;
145
-
146
- /**
147
- * Manually connect SSE
148
- */
149
- connectSSE: () => void;
150
-
151
- /**
152
- * Manually disconnect SSE
153
- */
154
- disconnectSSE: () => void;
155
-
156
- /**
157
- * Complete OAuth authorization
158
- */
159
- finishAuth: (sessionId: string, code: string) => Promise<FinishAuthResult>;
160
-
161
- /**
162
- * Explicitly resume OAuth flow for an existing session
163
- */
164
- resumeAuth: (sessionId: string) => Promise<void>;
165
-
166
- /**
167
- * Call a tool from a session
168
- */
169
- callTool: (
170
- sessionId: string,
171
- toolName: string,
172
- toolArgs: Record<string, unknown>
173
- ) => Promise<unknown>;
174
-
175
- /**
176
- * List available tools for a session
177
- */
178
- listTools: (sessionId: string) => Promise<ListToolsRpcResult>;
179
-
180
- /**
181
- * List available prompts for a session
182
- */
183
- listPrompts: (sessionId: string) => Promise<ListPromptsResult>;
184
-
185
- /**
186
- * Get a specific prompt with arguments
187
- */
188
- getPrompt: (sessionId: string, name: string, args?: Record<string, string>) => Promise<unknown>;
189
-
190
- /**
191
- * List available resources for a session
192
- */
193
- listResources: (sessionId: string) => Promise<ListResourcesResult>;
194
-
195
- /**
196
- * Read a specific resource
197
- */
198
- readResource: (sessionId: string, uri: string) => Promise<unknown>;
199
-
200
- /**
201
- * Access the underlying SSEClient instance (for advanced usage like AppHost)
202
- */
203
- sseClient: SSEClient | null;
204
- }
205
-
206
- /**
207
- * React hook for MCP connection management with SSE
208
- */
209
- export function useMcp(options: UseMcpOptions): McpClient {
210
- const {
211
- url,
212
- identity,
213
- authToken,
214
- autoConnect = true,
215
- autoInitialize = true,
216
- onConnectionEvent,
217
- onLog,
218
- onRedirect,
219
- } = options;
220
-
221
- const clientRef = useRef<SSEClient | null>(null);
222
- const isMountedRef = useRef(true);
223
- const suppressAuthRedirectSessionsRef = useRef<Set<string>>(new Set());
224
-
225
- const [connections, setConnections] = useState<McpConnection[]>([]);
226
- const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>(
227
- 'disconnected'
228
- );
229
- const [isInitializing, setIsInitializing] = useState(false);
230
-
231
- /**
232
- * Initialize SSE client
233
- */
234
- useEffect(() => {
235
- isMountedRef.current = true;
236
-
237
- const clientOptions: SSEClientOptions = {
238
- url,
239
- identity,
240
- authToken,
241
- onConnectionEvent: (event) => {
242
- // Update local state based on event
243
- updateConnectionsFromEvent(event);
244
-
245
- // Call user callback
246
- onConnectionEvent?.(event);
247
- },
248
- onObservabilityEvent: (event) => {
249
- onLog?.(event.level || 'info', event.message || event.displayMessage || 'No message', event.metadata);
250
- },
251
- onStatusChange: (newStatus) => {
252
- if (isMountedRef.current) {
253
- setStatus(newStatus);
254
- }
255
- },
256
- debug: options.debug,
257
- };
258
-
259
- const client = new SSEClient(clientOptions);
260
- clientRef.current = client;
261
-
262
- if (autoConnect) {
263
- client.connect();
264
-
265
- if (autoInitialize) {
266
- loadSessions();
267
- }
268
- }
269
-
270
- return () => {
271
- isMountedRef.current = false;
272
- client.disconnect();
273
- };
274
- }, [url, identity, authToken, autoConnect, autoInitialize]);
275
-
276
- /**
277
- * Update connections based on event
278
- */
279
- const updateConnectionsFromEvent = useCallback((event: McpConnectionEvent) => {
280
- if (!isMountedRef.current) return;
281
-
282
- const isTransientReconnectState = (state: McpConnectionState): boolean =>
283
- state === 'INITIALIZING' ||
284
- state === 'VALIDATING' ||
285
- state === 'RECONNECTING' ||
286
- state === 'CONNECTING' ||
287
- state === 'CONNECTED' ||
288
- state === 'DISCOVERING';
289
-
290
- setConnections((prev: McpConnection[]) => {
291
- switch (event.type) {
292
- case 'state_changed': {
293
- const existing = prev.find((c: McpConnection) => c.sessionId === event.sessionId);
294
- if (existing) {
295
- // In stateless per-request transport, tool calls can emit transient reconnect states.
296
- // Keep READY sticky to avoid UI flicker from READY -> CONNECTING -> CONNECTED.
297
- const nextState =
298
- existing.state === 'READY' && isTransientReconnectState(event.state)
299
- ? existing.state
300
- : event.state;
301
-
302
- return prev.map((c: McpConnection) =>
303
- c.sessionId === event.sessionId ? {
304
- ...c,
305
- state: nextState,
306
- // update createdAt if present in event, otherwise keep existing
307
- createdAt: event.createdAt ? new Date(event.createdAt) : c.createdAt
308
- } : c
309
- );
310
- } else {
311
- // Fix: Don't add back disconnected sessions that were just removed
312
- if (event.state === 'DISCONNECTED') {
313
- return prev;
314
- }
315
-
316
- return [
317
- ...prev,
318
- {
319
- sessionId: event.sessionId,
320
- serverId: event.serverId,
321
- serverName: event.serverName,
322
- serverUrl: event.serverUrl,
323
- state: event.state,
324
- createdAt: event.createdAt ? new Date(event.createdAt) : undefined,
325
- tools: [],
326
- },
327
- ];
328
- }
329
- }
330
-
331
- case 'tools_discovered': {
332
- // Preload UI resources for instant loading when tools are discovered
333
- if (clientRef.current && event.tools?.length) {
334
- clientRef.current.preloadToolUiResources(event.sessionId, event.tools);
335
- }
336
-
337
- return prev.map((c: McpConnection) =>
338
- c.sessionId === event.sessionId ? { ...c, tools: event.tools, state: 'READY' } : c
339
- );
340
- }
341
-
342
- 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
- }
354
- }
355
- }
356
- return prev.map((c: McpConnection) =>
357
- c.sessionId === event.sessionId ? { ...c, state: 'AUTHENTICATING', authUrl: event.authUrl } : c
358
- );
359
- }
360
-
361
- case 'error': {
362
- return prev.map((c: McpConnection) =>
363
- c.sessionId === event.sessionId ? { ...c, state: 'FAILED', error: event.error } : c
364
- );
365
- }
366
-
367
- case 'disconnected': {
368
- return prev.filter((c: McpConnection) => c.sessionId !== event.sessionId);
369
- }
370
-
371
- default:
372
- return prev;
373
- }
374
- });
375
- }, [onLog, onRedirect]);
376
-
377
- /**
378
- * Load sessions from server
379
- */
380
- const loadSessions = useCallback(async () => {
381
- if (!clientRef.current) return;
382
-
383
- try {
384
- setIsInitializing(true);
385
-
386
- const result = await clientRef.current.getSessions();
387
- const sessions = result.sessions || [];
388
-
389
- // Initialize connections
390
- if (isMountedRef.current) {
391
- setConnections(
392
- sessions.map((s: SessionInfo) => ({
393
- sessionId: s.sessionId,
394
- serverId: s.serverId ?? 'unknown',
395
- serverName: s.serverName ?? 'Unknown Server',
396
- serverUrl: s.serverUrl,
397
- transport: s.transport,
398
- state: (s.active === false ? 'AUTHENTICATING' : 'VALIDATING') as McpConnectionState,
399
- createdAt: new Date(s.createdAt),
400
- tools: [],
401
- }))
402
- );
403
- }
404
-
405
- // Validate each session in parallel
406
- await Promise.all(
407
- sessions.map(async (session: SessionInfo) => {
408
- if (clientRef.current) {
409
- try {
410
- // Pending auth sessions should not auto-trigger popup/redirect on reload.
411
- if (session.active === false) {
412
- return;
413
- }
414
- suppressAuthRedirectSessionsRef.current.add(session.sessionId);
415
- await clientRef.current.restoreSession(session.sessionId);
416
- } catch (error) {
417
- console.error(`[useMcp] Failed to validate session ${session.sessionId}:`, error);
418
- } finally {
419
- suppressAuthRedirectSessionsRef.current.delete(session.sessionId);
420
- }
421
- }
422
- })
423
- );
424
- } catch (error) {
425
- console.error('[useMcp] Failed to load sessions:', error);
426
- onLog?.('error', 'Failed to load sessions', { error });
427
- } finally {
428
- if (isMountedRef.current) {
429
- setIsInitializing(false);
430
- }
431
- }
432
- }, [onLog]);
433
-
434
- /**
435
- * Connect to an MCP server
436
- */
437
- const connect = useCallback(
438
- async (params: {
439
- serverId: string;
440
- serverName: string;
441
- serverUrl: string;
442
- callbackUrl: string;
443
- transportType?: 'sse' | 'streamable_http';
444
- }): Promise<string> => {
445
- if (!clientRef.current) {
446
- throw new Error('SSE client not initialized');
447
- }
448
-
449
- const result = await clientRef.current.connectToServer(params);
450
- return result.sessionId;
451
- },
452
- []
453
- );
454
-
455
- /**
456
- * Disconnect from an MCP server
457
- */
458
- const disconnect = useCallback(async (sessionId: string): Promise<void> => {
459
- if (!clientRef.current) {
460
- throw new Error('SSE client not initialized');
461
- }
462
-
463
- await clientRef.current.disconnectFromServer(sessionId);
464
-
465
- // Remove from local state
466
- if (isMountedRef.current) {
467
- setConnections((prev: McpConnection[]) => prev.filter((c: McpConnection) => c.sessionId !== sessionId));
468
- }
469
- }, []);
470
-
471
- /**
472
- * Refresh all connections
473
- */
474
- const refresh = useCallback(async () => {
475
- await loadSessions();
476
- }, [loadSessions]);
477
-
478
- /**
479
- * Manually connect SSE
480
- */
481
- const connectSSE = useCallback(() => {
482
- clientRef.current?.connect();
483
- }, []);
484
-
485
- /**
486
- * Manually disconnect SSE
487
- */
488
- const disconnectSSE = useCallback(() => {
489
- clientRef.current?.disconnect();
490
- }, []);
491
-
492
- /**
493
- * Complete OAuth authorization
494
- */
495
- const finishAuth = useCallback(async (sessionId: string, code: string): Promise<FinishAuthResult> => {
496
- if (!clientRef.current) {
497
- throw new Error('SSE client not initialized');
498
- }
499
-
500
- return await clientRef.current.finishAuth(sessionId, code);
501
- }, []);
502
-
503
- /**
504
- * Explicit user action to resume OAuth for an existing pending session.
505
- */
506
- const resumeAuth = useCallback(async (sessionId: string): Promise<void> => {
507
- if (!clientRef.current) {
508
- throw new Error('SSE client not initialized');
509
- }
510
- // Ensure this attempt is not suppressed as background restore.
511
- suppressAuthRedirectSessionsRef.current.delete(sessionId);
512
- await clientRef.current.restoreSession(sessionId);
513
- }, []);
514
-
515
- /**
516
- * Call a tool
517
- */
518
- const callTool = useCallback(
519
- async (
520
- sessionId: string,
521
- toolName: string,
522
- toolArgs: Record<string, unknown>
523
- ): Promise<unknown> => {
524
- if (!clientRef.current) {
525
- throw new Error('SSE client not initialized');
526
- }
527
-
528
- return await clientRef.current.callTool(sessionId, toolName, toolArgs);
529
- },
530
- []
531
- );
532
-
533
- /**
534
- * List tools (refresh tool list)
535
- */
536
- const listTools = useCallback(async (sessionId: string): Promise<ListToolsRpcResult> => {
537
- if (!clientRef.current) {
538
- throw new Error('SSE client not initialized');
539
- }
540
-
541
- return await clientRef.current.listTools(sessionId);
542
- }, []);
543
-
544
- /**
545
- * List prompts
546
- */
547
- const listPrompts = useCallback(async (sessionId: string): Promise<ListPromptsResult> => {
548
- if (!clientRef.current) {
549
- throw new Error('SSE client not initialized');
550
- }
551
-
552
- return await clientRef.current.listPrompts(sessionId);
553
- }, []);
554
-
555
- /**
556
- * Get a specific prompt
557
- */
558
- const getPrompt = useCallback(
559
- async (sessionId: string, name: string, args?: Record<string, string>): Promise<unknown> => {
560
- if (!clientRef.current) {
561
- throw new Error('SSE client not initialized');
562
- }
563
-
564
- return await clientRef.current.getPrompt(sessionId, name, args);
565
- },
566
- []
567
- );
568
-
569
- /**
570
- * List resources
571
- */
572
- const listResources = useCallback(async (sessionId: string): Promise<ListResourcesResult> => {
573
- if (!clientRef.current) {
574
- throw new Error('SSE client not initialized');
575
- }
576
-
577
- return await clientRef.current.listResources(sessionId);
578
- }, []);
579
-
580
- /**
581
- * Read a specific resource
582
- */
583
- const readResource = useCallback(async (sessionId: string, uri: string): Promise<unknown> => {
584
- if (!clientRef.current) {
585
- throw new Error('SSE client not initialized');
586
- }
587
-
588
- return await clientRef.current.readResource(sessionId, uri);
589
- }, []);
590
-
591
- // Utility functions
592
- const getConnection = useCallback(
593
- (sessionId: string) => connections.find((c: McpConnection) => c.sessionId === sessionId),
594
- [connections]
595
- );
596
-
597
- const getConnectionByServerId = useCallback(
598
- (serverId: string) => connections.find((c: McpConnection) => c.serverId === serverId),
599
- [connections]
600
- );
601
-
602
- const isServerConnected = useCallback(
603
- (serverId: string) => {
604
- const conn = getConnectionByServerId(serverId);
605
- return conn ? conn.state === 'CONNECTED' || conn.state === 'DISCOVERING' || conn.state === 'READY' : false;
606
- },
607
- [getConnectionByServerId]
608
- );
609
-
610
- const getTools = useCallback(
611
- (sessionId: string) => {
612
- const conn = getConnection(sessionId);
613
- return conn?.tools || [];
614
- },
615
- [getConnection]
616
- );
617
-
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
- };
641
- }
1
+ /**
2
+ * useMcp React Hook
3
+ * Manages MCP connections with SSE-based real-time updates
4
+ * Based on Cloudflare's agents pattern
5
+ */
6
+
7
+ import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
8
+ import { SSEClient, type SSEClientOptions } from '../core/sse-client';
9
+ import type { McpConnectionEvent, McpConnectionState } from '../../shared/events';
10
+ import type {
11
+ ToolInfo,
12
+ FinishAuthResult,
13
+ ListToolsRpcResult,
14
+ ListPromptsResult,
15
+ ListResourcesResult,
16
+ SessionInfo,
17
+ } from '../../shared/types';
18
+
19
+ export interface UseMcpOptions {
20
+ /**
21
+ * SSE endpoint URL
22
+ */
23
+ url: string;
24
+
25
+ /**
26
+ * User/Client identifier
27
+ */
28
+ identity: string;
29
+
30
+ /**
31
+ * Optional auth token
32
+ */
33
+ authToken?: string;
34
+
35
+ /**
36
+ * Auto-connect on mount
37
+ * @default true
38
+ */
39
+ autoConnect?: boolean;
40
+
41
+ /**
42
+ * Auto-initialize sessions on mount
43
+ * @default true
44
+ */
45
+ autoInitialize?: boolean;
46
+
47
+ /**
48
+ * Connection event callback
49
+ */
50
+ onConnectionEvent?: (event: McpConnectionEvent) => void;
51
+
52
+ /**
53
+ * Debug logging callback
54
+ */
55
+ onLog?: (level: string, message: string, metadata?: Record<string, unknown>) => void;
56
+ /**
57
+ * Optional callback to handle OAuth redirects (e.g. for popup flow)
58
+ * If provided, this will be called instead of window.location.href assignment
59
+ */
60
+ onRedirect?: (url: string) => void;
61
+
62
+ /**
63
+ * Request timeout in milliseconds
64
+ * @default 60000
65
+ */
66
+ requestTimeout?: number;
67
+
68
+ /**
69
+ * Enable client debug logs.
70
+ * @default false
71
+ */
72
+ debug?: boolean;
73
+
74
+ }
75
+
76
+ export interface McpConnection {
77
+ sessionId: string;
78
+ serverId: string;
79
+ serverName: string;
80
+ serverUrl?: string;
81
+ transport?: string;
82
+ state: McpConnectionState;
83
+ tools: ToolInfo[];
84
+ authUrl?: string;
85
+ error?: string;
86
+ createdAt?: Date;
87
+ }
88
+
89
+ export interface McpClient {
90
+ /**
91
+ * All connections
92
+ */
93
+ connections: McpConnection[];
94
+
95
+ /**
96
+ * SSE connection status
97
+ */
98
+ status: 'connecting' | 'connected' | 'disconnected' | 'error';
99
+
100
+ /**
101
+ * Whether initializing
102
+ */
103
+ isInitializing: boolean;
104
+
105
+ /**
106
+ * Connect to an MCP server
107
+ */
108
+ connect: (params: {
109
+ serverId: string;
110
+ serverName: string;
111
+ serverUrl: string;
112
+ callbackUrl: string;
113
+ transportType?: 'sse' | 'streamable_http';
114
+ }) => Promise<string>;
115
+
116
+ /**
117
+ * Disconnect from an MCP server
118
+ */
119
+ disconnect: (sessionId: string) => Promise<void>;
120
+
121
+ /**
122
+ * Get connection by session ID
123
+ */
124
+ getConnection: (sessionId: string) => McpConnection | undefined;
125
+
126
+ /**
127
+ * Get connection by server ID
128
+ */
129
+ getConnectionByServerId: (serverId: string) => McpConnection | undefined;
130
+
131
+ /**
132
+ * Check if server is connected
133
+ */
134
+ isServerConnected: (serverId: string) => boolean;
135
+
136
+ /**
137
+ * Get tools for a session
138
+ */
139
+ getTools: (sessionId: string) => ToolInfo[];
140
+
141
+ /**
142
+ * Refresh all connections
143
+ */
144
+ refresh: () => Promise<void>;
145
+
146
+ /**
147
+ * Manually connect SSE
148
+ */
149
+ connectSSE: () => void;
150
+
151
+ /**
152
+ * Manually disconnect SSE
153
+ */
154
+ disconnectSSE: () => void;
155
+
156
+ /**
157
+ * Complete OAuth authorization
158
+ */
159
+ finishAuth: (sessionId: string, code: string) => Promise<FinishAuthResult>;
160
+
161
+ /**
162
+ * Explicitly resume OAuth flow for an existing session
163
+ */
164
+ resumeAuth: (sessionId: string) => Promise<void>;
165
+
166
+ /**
167
+ * Call a tool from a session
168
+ */
169
+ callTool: (
170
+ sessionId: string,
171
+ toolName: string,
172
+ toolArgs: Record<string, unknown>
173
+ ) => Promise<unknown>;
174
+
175
+ /**
176
+ * List available tools for a session
177
+ */
178
+ listTools: (sessionId: string) => Promise<ListToolsRpcResult>;
179
+
180
+ /**
181
+ * List available prompts for a session
182
+ */
183
+ listPrompts: (sessionId: string) => Promise<ListPromptsResult>;
184
+
185
+ /**
186
+ * Get a specific prompt with arguments
187
+ */
188
+ getPrompt: (sessionId: string, name: string, args?: Record<string, string>) => Promise<unknown>;
189
+
190
+ /**
191
+ * List available resources for a session
192
+ */
193
+ listResources: (sessionId: string) => Promise<ListResourcesResult>;
194
+
195
+ /**
196
+ * Read a specific resource
197
+ */
198
+ readResource: (sessionId: string, uri: string) => Promise<unknown>;
199
+
200
+ /**
201
+ * Access the underlying SSEClient instance (for advanced usage like AppHost)
202
+ */
203
+ sseClient: SSEClient | null;
204
+ }
205
+
206
+ /**
207
+ * React hook for MCP connection management with SSE
208
+ */
209
+ export function useMcp(options: UseMcpOptions): McpClient {
210
+ const {
211
+ url,
212
+ identity,
213
+ authToken,
214
+ autoConnect = true,
215
+ autoInitialize = true,
216
+ onConnectionEvent,
217
+ onLog,
218
+ onRedirect,
219
+ } = options;
220
+
221
+ const clientRef = useRef<SSEClient | null>(null);
222
+ const isMountedRef = useRef(true);
223
+ const suppressAuthRedirectSessionsRef = useRef<Set<string>>(new Set());
224
+
225
+ const [connections, setConnections] = useState<McpConnection[]>([]);
226
+ const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>(
227
+ 'disconnected'
228
+ );
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);
232
+
233
+ /**
234
+ * Initialize SSE client
235
+ */
236
+ useEffect(() => {
237
+ isMountedRef.current = true;
238
+
239
+ const clientOptions: SSEClientOptions = {
240
+ url,
241
+ identity,
242
+ authToken,
243
+ onConnectionEvent: (event) => {
244
+ // Update local state based on event
245
+ updateConnectionsFromEvent(event);
246
+
247
+ // Call user callback
248
+ onConnectionEvent?.(event);
249
+ },
250
+ onObservabilityEvent: (event) => {
251
+ onLog?.(event.level || 'info', event.message || event.displayMessage || 'No message', event.metadata);
252
+ },
253
+ onStatusChange: (newStatus) => {
254
+ if (isMountedRef.current) {
255
+ setStatus(newStatus);
256
+ }
257
+ },
258
+ debug: options.debug,
259
+ };
260
+
261
+ const client = new SSEClient(clientOptions);
262
+ clientRef.current = client;
263
+ setSseClient(client);
264
+
265
+ if (autoConnect) {
266
+ client.connect();
267
+
268
+ if (autoInitialize) {
269
+ loadSessions();
270
+ }
271
+ }
272
+
273
+ return () => {
274
+ isMountedRef.current = false;
275
+ client.disconnect();
276
+ };
277
+ }, [url, identity, authToken, autoConnect, autoInitialize]);
278
+
279
+ /**
280
+ * Update connections based on event
281
+ */
282
+ const updateConnectionsFromEvent = useCallback((event: McpConnectionEvent) => {
283
+ if (!isMountedRef.current) return;
284
+
285
+ const isTransientReconnectState = (state: McpConnectionState): boolean =>
286
+ state === 'INITIALIZING' ||
287
+ state === 'VALIDATING' ||
288
+ state === 'RECONNECTING' ||
289
+ state === 'CONNECTING' ||
290
+ state === 'CONNECTED' ||
291
+ state === 'DISCOVERING';
292
+
293
+ setConnections((prev: McpConnection[]) => {
294
+ switch (event.type) {
295
+ case 'state_changed': {
296
+ const existing = prev.find((c: McpConnection) => c.sessionId === event.sessionId);
297
+ if (existing) {
298
+ // In stateless per-request transport, tool calls can emit transient reconnect states.
299
+ // Keep READY sticky to avoid UI flicker from READY -> CONNECTING -> CONNECTED.
300
+ const nextState =
301
+ existing.state === 'READY' && isTransientReconnectState(event.state)
302
+ ? existing.state
303
+ : event.state;
304
+
305
+ return prev.map((c: McpConnection) =>
306
+ c.sessionId === event.sessionId ? {
307
+ ...c,
308
+ state: nextState,
309
+ // update createdAt if present in event, otherwise keep existing
310
+ createdAt: event.createdAt ? new Date(event.createdAt) : c.createdAt
311
+ } : c
312
+ );
313
+ } else {
314
+ // Fix: Don't add back disconnected sessions that were just removed
315
+ if (event.state === 'DISCONNECTED') {
316
+ return prev;
317
+ }
318
+
319
+ return [
320
+ ...prev,
321
+ {
322
+ sessionId: event.sessionId,
323
+ serverId: event.serverId,
324
+ serverName: event.serverName,
325
+ serverUrl: event.serverUrl,
326
+ state: event.state,
327
+ createdAt: event.createdAt ? new Date(event.createdAt) : undefined,
328
+ tools: [],
329
+ },
330
+ ];
331
+ }
332
+ }
333
+
334
+ case 'tools_discovered': {
335
+ // Preload UI resources for instant loading when tools are discovered
336
+ if (clientRef.current && event.tools?.length) {
337
+ clientRef.current.preloadToolUiResources(event.sessionId, event.tools);
338
+ }
339
+
340
+ return prev.map((c: McpConnection) =>
341
+ c.sessionId === event.sessionId ? { ...c, tools: event.tools, state: 'READY' } : c
342
+ );
343
+ }
344
+
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
+ }
357
+ }
358
+ }
359
+ return prev.map((c: McpConnection) =>
360
+ c.sessionId === event.sessionId ? { ...c, state: 'AUTHENTICATING', authUrl: event.authUrl } : c
361
+ );
362
+ }
363
+
364
+ case 'error': {
365
+ return prev.map((c: McpConnection) =>
366
+ c.sessionId === event.sessionId ? { ...c, state: 'FAILED', error: event.error } : c
367
+ );
368
+ }
369
+
370
+ case 'disconnected': {
371
+ return prev.filter((c: McpConnection) => c.sessionId !== event.sessionId);
372
+ }
373
+
374
+ default:
375
+ return prev;
376
+ }
377
+ });
378
+ }, [onLog, onRedirect]);
379
+
380
+ /**
381
+ * Load sessions from server
382
+ */
383
+ const loadSessions = useCallback(async () => {
384
+ if (!clientRef.current) return;
385
+
386
+ try {
387
+ setIsInitializing(true);
388
+
389
+ const result = await clientRef.current.getSessions();
390
+ const sessions = result.sessions || [];
391
+
392
+ // Initialize connections
393
+ if (isMountedRef.current) {
394
+ setConnections(
395
+ sessions.map((s: SessionInfo) => ({
396
+ sessionId: s.sessionId,
397
+ serverId: s.serverId ?? 'unknown',
398
+ serverName: s.serverName ?? 'Unknown Server',
399
+ serverUrl: s.serverUrl,
400
+ transport: s.transport,
401
+ state: (s.active === false ? 'AUTHENTICATING' : 'VALIDATING') as McpConnectionState,
402
+ createdAt: new Date(s.createdAt),
403
+ tools: [],
404
+ }))
405
+ );
406
+ }
407
+
408
+ // Validate each session in parallel
409
+ await Promise.all(
410
+ sessions.map(async (session: SessionInfo) => {
411
+ if (clientRef.current) {
412
+ try {
413
+ // Pending auth sessions should not auto-trigger popup/redirect on reload.
414
+ if (session.active === false) {
415
+ return;
416
+ }
417
+ suppressAuthRedirectSessionsRef.current.add(session.sessionId);
418
+ await clientRef.current.restoreSession(session.sessionId);
419
+ } catch (error) {
420
+ console.error(`[useMcp] Failed to validate session ${session.sessionId}:`, error);
421
+ } finally {
422
+ suppressAuthRedirectSessionsRef.current.delete(session.sessionId);
423
+ }
424
+ }
425
+ })
426
+ );
427
+ } catch (error) {
428
+ console.error('[useMcp] Failed to load sessions:', error);
429
+ onLog?.('error', 'Failed to load sessions', { error });
430
+ } finally {
431
+ if (isMountedRef.current) {
432
+ setIsInitializing(false);
433
+ }
434
+ }
435
+ }, [onLog]);
436
+
437
+ /**
438
+ * Connect to an MCP server
439
+ */
440
+ const connect = useCallback(
441
+ async (params: {
442
+ serverId: string;
443
+ serverName: string;
444
+ serverUrl: string;
445
+ callbackUrl: string;
446
+ transportType?: 'sse' | 'streamable_http';
447
+ }): Promise<string> => {
448
+ if (!clientRef.current) {
449
+ throw new Error('SSE client not initialized');
450
+ }
451
+
452
+ const result = await clientRef.current.connectToServer(params);
453
+ return result.sessionId;
454
+ },
455
+ []
456
+ );
457
+
458
+ /**
459
+ * Disconnect from an MCP server
460
+ */
461
+ const disconnect = useCallback(async (sessionId: string): Promise<void> => {
462
+ if (!clientRef.current) {
463
+ throw new Error('SSE client not initialized');
464
+ }
465
+
466
+ await clientRef.current.disconnectFromServer(sessionId);
467
+
468
+ // Remove from local state
469
+ if (isMountedRef.current) {
470
+ setConnections((prev: McpConnection[]) => prev.filter((c: McpConnection) => c.sessionId !== sessionId));
471
+ }
472
+ }, []);
473
+
474
+ /**
475
+ * Refresh all connections
476
+ */
477
+ const refresh = useCallback(async () => {
478
+ await loadSessions();
479
+ }, [loadSessions]);
480
+
481
+ /**
482
+ * Manually connect SSE
483
+ */
484
+ const connectSSE = useCallback(() => {
485
+ clientRef.current?.connect();
486
+ }, []);
487
+
488
+ /**
489
+ * Manually disconnect SSE
490
+ */
491
+ const disconnectSSE = useCallback(() => {
492
+ clientRef.current?.disconnect();
493
+ }, []);
494
+
495
+ /**
496
+ * Complete OAuth authorization
497
+ */
498
+ const finishAuth = useCallback(async (sessionId: string, code: string): Promise<FinishAuthResult> => {
499
+ if (!clientRef.current) {
500
+ throw new Error('SSE client not initialized');
501
+ }
502
+
503
+ return await clientRef.current.finishAuth(sessionId, code);
504
+ }, []);
505
+
506
+ /**
507
+ * Explicit user action to resume OAuth for an existing pending session.
508
+ */
509
+ const resumeAuth = useCallback(async (sessionId: string): Promise<void> => {
510
+ if (!clientRef.current) {
511
+ throw new Error('SSE client not initialized');
512
+ }
513
+ // Ensure this attempt is not suppressed as background restore.
514
+ suppressAuthRedirectSessionsRef.current.delete(sessionId);
515
+ await clientRef.current.restoreSession(sessionId);
516
+ }, []);
517
+
518
+ /**
519
+ * Call a tool
520
+ */
521
+ const callTool = useCallback(
522
+ async (
523
+ sessionId: string,
524
+ toolName: string,
525
+ toolArgs: Record<string, unknown>
526
+ ): Promise<unknown> => {
527
+ if (!clientRef.current) {
528
+ throw new Error('SSE client not initialized');
529
+ }
530
+
531
+ return await clientRef.current.callTool(sessionId, toolName, toolArgs);
532
+ },
533
+ []
534
+ );
535
+
536
+ /**
537
+ * List tools (refresh tool list)
538
+ */
539
+ const listTools = useCallback(async (sessionId: string): Promise<ListToolsRpcResult> => {
540
+ if (!clientRef.current) {
541
+ throw new Error('SSE client not initialized');
542
+ }
543
+
544
+ return await clientRef.current.listTools(sessionId);
545
+ }, []);
546
+
547
+ /**
548
+ * List prompts
549
+ */
550
+ const listPrompts = useCallback(async (sessionId: string): Promise<ListPromptsResult> => {
551
+ if (!clientRef.current) {
552
+ throw new Error('SSE client not initialized');
553
+ }
554
+
555
+ return await clientRef.current.listPrompts(sessionId);
556
+ }, []);
557
+
558
+ /**
559
+ * Get a specific prompt
560
+ */
561
+ const getPrompt = useCallback(
562
+ async (sessionId: string, name: string, args?: Record<string, string>): Promise<unknown> => {
563
+ if (!clientRef.current) {
564
+ throw new Error('SSE client not initialized');
565
+ }
566
+
567
+ return await clientRef.current.getPrompt(sessionId, name, args);
568
+ },
569
+ []
570
+ );
571
+
572
+ /**
573
+ * List resources
574
+ */
575
+ const listResources = useCallback(async (sessionId: string): Promise<ListResourcesResult> => {
576
+ if (!clientRef.current) {
577
+ throw new Error('SSE client not initialized');
578
+ }
579
+
580
+ return await clientRef.current.listResources(sessionId);
581
+ }, []);
582
+
583
+ /**
584
+ * Read a specific resource
585
+ */
586
+ const readResource = useCallback(async (sessionId: string, uri: string): Promise<unknown> => {
587
+ if (!clientRef.current) {
588
+ throw new Error('SSE client not initialized');
589
+ }
590
+
591
+ return await clientRef.current.readResource(sessionId, uri);
592
+ }, []);
593
+
594
+ // Utility functions
595
+ const getConnection = useCallback(
596
+ (sessionId: string) => connections.find((c: McpConnection) => c.sessionId === sessionId),
597
+ [connections]
598
+ );
599
+
600
+ const getConnectionByServerId = useCallback(
601
+ (serverId: string) => connections.find((c: McpConnection) => c.serverId === serverId),
602
+ [connections]
603
+ );
604
+
605
+ const isServerConnected = useCallback(
606
+ (serverId: string) => {
607
+ const conn = getConnectionByServerId(serverId);
608
+ return conn ? conn.state === 'CONNECTED' || conn.state === 'DISCOVERING' || conn.state === 'READY' : false;
609
+ },
610
+ [getConnectionByServerId]
611
+ );
612
+
613
+ const getTools = useCallback(
614
+ (sessionId: string) => {
615
+ const conn = getConnection(sessionId);
616
+ return conn?.tools || [];
617
+ },
618
+ [getConnection]
619
+ );
620
+
621
+ return useMemo(
622
+ () => ({
623
+ connections,
624
+ status,
625
+ isInitializing,
626
+ connect,
627
+ disconnect,
628
+ getConnection,
629
+ getConnectionByServerId,
630
+ isServerConnected,
631
+ getTools,
632
+ refresh,
633
+ connectSSE,
634
+ disconnectSSE,
635
+ finishAuth,
636
+ resumeAuth,
637
+ callTool,
638
+ listTools,
639
+ listPrompts,
640
+ getPrompt,
641
+ listResources,
642
+ readResource,
643
+ sseClient,
644
+ }),
645
+ [
646
+ connections,
647
+ status,
648
+ isInitializing,
649
+ connect,
650
+ disconnect,
651
+ getConnection,
652
+ getConnectionByServerId,
653
+ isServerConnected,
654
+ getTools,
655
+ refresh,
656
+ connectSSE,
657
+ disconnectSSE,
658
+ finishAuth,
659
+ resumeAuth,
660
+ callTool,
661
+ listTools,
662
+ listPrompts,
663
+ getPrompt,
664
+ listResources,
665
+ readResource,
666
+ sseClient,
667
+ ]
668
+ );
669
+ }