@mcp-ts/sdk 1.3.6 → 1.3.9

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 (103) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +398 -404
  3. package/dist/adapters/agui-adapter.d.mts +1 -1
  4. package/dist/adapters/agui-adapter.d.ts +1 -1
  5. package/dist/adapters/agui-adapter.js +2 -2
  6. package/dist/adapters/agui-adapter.js.map +1 -1
  7. package/dist/adapters/agui-adapter.mjs +2 -2
  8. package/dist/adapters/agui-adapter.mjs.map +1 -1
  9. package/dist/adapters/agui-middleware.d.mts +1 -1
  10. package/dist/adapters/agui-middleware.d.ts +1 -1
  11. package/dist/adapters/agui-middleware.js.map +1 -1
  12. package/dist/adapters/agui-middleware.mjs.map +1 -1
  13. package/dist/adapters/ai-adapter.d.mts +1 -1
  14. package/dist/adapters/ai-adapter.d.ts +1 -1
  15. package/dist/adapters/ai-adapter.js +1 -1
  16. package/dist/adapters/ai-adapter.js.map +1 -1
  17. package/dist/adapters/ai-adapter.mjs +1 -1
  18. package/dist/adapters/ai-adapter.mjs.map +1 -1
  19. package/dist/adapters/langchain-adapter.d.mts +1 -1
  20. package/dist/adapters/langchain-adapter.d.ts +1 -1
  21. package/dist/adapters/langchain-adapter.js +1 -1
  22. package/dist/adapters/langchain-adapter.js.map +1 -1
  23. package/dist/adapters/langchain-adapter.mjs +1 -1
  24. package/dist/adapters/langchain-adapter.mjs.map +1 -1
  25. package/dist/adapters/mastra-adapter.d.mts +1 -1
  26. package/dist/adapters/mastra-adapter.d.ts +1 -1
  27. package/dist/adapters/mastra-adapter.js +1 -1
  28. package/dist/adapters/mastra-adapter.js.map +1 -1
  29. package/dist/adapters/mastra-adapter.mjs +1 -1
  30. package/dist/adapters/mastra-adapter.mjs.map +1 -1
  31. package/dist/bin/mcp-ts.js +0 -0
  32. package/dist/bin/mcp-ts.js.map +1 -1
  33. package/dist/bin/mcp-ts.mjs +0 -0
  34. package/dist/bin/mcp-ts.mjs.map +1 -1
  35. package/dist/client/index.js.map +1 -1
  36. package/dist/client/index.mjs.map +1 -1
  37. package/dist/client/react.d.mts +2 -2
  38. package/dist/client/react.d.ts +2 -2
  39. package/dist/client/react.js +25 -2
  40. package/dist/client/react.js.map +1 -1
  41. package/dist/client/react.mjs +26 -3
  42. package/dist/client/react.mjs.map +1 -1
  43. package/dist/client/vue.js.map +1 -1
  44. package/dist/client/vue.mjs.map +1 -1
  45. package/dist/index.d.mts +1 -1
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.js +134 -71
  48. package/dist/index.js.map +1 -1
  49. package/dist/index.mjs +134 -71
  50. package/dist/index.mjs.map +1 -1
  51. package/dist/{multi-session-client-BYLarghq.d.ts → multi-session-client-CHE8QpVE.d.ts} +75 -5
  52. package/dist/{multi-session-client-CzhMkE0k.d.mts → multi-session-client-CQsRbxYI.d.mts} +75 -5
  53. package/dist/server/index.d.mts +1 -1
  54. package/dist/server/index.d.ts +1 -1
  55. package/dist/server/index.js +134 -71
  56. package/dist/server/index.js.map +1 -1
  57. package/dist/server/index.mjs +134 -71
  58. package/dist/server/index.mjs.map +1 -1
  59. package/dist/shared/index.js +10 -2
  60. package/dist/shared/index.js.map +1 -1
  61. package/dist/shared/index.mjs +10 -2
  62. package/dist/shared/index.mjs.map +1 -1
  63. package/package.json +185 -185
  64. package/src/adapters/agui-adapter.ts +222 -222
  65. package/src/adapters/agui-middleware.ts +382 -382
  66. package/src/adapters/ai-adapter.ts +115 -115
  67. package/src/adapters/langchain-adapter.ts +127 -127
  68. package/src/adapters/mastra-adapter.ts +126 -126
  69. package/src/bin/mcp-ts.ts +102 -102
  70. package/src/client/core/app-host.ts +417 -417
  71. package/src/client/core/sse-client.ts +371 -371
  72. package/src/client/core/types.ts +31 -31
  73. package/src/client/index.ts +27 -27
  74. package/src/client/react/index.ts +16 -16
  75. package/src/client/react/use-app-host.ts +73 -73
  76. package/src/client/react/use-mcp-apps.tsx +247 -214
  77. package/src/client/react/use-mcp.ts +641 -641
  78. package/src/client/vue/index.ts +10 -10
  79. package/src/client/vue/use-mcp.ts +617 -617
  80. package/src/index.ts +11 -11
  81. package/src/server/handlers/nextjs-handler.ts +204 -204
  82. package/src/server/handlers/sse-handler.ts +631 -631
  83. package/src/server/index.ts +57 -57
  84. package/src/server/mcp/multi-session-client.ts +228 -132
  85. package/src/server/mcp/oauth-client.ts +1188 -1188
  86. package/src/server/mcp/storage-oauth-provider.ts +272 -272
  87. package/src/server/storage/file-backend.ts +157 -170
  88. package/src/server/storage/index.ts +176 -175
  89. package/src/server/storage/memory-backend.ts +123 -136
  90. package/src/server/storage/redis-backend.ts +276 -289
  91. package/src/server/storage/redis.ts +160 -160
  92. package/src/server/storage/sqlite-backend.ts +182 -186
  93. package/src/server/storage/supabase-backend.ts +228 -227
  94. package/src/server/storage/types.ts +116 -116
  95. package/src/shared/constants.ts +29 -29
  96. package/src/shared/errors.ts +133 -133
  97. package/src/shared/event-routing.ts +28 -28
  98. package/src/shared/events.ts +180 -180
  99. package/src/shared/index.ts +75 -75
  100. package/src/shared/tool-utils.ts +61 -61
  101. package/src/shared/types.ts +282 -282
  102. package/src/shared/utils.ts +38 -16
  103. package/supabase/migrations/20260330195700_install_mcp_sessions.sql +84 -84
@@ -1,641 +1,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 } 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 } 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
+ }