@mcp-ts/sdk 2.3.4 → 2.4.1

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 (89) hide show
  1. package/README.md +161 -39
  2. package/dist/adapters/agui-adapter.d.mts +4 -4
  3. package/dist/adapters/agui-adapter.d.ts +4 -4
  4. package/dist/adapters/agui-middleware.d.mts +4 -4
  5. package/dist/adapters/agui-middleware.d.ts +4 -4
  6. package/dist/adapters/ai-adapter.d.mts +4 -4
  7. package/dist/adapters/ai-adapter.d.ts +4 -4
  8. package/dist/adapters/langchain-adapter.d.mts +4 -4
  9. package/dist/adapters/langchain-adapter.d.ts +4 -4
  10. package/dist/adapters/mastra-adapter.d.mts +2 -2
  11. package/dist/adapters/mastra-adapter.d.ts +2 -2
  12. package/dist/client/index.d.mts +3 -3
  13. package/dist/client/index.d.ts +3 -3
  14. package/dist/client/index.js +2 -2
  15. package/dist/client/index.js.map +1 -1
  16. package/dist/client/index.mjs +2 -2
  17. package/dist/client/index.mjs.map +1 -1
  18. package/dist/client/react.d.mts +11 -10
  19. package/dist/client/react.d.ts +11 -10
  20. package/dist/client/react.js +66 -26
  21. package/dist/client/react.js.map +1 -1
  22. package/dist/client/react.mjs +67 -27
  23. package/dist/client/react.mjs.map +1 -1
  24. package/dist/client/vue.d.mts +8 -7
  25. package/dist/client/vue.d.ts +8 -7
  26. package/dist/client/vue.js +27 -17
  27. package/dist/client/vue.js.map +1 -1
  28. package/dist/client/vue.mjs +27 -17
  29. package/dist/client/vue.mjs.map +1 -1
  30. package/dist/{events-CK3N--3g.d.mts → events-C4m7tK1U.d.mts} +0 -1
  31. package/dist/{events-CK3N--3g.d.ts → events-C4m7tK1U.d.ts} +0 -1
  32. package/dist/{index-CmjMd2ac.d.ts → index-Ch7ouNSa.d.ts} +3 -3
  33. package/dist/{index-Cfjsme-a.d.mts → index-L5XoXgsb.d.mts} +3 -3
  34. package/dist/index.d.mts +6 -6
  35. package/dist/index.d.ts +6 -6
  36. package/dist/index.js +799 -326
  37. package/dist/index.js.map +1 -1
  38. package/dist/index.mjs +793 -324
  39. package/dist/index.mjs.map +1 -1
  40. package/dist/{multi-session-client-C_kPHpD5.d.ts → multi-session-client-Bwm-efqg.d.ts} +43 -38
  41. package/dist/{multi-session-client-C7hGqzgM.d.mts → multi-session-client-k-9RvWvi.d.mts} +43 -38
  42. package/dist/server/index.d.mts +48 -20
  43. package/dist/server/index.d.ts +48 -20
  44. package/dist/server/index.js +791 -320
  45. package/dist/server/index.js.map +1 -1
  46. package/dist/server/index.mjs +787 -319
  47. package/dist/server/index.mjs.map +1 -1
  48. package/dist/shared/index.d.mts +10 -9
  49. package/dist/shared/index.d.ts +10 -9
  50. package/dist/shared/index.js +7 -5
  51. package/dist/shared/index.js.map +1 -1
  52. package/dist/shared/index.mjs +5 -4
  53. package/dist/shared/index.mjs.map +1 -1
  54. package/dist/{tool-router-BMzhoNYt.d.ts → tool-router-CZMrOG8J.d.ts} +1 -1
  55. package/dist/{tool-router-BhHsvBfP.d.mts → tool-router-CuApsDiV.d.mts} +1 -1
  56. package/dist/{types-CxFaaZrM.d.mts → types-DCk_IF4L.d.mts} +5 -3
  57. package/dist/{types-CxFaaZrM.d.ts → types-DCk_IF4L.d.ts} +5 -3
  58. package/migrations/neon/20260513010000_install_mcp_sessions.sql +40 -3
  59. package/migrations/neon/20260513020000_add_session_cleanup_cron.sql +4 -4
  60. package/migrations/supabase/20260330195700_install_mcp_sessions.sql +67 -3
  61. package/migrations/supabase/20260421010000_add_session_cleanup_cron.sql +6 -6
  62. package/migrations/v2.3.4/neon/20260513010000_install_mcp_sessions.sql +69 -0
  63. package/migrations/v2.3.4/neon/20260513020000_add_session_cleanup_cron.sql +35 -0
  64. package/migrations/v2.3.4/supabase/20260330195700_install_mcp_sessions.sql +82 -0
  65. package/migrations/v2.3.4/supabase/20260421010000_add_session_cleanup_cron.sql +32 -0
  66. package/package.json +13 -3
  67. package/src/client/core/sse-client.ts +2 -2
  68. package/src/client/react/index.ts +1 -1
  69. package/src/client/react/oauth-popup.tsx +34 -13
  70. package/src/client/react/use-mcp.ts +19 -45
  71. package/src/client/utils/session-state.ts +43 -0
  72. package/src/client/vue/use-mcp.ts +18 -47
  73. package/src/server/handlers/sse-handler.ts +16 -9
  74. package/src/server/mcp/multi-session-client.ts +48 -11
  75. package/src/server/mcp/oauth-client.ts +133 -111
  76. package/src/server/mcp/storage-oauth-provider.ts +79 -50
  77. package/src/server/storage/file-backend.ts +74 -18
  78. package/src/server/storage/index.ts +2 -4
  79. package/src/server/storage/memory-backend.ts +49 -13
  80. package/src/server/storage/neon-backend.ts +201 -68
  81. package/src/server/storage/redis-backend.ts +81 -23
  82. package/src/server/storage/session-lifecycle.ts +78 -0
  83. package/src/server/storage/sqlite-backend.ts +89 -15
  84. package/src/server/storage/supabase-backend.ts +188 -63
  85. package/src/server/storage/types.ts +49 -16
  86. package/src/shared/constants.ts +5 -6
  87. package/src/shared/events.ts +0 -1
  88. package/src/shared/types.ts +5 -3
  89. package/src/shared/utils.ts +26 -0
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
2
+ import { parseOAuthState } from '../../shared/utils.js';
2
3
 
3
4
  export interface OAuthPopupConnectionLike {
4
5
  sessionId: string;
@@ -11,7 +12,7 @@ export interface OAuthPopupConnectionLike {
11
12
  *
12
13
  * These utilities sit on top of the core MCP auth primitives:
13
14
  * - `useMcp({ onRedirect })` to decide how auth navigation happens
14
- * - `finishAuth(sessionId, code)` to complete code exchange
15
+ * - `finishAuth(state, code)` to complete code exchange
15
16
  *
16
17
  * Consumers are free to:
17
18
  * - use these helpers as-is for a turnkey popup flow
@@ -60,6 +61,7 @@ function postPopupResult(
60
61
  popupWindow: WindowProxy | null,
61
62
  result: {
62
63
  sessionId?: string;
64
+ state?: string;
63
65
  success: boolean;
64
66
  error?: string;
65
67
  }
@@ -150,9 +152,9 @@ export function createOAuthPopupRedirectHandler(
150
152
  */
151
153
  export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
152
154
  connections: TConnection[],
153
- finishAuth: (sessionId: string, code: string) => Promise<unknown>
155
+ finishAuth: (state: string, code: string) => Promise<unknown>
154
156
  ): void {
155
- const pendingPopupsRef = useRef<Map<string, WindowProxy>>(new Map());
157
+ const pendingPopupsRef = useRef<Map<string, { popupWindow: WindowProxy | null; state: string }>>(new Map());
156
158
  const processingCodesRef = useRef<Set<string>>(new Set());
157
159
 
158
160
  useEffect(() => {
@@ -169,10 +171,16 @@ export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
169
171
  const popupWindow = event.source && 'postMessage' in event.source
170
172
  ? event.source as WindowProxy
171
173
  : null;
172
- const targetSessionId = typeof event.data.sessionId === 'string' ? event.data.sessionId : '';
174
+ const rawState =
175
+ typeof event.data.state === 'string'
176
+ ? event.data.state
177
+ : typeof event.data.sessionId === 'string'
178
+ ? event.data.sessionId
179
+ : '';
180
+ const targetSessionId = rawState ? parseOAuthState(rawState)?.sessionId || rawState : '';
173
181
 
174
182
  if (popupWindow && targetSessionId) {
175
- pendingPopupsRef.current.set(targetSessionId, popupWindow);
183
+ pendingPopupsRef.current.set(targetSessionId, { popupWindow, state: rawState });
176
184
  }
177
185
 
178
186
  if (!targetSessionId) {
@@ -190,6 +198,7 @@ export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
190
198
  if (popupWindow) {
191
199
  postPopupResult(popupWindow, {
192
200
  sessionId: targetSessionId,
201
+ state: rawState,
193
202
  success: false,
194
203
  error: 'OAuth session not found in the current client state',
195
204
  });
@@ -204,13 +213,14 @@ export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
204
213
  processingCodesRef.current.add(codeKey);
205
214
 
206
215
  try {
207
- await finishAuth(targetSession.sessionId, code);
216
+ await finishAuth(rawState, code);
208
217
  } catch (error) {
209
218
  processingCodesRef.current.delete(codeKey);
210
219
  pendingPopupsRef.current.delete(targetSession.sessionId);
211
220
  if (popupWindow) {
212
221
  postPopupResult(popupWindow, {
213
- sessionId: targetSession.sessionId,
222
+ sessionId: rawState,
223
+ state: rawState,
214
224
  success: false,
215
225
  error: error instanceof Error ? error.message : 'Failed to finish auth',
216
226
  });
@@ -237,11 +247,14 @@ export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
237
247
 
238
248
  useEffect(() => {
239
249
  for (const connection of connections) {
240
- const popupWindow = pendingPopupsRef.current.get(connection.sessionId) || null;
250
+ const pendingPopup = pendingPopupsRef.current.get(connection.sessionId);
251
+ const popupWindow = pendingPopup?.popupWindow || null;
252
+ const resultState = pendingPopup?.state || connection.sessionId;
241
253
 
242
254
  if (connection.state === 'AUTHENTICATED' || connection.state === 'READY' || connection.state === 'CONNECTED') {
243
255
  postPopupResult(popupWindow, {
244
- sessionId: connection.sessionId,
256
+ sessionId: resultState,
257
+ state: resultState,
245
258
  success: true,
246
259
  });
247
260
  for (const codeKey of processingCodesRef.current) {
@@ -255,7 +268,8 @@ export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
255
268
 
256
269
  if (connection.state === 'FAILED') {
257
270
  postPopupResult(popupWindow, {
258
- sessionId: connection.sessionId,
271
+ sessionId: resultState,
272
+ state: resultState,
259
273
  success: false,
260
274
  error: connection.error || 'Failed to complete authorization',
261
275
  });
@@ -277,7 +291,7 @@ export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
277
291
  * opener window, waits for success/failure, and closes the popup on success.
278
292
  *
279
293
  * It is intentionally optional: apps can replace it with their own callback
280
- * page UI or skip popup auth entirely and call `finishAuth(sessionId, code)`
294
+ * page UI or skip popup auth entirely and call `finishAuth(state, code)`
281
295
  * from any callback route they control.
282
296
  */
283
297
  export function McpOAuthCallbackContent({
@@ -328,7 +342,14 @@ export function McpOAuthCallbackContent({
328
342
  return;
329
343
  }
330
344
 
331
- if (event.data.sessionId !== sessionId) {
345
+ const resultState =
346
+ typeof event.data.state === 'string'
347
+ ? event.data.state
348
+ : typeof event.data.sessionId === 'string'
349
+ ? event.data.sessionId
350
+ : '';
351
+
352
+ if (resultState !== sessionId) {
332
353
  return;
333
354
  }
334
355
 
@@ -352,7 +373,7 @@ export function McpOAuthCallbackContent({
352
373
  window.addEventListener('message', handleResult);
353
374
  channel?.addEventListener('message', handleResult);
354
375
 
355
- const payload = { type: AUTH_CODE_MESSAGE, code, sessionId };
376
+ const payload = { type: AUTH_CODE_MESSAGE, code, state: sessionId, sessionId };
356
377
 
357
378
  if (window.opener) {
358
379
  try {
@@ -6,6 +6,11 @@
6
6
 
7
7
  import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
8
8
  import { SSEClient, type SSEClientOptions } from '../core/sse-client';
9
+ import {
10
+ getInitialConnectionState,
11
+ getVisibleConnectionState,
12
+ isTransientReconnectState,
13
+ } from '../utils/session-state';
9
14
  import type { McpConnectionEvent, McpConnectionState } from '../../shared/events';
10
15
  import type {
11
16
  ToolInfo,
@@ -84,6 +89,7 @@ export interface McpConnection {
84
89
  authUrl?: string;
85
90
  error?: string;
86
91
  createdAt?: Date;
92
+ updatedAt?: Date;
87
93
  }
88
94
 
89
95
  export interface McpClient {
@@ -167,7 +173,7 @@ export interface McpClient {
167
173
  /**
168
174
  * Complete OAuth authorization
169
175
  */
170
- finishAuth: (sessionId: string, code: string) => Promise<FinishAuthResult>;
176
+ finishAuth: (state: string, code: string) => Promise<FinishAuthResult>;
171
177
 
172
178
  /**
173
179
  * Explicitly resume OAuth flow for an existing session
@@ -293,42 +299,6 @@ export function useMcp(options: UseMcpOptions): McpClient {
293
299
  const updateConnectionsFromEvent = useCallback((event: McpConnectionEvent) => {
294
300
  if (!isMountedRef.current) return;
295
301
 
296
- const isTransientReconnectState = (state: McpConnectionState): boolean =>
297
- state === 'INITIALIZING' ||
298
- state === 'VALIDATING' ||
299
- state === 'RECONNECTING' ||
300
- state === 'CONNECTING' ||
301
- state === 'CONNECTED' ||
302
- state === 'DISCOVERING';
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
-
332
302
  setConnections((prev: McpConnection[]) => {
333
303
  switch (event.type) {
334
304
  case 'state_changed': {
@@ -336,7 +306,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
336
306
  if (existing) {
337
307
  // Normalize the incoming backend state into the smoother user-facing
338
308
  // state we want to render for this existing connection.
339
- const normalizedState = getVisibleState(event.state, existing.state, event.previousState);
309
+ const normalizedState = getVisibleConnectionState(event.state, existing.state, event.previousState);
340
310
  // In stateless per-request transport, tool calls can emit transient reconnect states.
341
311
  // Keep READY sticky to avoid UI flicker from READY -> CONNECTING -> CONNECTED.
342
312
  const nextState =
@@ -344,12 +314,14 @@ export function useMcp(options: UseMcpOptions): McpClient {
344
314
  ? existing.state
345
315
  : normalizedState;
346
316
 
317
+ const updatedAt = new Date();
347
318
  return prev.map((c: McpConnection) =>
348
319
  c.sessionId === event.sessionId ? {
349
320
  ...c,
350
321
  state: nextState,
351
322
  // update createdAt if present in event, otherwise keep existing
352
- createdAt: event.createdAt ? new Date(event.createdAt) : c.createdAt
323
+ createdAt: event.createdAt ? new Date(event.createdAt) : c.createdAt,
324
+ updatedAt,
353
325
  } : c
354
326
  );
355
327
  } else {
@@ -367,8 +339,9 @@ export function useMcp(options: UseMcpOptions): McpClient {
367
339
  serverUrl: event.serverUrl,
368
340
  // New connections do not have prior local state, so we normalize
369
341
  // only against the server-reported previous state.
370
- state: getVisibleState(event.state, undefined, event.previousState),
342
+ state: getVisibleConnectionState(event.state, undefined, event.previousState),
371
343
  createdAt: event.createdAt ? new Date(event.createdAt) : undefined,
344
+ updatedAt: new Date(),
372
345
  tools: [],
373
346
  },
374
347
  ];
@@ -382,7 +355,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
382
355
  }
383
356
 
384
357
  return prev.map((c: McpConnection) =>
385
- c.sessionId === event.sessionId ? { ...c, tools: event.tools, state: 'READY' } : c
358
+ c.sessionId === event.sessionId ? { ...c, tools: event.tools, state: 'READY', updatedAt: new Date() } : c
386
359
  );
387
360
  }
388
361
 
@@ -453,8 +426,9 @@ export function useMcp(options: UseMcpOptions): McpClient {
453
426
  serverName: s.serverName ?? 'Unknown Server',
454
427
  serverUrl: s.serverUrl,
455
428
  transport: s.transport,
456
- state: (s.active === false ? 'AUTHENTICATING' : 'VALIDATING') as McpConnectionState,
429
+ state: getInitialConnectionState(s.status),
457
430
  createdAt: new Date(s.createdAt),
431
+ updatedAt: new Date(s.updatedAt ?? s.createdAt),
458
432
  tools: [],
459
433
  }))
460
434
  );
@@ -466,7 +440,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
466
440
  if (clientRef.current) {
467
441
  try {
468
442
  // Pending auth sessions should not auto-trigger popup/redirect on reload.
469
- if (session.active === false) {
443
+ if (session.status !== 'active') {
470
444
  return;
471
445
  }
472
446
  suppressAuthRedirectSessionsRef.current.add(session.sessionId);
@@ -585,12 +559,12 @@ export function useMcp(options: UseMcpOptions): McpClient {
585
559
  /**
586
560
  * Complete OAuth authorization
587
561
  */
588
- const finishAuth = useCallback(async (sessionId: string, code: string): Promise<FinishAuthResult> => {
562
+ const finishAuth = useCallback(async (state: string, code: string): Promise<FinishAuthResult> => {
589
563
  if (!clientRef.current) {
590
564
  throw new Error('SSE client not initialized');
591
565
  }
592
566
 
593
- return await clientRef.current.finishAuth(sessionId, code);
567
+ return await clientRef.current.finishAuth(state, code);
594
568
  }, []);
595
569
 
596
570
  /**
@@ -0,0 +1,43 @@
1
+ import type { McpConnectionState } from '../../shared/events';
2
+ import type { SessionInfo } from '../../shared/types';
3
+
4
+ export function getInitialConnectionState(status: SessionInfo['status']): McpConnectionState {
5
+ return status === 'active' ? 'VALIDATING' : 'AUTHENTICATING';
6
+ }
7
+
8
+ export function isTransientReconnectState(state: McpConnectionState): boolean {
9
+ return state === 'INITIALIZING' ||
10
+ state === 'VALIDATING' ||
11
+ state === 'RECONNECTING' ||
12
+ state === 'CONNECTING' ||
13
+ state === 'CONNECTED' ||
14
+ state === 'DISCOVERING';
15
+ }
16
+
17
+ export function getVisibleConnectionState(
18
+ incomingState: McpConnectionState,
19
+ existingState?: McpConnectionState,
20
+ previousState?: McpConnectionState
21
+ ): McpConnectionState {
22
+ // `INITIALIZING` has two meanings in practice:
23
+ // 1. genuine cold start / reconnect work
24
+ // 2. an internal setup step that happens mid-OAuth completion
25
+ //
26
+ // For case (2), showing raw `INITIALIZING` creates a confusing user-facing
27
+ // sequence like AUTHENTICATING -> INITIALIZING -> AUTHENTICATED.
28
+ if (
29
+ incomingState === 'INITIALIZING' &&
30
+ (
31
+ existingState === 'AUTHENTICATING' ||
32
+ existingState === 'AUTHENTICATED' ||
33
+ previousState === 'AUTHENTICATING' ||
34
+ previousState === 'AUTHENTICATED'
35
+ )
36
+ ) {
37
+ return existingState === 'AUTHENTICATED' || previousState === 'AUTHENTICATED'
38
+ ? 'AUTHENTICATED'
39
+ : 'AUTHENTICATING';
40
+ }
41
+
42
+ return incomingState;
43
+ }
@@ -6,6 +6,11 @@
6
6
 
7
7
  import { ref, onMounted, onUnmounted, watch, computed, shallowRef } from 'vue';
8
8
  import { SSEClient, type SSEClientOptions } from '../core/sse-client';
9
+ import {
10
+ getInitialConnectionState,
11
+ getVisibleConnectionState,
12
+ isTransientReconnectState,
13
+ } from '../utils/session-state';
9
14
  import type { McpConnectionEvent, McpConnectionState } from '../../shared/events';
10
15
  import type {
11
16
  ToolInfo,
@@ -83,6 +88,7 @@ export interface McpConnection {
83
88
  authUrl?: string;
84
89
  error?: string;
85
90
  createdAt?: Date;
91
+ updatedAt?: Date;
86
92
  }
87
93
 
88
94
  export interface McpClient {
@@ -166,7 +172,7 @@ export interface McpClient {
166
172
  /**
167
173
  * Complete OAuth authorization
168
174
  */
169
- finishAuth: (sessionId: string, code: string) => Promise<FinishAuthResult>;
175
+ finishAuth: (state: string, code: string) => Promise<FinishAuthResult>;
170
176
 
171
177
  /**
172
178
  * Explicitly resume OAuth flow for an existing session
@@ -243,51 +249,13 @@ export function useMcp(options: UseMcpOptions): McpClient {
243
249
  const updateConnectionsFromEvent = (event: McpConnectionEvent) => {
244
250
  if (!isMountedRef.value) return;
245
251
 
246
- const isTransientReconnectState = (state: McpConnectionState): boolean =>
247
- state === 'INITIALIZING' ||
248
- state === 'VALIDATING' ||
249
- state === 'RECONNECTING' ||
250
- state === 'CONNECTING' ||
251
- state === 'CONNECTED' ||
252
- state === 'DISCOVERING';
253
-
254
- const getVisibleState = (
255
- incomingState: McpConnectionState,
256
- existingState?: McpConnectionState,
257
- previousState?: McpConnectionState
258
- ): McpConnectionState => {
259
- // `INITIALIZING` has two meanings in practice:
260
- // 1. genuine cold start / reconnect work
261
- // 2. an internal setup step that happens mid-OAuth completion
262
- //
263
- // For case (2), showing raw `INITIALIZING` creates a confusing user-facing
264
- // sequence like AUTHENTICATING -> INITIALIZING -> AUTHENTICATED.
265
- // We keep the raw event stream intact for observability, but collapse the
266
- // visible state back into the current auth phase in the UI.
267
- if (
268
- incomingState === 'INITIALIZING' &&
269
- (
270
- existingState === 'AUTHENTICATING' ||
271
- existingState === 'AUTHENTICATED' ||
272
- previousState === 'AUTHENTICATING' ||
273
- previousState === 'AUTHENTICATED'
274
- )
275
- ) {
276
- return existingState === 'AUTHENTICATED' || previousState === 'AUTHENTICATED'
277
- ? 'AUTHENTICATED'
278
- : 'AUTHENTICATING';
279
- }
280
-
281
- return incomingState;
282
- };
283
-
284
252
  switch (event.type) {
285
253
  case 'state_changed': {
286
254
  const existing = connections.value.find((c) => c.sessionId === event.sessionId);
287
255
  if (existing) {
288
256
  // Normalize the incoming backend state into the smoother user-facing
289
257
  // state we want to render for this existing connection.
290
- const normalizedState = getVisibleState(event.state, existing.state, event.previousState);
258
+ const normalizedState = getVisibleConnectionState(event.state, existing.state, event.previousState);
291
259
  // In stateless per-request transport, tool calls can emit transient reconnect states.
292
260
  // Keep READY sticky to avoid UI flicker from READY -> CONNECTING -> CONNECTED.
293
261
  const nextState =
@@ -300,7 +268,8 @@ export function useMcp(options: UseMcpOptions): McpClient {
300
268
  ...existing,
301
269
  state: nextState,
302
270
  // update createdAt if present in event, otherwise keep existing
303
- createdAt: event.createdAt ? new Date(event.createdAt) : existing.createdAt
271
+ createdAt: event.createdAt ? new Date(event.createdAt) : existing.createdAt,
272
+ updatedAt: new Date(),
304
273
  };
305
274
  } else {
306
275
  // Fix: Don't add back disconnected sessions that were just removed
@@ -314,8 +283,9 @@ export function useMcp(options: UseMcpOptions): McpClient {
314
283
  serverName: event.serverName,
315
284
  // New connections do not have prior local state, so we normalize
316
285
  // only against the server-reported previous state.
317
- state: getVisibleState(event.state, undefined, event.previousState),
286
+ state: getVisibleConnectionState(event.state, undefined, event.previousState),
318
287
  createdAt: event.createdAt ? new Date(event.createdAt) : undefined,
288
+ updatedAt: new Date(),
319
289
  tools: [],
320
290
  }];
321
291
  }
@@ -325,7 +295,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
325
295
  case 'tools_discovered': {
326
296
  const index = connections.value.findIndex((c) => c.sessionId === event.sessionId);
327
297
  if (index !== -1) {
328
- connections.value[index] = { ...connections.value[index], tools: event.tools, state: 'READY' };
298
+ connections.value[index] = { ...connections.value[index], tools: event.tools, state: 'READY', updatedAt: new Date() };
329
299
  }
330
300
  break;
331
301
  }
@@ -397,8 +367,9 @@ export function useMcp(options: UseMcpOptions): McpClient {
397
367
  serverName: s.serverName ?? 'Unknown Server',
398
368
  serverUrl: s.serverUrl,
399
369
  transport: s.transport,
400
- state: (s.active === false ? 'AUTHENTICATING' : 'VALIDATING') as McpConnectionState,
370
+ state: getInitialConnectionState(s.status),
401
371
  createdAt: new Date(s.createdAt),
372
+ updatedAt: new Date(s.updatedAt ?? s.createdAt),
402
373
  tools: [],
403
374
  }));
404
375
  }
@@ -409,7 +380,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
409
380
  if (clientRef.value) {
410
381
  try {
411
382
  // Pending auth sessions should not auto-trigger popup/redirect on reload.
412
- if (session.active === false) {
383
+ if (session.status !== 'active') {
413
384
  return;
414
385
  }
415
386
  suppressAuthRedirectSessions.value.add(session.sessionId);
@@ -573,12 +544,12 @@ export function useMcp(options: UseMcpOptions): McpClient {
573
544
  /**
574
545
  * Complete OAuth authorization
575
546
  */
576
- const finishAuth = async (sessionId: string, code: string): Promise<FinishAuthResult> => {
547
+ const finishAuth = async (state: string, code: string): Promise<FinishAuthResult> => {
577
548
  if (!clientRef.value) {
578
549
  throw new Error('SSE client not initialized');
579
550
  }
580
551
 
581
- return await clientRef.value.finishAuth(sessionId, code);
552
+ return await clientRef.value.finishAuth(state, code);
582
553
  };
583
554
 
584
555
  /**
@@ -36,6 +36,7 @@ import type {
36
36
  import { RpcErrorCodes } from '../../shared/errors.js';
37
37
  import { UnauthorizedError } from '../../shared/errors.js';
38
38
  import { isConnectionEvent, isRpcResponseEvent } from '../../shared/event-routing.js';
39
+ import { parseOAuthState } from '../../shared/utils.js';
39
40
  import { MCPClient } from '../mcp/oauth-client.js';
40
41
  import { sessions } from '../storage/index.js';
41
42
 
@@ -238,7 +239,8 @@ export class SSEConnectionManager {
238
239
  serverUrl: s.serverUrl,
239
240
  transport: s.transportType,
240
241
  createdAt: s.createdAt,
241
- active: s.active !== false,
242
+ updatedAt: s.updatedAt ?? s.createdAt,
243
+ status: s.status ?? 'pending',
242
244
  })),
243
245
  };
244
246
  }
@@ -265,7 +267,7 @@ export class SSEConnectionManager {
265
267
  if (duplicate) {
266
268
  // If the existing session is still pending OAuth, treat connect as "resume auth"
267
269
  // instead of failing with duplicate connection error.
268
- if (duplicate.active === false) {
270
+ if (duplicate.status === 'pending') {
269
271
  await this.getSession({ sessionId: duplicate.sessionId });
270
272
  return {
271
273
  sessionId: duplicate.sessionId,
@@ -354,8 +356,8 @@ export class SSEConnectionManager {
354
356
  const client = this.clients.get(sessionId);
355
357
 
356
358
  if (client) {
359
+ // clearSession() handles DELETE + local cleanup internally.
357
360
  await client.clearSession();
358
- client.disconnect();
359
361
  this.clients.delete(sessionId);
360
362
  } else {
361
363
  // Handle orphaned sessions (e.g., OAuth flow failed before client was stored)
@@ -504,7 +506,10 @@ export class SSEConnectionManager {
504
506
  * Complete OAuth authorization flow
505
507
  */
506
508
  private async finishAuth(params: FinishAuthParams): Promise<FinishAuthResult> {
507
- const { sessionId, code } = params;
509
+ const { code } = params;
510
+ const oauthState = params.state;
511
+ const parsedState = parseOAuthState(oauthState);
512
+ const sessionId = parsedState?.sessionId || oauthState;
508
513
 
509
514
  const session = await sessions.get(this.userId, sessionId);
510
515
  if (!session) {
@@ -533,7 +538,7 @@ export class SSEConnectionManager {
533
538
 
534
539
  client.onConnectionEvent((event) => this.emitConnectionEvent(event));
535
540
 
536
- await client.finishAuth(code);
541
+ await client.finishAuth(code, oauthState);
537
542
  this.clients.set(sessionId, client);
538
543
 
539
544
  const tools = await client.listTools();
@@ -601,16 +606,18 @@ export class SSEConnectionManager {
601
606
  /**
602
607
  * Cleanup and close all connections
603
608
  */
604
- dispose(): void {
609
+ async dispose(): Promise<void> {
605
610
  this.isActive = false;
606
611
 
607
612
  if (this.heartbeatTimer) {
608
613
  clearInterval(this.heartbeatTimer);
609
614
  }
610
615
 
611
- for (const client of this.clients.values()) {
612
- client.disconnect();
613
- }
616
+ // Send HTTP DELETE to each Streamable HTTP server before closing, per spec.
617
+ // Run in parallel so shutdown is not serialised across many sessions.
618
+ await Promise.all(
619
+ Array.from(this.clients.values()).map((client) => client.disconnect())
620
+ );
614
621
 
615
622
  this.clients.clear();
616
623
  }
@@ -66,12 +66,8 @@ export class MultiSessionClient {
66
66
  *
67
67
  * A session is considered connectable when:
68
68
  * - It has a `serverId`, `serverUrl`, and `callbackUrl` (i.e. it was fully initialized)
69
- * - Its `active` flag is not explicitly `false` sessions with `active: false` are
70
- * either mid-OAuth flow, auth-pending, or previously failed. We skip those here
69
+ * - Its status is `active`. Pending sessions are skipped here
71
70
  * and let the OAuth flow complete separately before we try to reconnect them.
72
- *
73
- * Note: Sessions where `active` is `undefined` (legacy records) are included
74
- * for backwards compatibility.
75
71
  */
76
72
  private async getActiveSessions(): Promise<Session[]> {
77
73
  const sessionList = await sessions.list(this.userId);
@@ -79,7 +75,7 @@ export class MultiSessionClient {
79
75
  s.serverId &&
80
76
  s.serverUrl &&
81
77
  s.callbackUrl &&
82
- s.active !== false // exclude OAuth-pending / failed sessions
78
+ s.status === 'active'
83
79
  );
84
80
  return valid;
85
81
  }
@@ -104,6 +100,9 @@ export class MultiSessionClient {
104
100
  * Connects a single session, with built-in deduplication to prevent race conditions.
105
101
  *
106
102
  * - If a client for this session already exists and is connected, returns immediately.
103
+ * - If the existing client entry is no longer connected (e.g. it was explicitly
104
+ * disconnected), it is evicted so that `establishConnectionWithRetries` creates a
105
+ * fresh transport — preventing "Client already connected" errors from the SDK.
107
106
  * - If a connection attempt for this session is already in-flight (e.g. from a
108
107
  * concurrent call), it joins the existing promise instead of starting a new one.
109
108
  * This is the key concurrency lock — the `connectionPromises` map acts as a
@@ -111,9 +110,19 @@ export class MultiSessionClient {
111
110
  * - On completion (success or failure), the promise is cleaned up from the map.
112
111
  */
113
112
  private async connectSession(session: Session): Promise<void> {
114
- const existingClient = this.clients.find(c => c.getSessionId() === session.sessionId);
115
- if (existingClient?.isConnected()) {
116
- return;
113
+ const existing = this.clients.find(c => c.getSessionId() === session.sessionId);
114
+
115
+ if (existing) {
116
+ if (existing.isConnected()) {
117
+ // Genuinely connected — nothing to do.
118
+ return;
119
+ }
120
+
121
+ // Client entry exists but is no longer connected (explicit disconnect or
122
+ // a prior failed reconnect attempt). Remove it so the fresh connect below
123
+ // starts with a clean slate and the underlying SDK Client doesn't complain
124
+ // about an already-attached transport.
125
+ this.clients = this.clients.filter(c => c !== existing);
117
126
  }
118
127
 
119
128
  // Avoid concurrent connection attempts for the same session
@@ -201,9 +210,33 @@ export class MultiSessionClient {
201
210
  */
202
211
  async connect(): Promise<void> {
203
212
  const sessions = await this.getActiveSessions();
213
+ const activeSessionIds = new Set(sessions.map(s => s.sessionId));
214
+
215
+ // Prune stale clients whose sessions no longer exist in storage.
216
+ // Otherwise a lingering MCPClient with a still-alive SDK Client
217
+ // transport triggers OAuth refreshes on listTools(), and the
218
+ // StorageOAuthClientProvider.state() -> patchCredentials() call
219
+ // fails with a FK violation because the session was deleted.
220
+ this.clients = this.clients.filter(c => activeSessionIds.has(c.getSessionId()));
221
+
204
222
  await this.connectInBatches(sessions);
205
223
  }
206
224
 
225
+ /**
226
+ * Drops all cached `MCPClient` instances and reconnects fresh from storage.
227
+ *
228
+ * Call this when downstream MCP servers have expired their transport sessions
229
+ * (e.g. after a remote server restart) and subsequent tool calls return
230
+ * "Session not found. Reconnect without session header." errors.
231
+ *
232
+ * OAuth tokens are preserved in the storage backend — no re-authentication
233
+ * is required. Only the in-memory transport sessions are cleared.
234
+ */
235
+ async reconnect(): Promise<void> {
236
+ await this.disconnect();
237
+ await this.connect();
238
+ }
239
+
207
240
  /**
208
241
  * Returns all currently connected `MCPClient` instances.
209
242
  *
@@ -217,11 +250,15 @@ export class MultiSessionClient {
217
250
  /**
218
251
  * Gracefully disconnects all active MCP clients and clears the internal client list.
219
252
  *
253
+ * For Streamable HTTP sessions, each client sends an HTTP DELETE to its MCP
254
+ * endpoint per the spec before closing locally. All disconnects run in
255
+ * parallel so shutdown is not serialised across many sessions.
256
+ *
220
257
  * Call this during server shutdown or when a user logs out to free up
221
258
  * underlying transport resources (SSE streams, HTTP connections, etc.).
222
259
  */
223
- disconnect(): void {
224
- this.clients.forEach((client) => client.disconnect());
260
+ async disconnect(): Promise<void> {
261
+ await Promise.all(this.clients.map((client) => client.disconnect()));
225
262
  this.clients = [];
226
263
  }
227
264
  }