@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
@@ -26,15 +26,13 @@ import {
26
26
  ReadResourceResultSchema,
27
27
  } from '@modelcontextprotocol/sdk/types.js';
28
28
  import { StorageOAuthClientProvider, type AgentsOAuthProvider } from './storage-oauth-provider.js';
29
- import { sanitizeServerLabel } from '../../shared/utils.js';
30
29
  import { Emitter, type McpConnectionEvent, type McpObservabilityEvent, type McpConnectionState } from '../../shared/events.js';
31
30
  import { UnauthorizedError } from '../../shared/errors.js';
32
31
  import { sessions } from '../storage/index.js';
32
+ import type { Session, SessionStatus } from '../storage/types.js';
33
33
  import {
34
34
  MCP_CLIENT_NAME,
35
35
  MCP_CLIENT_VERSION,
36
- SESSION_TTL_SECONDS,
37
- STATE_EXPIRATION_MS,
38
36
  } from '../../shared/constants.js';
39
37
 
40
38
  /**
@@ -244,7 +242,7 @@ export class MCPClient {
244
242
  * Observation: SDK 1.24.0+ connections may hang indefinitely in some environments.
245
243
  * This wrapper enforces a timeout and properly uses AbortController to unblock the request.
246
244
  */
247
- fetch: (url: RequestInfo | URL, init?: RequestInit) => {
245
+ fetch: async (url: RequestInfo | URL, init?: RequestInit) => {
248
246
  const timeout = 30000;
249
247
  const controller = new AbortController();
250
248
  const timeoutId = setTimeout(() => controller.abort(), timeout);
@@ -253,7 +251,20 @@ export class MCPClient {
253
251
  (AbortSignal.any ? AbortSignal.any([init.signal, controller.signal]) : controller.signal) :
254
252
  controller.signal;
255
253
 
256
- return fetch(url, { ...init, signal }).finally(() => clearTimeout(timeoutId));
254
+ try {
255
+ const response = await fetch(url, { ...init, signal });
256
+
257
+ const hasSessionHeader = init?.headers && new Headers(init.headers as HeadersInit).has('mcp-session-id');
258
+
259
+ if (response.status === 404 && hasSessionHeader) {
260
+ this.client = null;
261
+ throw new Error("MCP_SESSION_EXPIRED: Downstream session was not found on the server.");
262
+ }
263
+
264
+ return response;
265
+ } finally {
266
+ clearTimeout(timeoutId);
267
+ }
257
268
  }
258
269
  };
259
270
 
@@ -278,23 +289,25 @@ export class MCPClient {
278
289
  this.emitStateChange('INITIALIZING');
279
290
  this.emitProgress('Loading session configuration...');
280
291
 
292
+ let existingSession: Session | null = null;
293
+
281
294
  if (!this.serverUrl || !this.callbackUrl || !this.serverId) {
282
- const sessionData = await sessions.get(this.userId, this.sessionId);
283
- if (!sessionData) {
295
+ existingSession = await sessions.get(this.userId, this.sessionId);
296
+ if (!existingSession) {
284
297
  throw new Error(`Session not found: ${this.sessionId}`);
285
298
  }
286
299
 
287
- this.serverUrl = this.serverUrl || sessionData.serverUrl;
288
- this.callbackUrl = this.callbackUrl || sessionData.callbackUrl;
300
+ this.serverUrl = this.serverUrl || existingSession.serverUrl;
301
+ this.callbackUrl = this.callbackUrl || existingSession.callbackUrl;
289
302
  /**
290
303
  * Do NOT load transportType from session if not explicitly provided.
291
304
  * We want to re-negotiate (try streamable -> sse) on new connections if in "Auto" mode.
292
305
  * this.transportType = this.transportType || sessionData.transportType;
293
306
  */
294
- this.serverName = this.serverName || sessionData.serverName;
295
- this.serverId = this.serverId || sessionData.serverId || 'unknown';
296
- this.headers = this.headers || sessionData.headers;
297
- this.createdAt = sessionData.createdAt;
307
+ this.serverName = this.serverName || existingSession.serverName;
308
+ this.serverId = this.serverId || existingSession.serverId || 'unknown';
309
+ this.headers = this.headers || existingSession.headers;
310
+ this.createdAt = existingSession.createdAt;
298
311
  }
299
312
 
300
313
  if (!this.serverUrl || !this.callbackUrl || !this.serverId) {
@@ -345,10 +358,13 @@ export class MCPClient {
345
358
  // Create session in the session store if it doesn't exist yet
346
359
  // This is needed BEFORE OAuth flow starts because the OAuth provider
347
360
  // will call saveCodeVerifier() which requires the session to exist
348
- const existingSession = await sessions.get(this.userId, this.sessionId);
361
+ if (existingSession === null) {
362
+ existingSession = await sessions.get(this.userId, this.sessionId);
363
+ }
349
364
  if (!existingSession && this.serverId && this.serverUrl && this.callbackUrl) {
350
365
  this.createdAt = Date.now();
351
- console.log(`[MCPClient] Creating initial session ${this.sessionId} for OAuth flow`);
366
+ const updatedAt = this.createdAt;
367
+ console.log(`[MCPClient] Creating pending session ${this.sessionId} for connection setup`);
352
368
  await sessions.create({
353
369
  sessionId: this.sessionId,
354
370
  userId: this.userId,
@@ -359,21 +375,21 @@ export class MCPClient {
359
375
  transportType: this.transportType || 'streamable-http',
360
376
  headers: this.headers,
361
377
  createdAt: this.createdAt,
362
- active: false,
363
- }, Math.floor(STATE_EXPIRATION_MS / 1000)); // Short TTL until connection succeeds
378
+ updatedAt,
379
+ status: 'pending',
380
+ });
364
381
  }
365
382
  }
366
383
 
367
384
  /**
368
385
  * Saves current session state to the session store
369
386
  * Creates new session if it doesn't exist, updates if it does
370
- * @param ttl - Time-to-live in seconds (defaults to 12hr for connected sessions)
371
- * @param active - Session status marker used to avoid unnecessary TTL rewrites
387
+ * @param status - Session lifecycle status used by storage cleanup
372
388
  * @private
373
389
  */
374
390
  private async saveSession(
375
- ttl: number = SESSION_TTL_SECONDS,
376
- active: boolean = true
391
+ status: SessionStatus = 'active',
392
+ existingSession?: Session | null
377
393
  ): Promise<void> {
378
394
  if (!this.sessionId || !this.serverId || !this.serverUrl || !this.callbackUrl) {
379
395
  return;
@@ -389,15 +405,33 @@ export class MCPClient {
389
405
  transportType: (this.transportType || 'streamable-http') as TransportType,
390
406
  headers: this.headers,
391
407
  createdAt: this.createdAt || Date.now(),
392
- active,
408
+ updatedAt: Date.now(),
409
+ status,
393
410
  };
411
+ if (status === 'active') {
412
+ (sessionData as typeof sessionData & { authUrl: null }).authUrl = null;
413
+ }
394
414
 
395
415
  // Try to update first, create if doesn't exist
396
- const existingSession = await sessions.get(this.userId, this.sessionId);
416
+ if (existingSession === undefined) {
417
+ existingSession = await sessions.get(this.userId, this.sessionId);
418
+ }
397
419
  if (existingSession) {
398
- await sessions.update(this.userId, this.sessionId, sessionData, ttl);
420
+ await sessions.update(this.userId, this.sessionId, sessionData);
399
421
  } else {
400
- await sessions.create(sessionData, ttl);
422
+ await sessions.create(sessionData);
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Removes transient setup/auth sessions without masking the original error.
428
+ * @private
429
+ */
430
+ private async deleteTransientSession(): Promise<void> {
431
+ try {
432
+ await sessions.delete(this.userId, this.sessionId);
433
+ } catch {
434
+ // Best effort only: preserve the original connection/auth error.
401
435
  }
402
436
  }
403
437
 
@@ -476,6 +510,27 @@ export class MCPClient {
476
510
  * @throws {Error} When connection fails for other reasons
477
511
  */
478
512
  async connect(): Promise<void> {
513
+ // Re-entry guard: if a previous SDK Client is still attached to a transport
514
+ // (e.g. a stale session from a prior connect() call on the same MCPClient
515
+ // instance), close and detach it before proceeding. The MCP SDK client will
516
+ // throw if asked to connect while a transport is already attached, and the
517
+ // stale transport would send an expired mcp-session-id to the remote server
518
+ // causing "Session not found. Reconnect without session header." errors.
519
+ //
520
+ // We also null out this.client so that initialize() creates a fresh Client
521
+ // instance with a clean transport slot, rather than reusing the old one.
522
+ // The oauthProvider is intentionally preserved — OAuth tokens remain valid
523
+ // across reconnects; only the transport session needs to be renegotiated.
524
+ if (this.client?.transport) {
525
+ this.transport = null;
526
+ try {
527
+ await this.client.close();
528
+ } catch {
529
+ // Closing a transport that may have already failed is best-effort.
530
+ }
531
+ this.client = null;
532
+ }
533
+
479
534
  await this.initialize();
480
535
 
481
536
  if (!this.client || !this.oauthProvider) {
@@ -499,8 +554,8 @@ export class MCPClient {
499
554
 
500
555
  // Refresh session metadata on every successful connect so active sessions
501
556
  // record ongoing usage and don't look dormant to session cleanup jobs.
502
- console.log(`[MCPClient] Saving session ${this.sessionId} with 12hr TTL (connect success)`);
503
- await this.saveSession(SESSION_TTL_SECONDS, true);
557
+ console.log(`[MCPClient] Saving active session ${this.sessionId} (connect success)`);
558
+ await this.saveSession('active');
504
559
  } catch (error) {
505
560
  /** Handle Authentication Errors */
506
561
  if (
@@ -529,23 +584,16 @@ export class MCPClient {
529
584
  this.emitError(message, 'auth');
530
585
  this.emitStateChange('FAILED');
531
586
 
532
- // Proactive Cleanup: This session has reached a terminal failure state.
533
- // We remove it now to ensure the database remains lean, bypassing the
534
- // automated lifecycle sweep.
535
- try {
536
- await sessions.delete(this.userId, this.sessionId);
537
- } catch {
538
- // Non-blocking: Proactive cleanup failures are suppressed to prioritize
539
- // the original error context.
540
- }
587
+ // Remove terminal setup failures immediately. Active sessions are not
588
+ // deleted here because this branch only runs before OAuth is available.
589
+ await this.deleteTransientSession();
541
590
 
542
591
  throw new Error(message);
543
592
  }
544
593
 
545
594
  this.emitStateChange('AUTHENTICATING');
546
- // Save session with 10min TTL for OAuth pending state
547
- console.log(`[MCPClient] Saving session ${this.sessionId} with 10min TTL (OAuth pending)`);
548
- await this.saveSession(Math.floor(STATE_EXPIRATION_MS / 1000), false);
595
+ console.log(`[MCPClient] Saving pending OAuth session ${this.sessionId}`);
596
+ await this.saveSession('pending');
549
597
 
550
598
  if (this.serverId) {
551
599
  this._onConnectionEvent.fire({
@@ -569,16 +617,15 @@ export class MCPClient {
569
617
  this.emitError(errorMessage, 'connection');
570
618
  this.emitStateChange('FAILED');
571
619
 
572
- // Terminal Handshake Failure: only purge transient sessions. Active
573
- // sessions may still hold valid credentials for a later reconnect.
620
+ // Remove transient sessions that failed before becoming restorable.
621
+ // Existing active sessions may still hold usable credentials for reconnect.
574
622
  try {
575
623
  const existingSession = await sessions.get(this.userId, this.sessionId);
576
- if (!existingSession || existingSession.active !== true) {
624
+ if (!existingSession || existingSession.status !== 'active') {
577
625
  await sessions.delete(this.userId, this.sessionId);
578
626
  }
579
627
  } catch {
580
- // Non-blocking: Cleanup is performed on a best-effort basis and should
581
- // not interfere with the primary error propagation.
628
+ // Best effort only: preserve the original connection error.
582
629
  }
583
630
 
584
631
  throw error;
@@ -593,7 +640,7 @@ export class MCPClient {
593
640
  */
594
641
 
595
642
  // TODO: needs to be optimized
596
- async finishAuth(authCode: string): Promise<void> {
643
+ async finishAuth(authCode: string, state?: string): Promise<void> {
597
644
  this.emitStateChange('AUTHENTICATING');
598
645
  this.emitProgress('Exchanging authorization code for tokens...');
599
646
 
@@ -606,6 +653,18 @@ export class MCPClient {
606
653
  throw new Error(error);
607
654
  }
608
655
 
656
+ if (state) {
657
+ const stateCheck = await this.oauthProvider.checkState(state);
658
+ if (!stateCheck.valid) {
659
+ const error = stateCheck.error || 'Invalid OAuth state';
660
+ this.emitError(error, 'auth');
661
+ this.emitStateChange('FAILED');
662
+ throw new Error(error);
663
+ }
664
+
665
+ await this.oauthProvider.consumeState(state);
666
+ }
667
+
609
668
  /**
610
669
  * Determine which transports to try for finishing auth
611
670
  * If transportType is set, use only that. Otherwise try streamable_http then sse.
@@ -666,9 +725,8 @@ export class MCPClient {
666
725
  this.transportType = currentType;
667
726
 
668
727
  this.emitStateChange('CONNECTED');
669
- // Update session with 12hr TTL after successful OAuth
670
- console.log(`[MCPClient] Updating session ${this.sessionId} to 12hr TTL (OAuth complete)`);
671
- await this.saveSession(SESSION_TTL_SECONDS, true);
728
+ console.log(`[MCPClient] Saving active session ${this.sessionId} (OAuth complete)`);
729
+ await this.saveSession('active');
672
730
 
673
731
  return; // Success, exit function
674
732
 
@@ -689,6 +747,7 @@ export class MCPClient {
689
747
  const msg = error instanceof Error ? error.message : 'Authentication failed';
690
748
  this.emitError(msg, 'auth');
691
749
  this.emitStateChange('FAILED');
750
+ await this.deleteTransientSession();
692
751
  throw error;
693
752
  }
694
753
 
@@ -696,6 +755,7 @@ export class MCPClient {
696
755
  const msg = error instanceof Error ? error.message : 'Authentication failed';
697
756
  this.emitError(msg, 'auth');
698
757
  this.emitStateChange('FAILED');
758
+ await this.deleteTransientSession();
699
759
  throw error;
700
760
  }
701
761
 
@@ -708,6 +768,7 @@ export class MCPClient {
708
768
  const errorMessage = lastError instanceof Error ? lastError.message : 'Authentication failed';
709
769
  this.emitError(errorMessage, 'auth');
710
770
  this.emitStateChange('FAILED');
771
+ await this.deleteTransientSession();
711
772
  throw lastError;
712
773
  }
713
774
  }
@@ -970,7 +1031,7 @@ export class MCPClient {
970
1031
  }
971
1032
 
972
1033
  await sessions.delete(this.userId, this.sessionId);
973
- this.disconnect();
1034
+ await this.disconnect();
974
1035
  }
975
1036
 
976
1037
  /**
@@ -982,10 +1043,29 @@ export class MCPClient {
982
1043
  }
983
1044
 
984
1045
  /**
985
- * Disconnects from the MCP server and cleans up resources
986
- * Does not remove session from Redis - use clearSession() for that
1046
+ * Disconnects from the MCP server and cleans up resources.
1047
+ * Does not remove session from Redis use clearSession() for that.
1048
+ *
1049
+ * For Streamable HTTP sessions, sends an HTTP DELETE to the MCP endpoint
1050
+ * before closing, as recommended by the MCP Streamable HTTP spec
1051
+ * (section "Session Management", rule 5). This is best-effort — errors
1052
+ * (e.g. server already restarted, 404/405 responses) are silently ignored.
987
1053
  */
988
- disconnect(reason?: string): void {
1054
+ async disconnect(): Promise<void> {
1055
+ // Per the MCP Streamable HTTP spec (2025-11-25), clients SHOULD send an
1056
+ // HTTP DELETE with the mcp-session-id header when they no longer need a
1057
+ // session. The server MAY respond with 405 if it doesn't support explicit
1058
+ // termination — terminateSession() handles that gracefully.
1059
+ // SSEClientTransport has no session concept, so we guard with instanceof.
1060
+ if (this.transport instanceof StreamableHTTPClientTransport) {
1061
+ try {
1062
+ await this.transport.terminateSession();
1063
+ } catch {
1064
+ // Best-effort: server may be unreachable or may have already expired
1065
+ // the session. Either way, we proceed with local cleanup.
1066
+ }
1067
+ }
1068
+
989
1069
  if (this.client) {
990
1070
  this.client.close();
991
1071
  }
@@ -999,7 +1079,6 @@ export class MCPClient {
999
1079
  type: 'disconnected',
1000
1080
  sessionId: this.sessionId,
1001
1081
  serverId: this.serverId,
1002
- reason,
1003
1082
  timestamp: Date.now(),
1004
1083
  });
1005
1084
 
@@ -1009,9 +1088,7 @@ export class MCPClient {
1009
1088
  message: `Disconnected from ${this.serverId}`,
1010
1089
  sessionId: this.sessionId,
1011
1090
  serverId: this.serverId,
1012
- payload: {
1013
- reason: reason || 'unknown',
1014
- },
1091
+ payload: {},
1015
1092
  timestamp: Date.now(),
1016
1093
  id: nanoid(),
1017
1094
  });
@@ -1080,59 +1157,4 @@ export class MCPClient {
1080
1157
  return this.sessionId;
1081
1158
  }
1082
1159
 
1083
- /**
1084
- * Gets MCP server configuration for all active user sessions
1085
- * Loads sessions from storage and returns server connection metadata.
1086
- * OAuth refresh is handled by SDK transports through their authProvider.
1087
- * @deprecated This returns legacy connection metadata only and does not
1088
- * include OAuth tokens or generated Authorization headers. Prefer
1089
- * MultiSessionClient or explicit MCPClient instances so SDK transports can
1090
- * own OAuth refresh and reauthorization.
1091
- * @param userId - User ID to fetch sessions for
1092
- * @returns Object keyed by sanitized server labels containing transport and url.
1093
- * @static
1094
- */
1095
- static async getMcpServerConfig(userId: string): Promise<Record<string, any>> {
1096
- const mcpConfig: Record<string, any> = {};
1097
- const sessionList = await sessions.list(userId);
1098
-
1099
- await Promise.all(
1100
- sessionList.map(async (sessionData) => {
1101
- const { sessionId } = sessionData;
1102
-
1103
- try {
1104
- // Validate session - remove if missing required fields
1105
- if (
1106
- !sessionData.serverId ||
1107
- !sessionData.transportType ||
1108
- !sessionData.serverUrl ||
1109
- !sessionData.callbackUrl
1110
- ) {
1111
- await sessions.delete(userId, sessionId);
1112
- return;
1113
- }
1114
-
1115
- // Build server config
1116
- const label = sanitizeServerLabel(
1117
- sessionData.serverName || sessionData.serverId || 'server'
1118
- );
1119
-
1120
- mcpConfig[label] = {
1121
- transport: sessionData.transportType,
1122
- url: sessionData.serverUrl,
1123
- ...(sessionData.serverName && {
1124
- serverName: sessionData.serverName,
1125
- serverLabel: label,
1126
- }),
1127
- };
1128
- } catch (error) {
1129
- await sessions.delete(userId, sessionId);
1130
- console.warn(`[MCP] Failed to process session ${sessionId}:`, error);
1131
- }
1132
- })
1133
- );
1134
-
1135
- return mcpConfig;
1136
- }
1137
-
1138
1160
  }
@@ -5,7 +5,8 @@ import type {
5
5
  OAuthClientMetadata,
6
6
  OAuthTokens
7
7
  } from "@modelcontextprotocol/sdk/shared/auth.js";
8
- import { sessions, type Session } from "../storage/index.js";
8
+ import { nanoid } from "nanoid";
9
+ import { sessions, type SessionCredentials } from "../storage/index.js";
9
10
  import {
10
11
  DEFAULT_CLIENT_NAME,
11
12
  DEFAULT_CLIENT_URI,
@@ -13,8 +14,9 @@ import {
13
14
  DEFAULT_POLICY_URI,
14
15
  SOFTWARE_ID,
15
16
  SOFTWARE_VERSION,
16
- TOKEN_EXPIRY_BUFFER_MS,
17
+ STATE_EXPIRATION_MS,
17
18
  } from '../../shared/constants.js';
19
+ import { formatOAuthState, parseOAuthState } from '../../shared/utils.js';
18
20
 
19
21
  /**
20
22
  * Extension of OAuthClientProvider interface with additional methods
@@ -29,8 +31,6 @@ export interface AgentsOAuthProvider extends OAuthClientProvider {
29
31
  ): Promise<{ valid: boolean; serverId?: string; error?: string }>;
30
32
  consumeState(state: string): Promise<void>;
31
33
  deleteCodeVerifier(): Promise<void>;
32
- isTokenExpired(): boolean;
33
- setTokenExpiresAt(expiresAt: number): void;
34
34
  }
35
35
 
36
36
  export interface StorageOAuthClientProviderOptions {
@@ -66,7 +66,6 @@ export class StorageOAuthClientProvider implements AgentsOAuthProvider {
66
66
  private _authUrl: string | undefined;
67
67
  private _clientId: string | undefined;
68
68
  private onRedirectCallback?: (url: string) => void;
69
- private tokenExpiresAt?: number;
70
69
 
71
70
  /**
72
71
  * Creates a new session-backed OAuth provider
@@ -110,32 +109,32 @@ export class StorageOAuthClientProvider implements AgentsOAuthProvider {
110
109
  }
111
110
 
112
111
  /**
113
- * Loads OAuth data from the session store
112
+ * Loads OAuth credentials from the session store
114
113
  * @private
115
114
  */
116
- private async getSessionData(): Promise<Session> {
117
- const data = await sessions.get(this.userId, this.sessionId);
115
+ private async getCredentials(): Promise<SessionCredentials> {
116
+ const data = await sessions.getCredentials(this.userId, this.sessionId);
118
117
  if (!data) {
119
- return {} as Session;
118
+ return { userId: this.userId, sessionId: this.sessionId };
120
119
  }
121
120
  return data;
122
121
  }
123
122
 
124
123
  /**
125
- * Saves OAuth data to the session store
126
- * @param data - Partial OAuth data to save
124
+ * Saves OAuth credentials to the session store
125
+ * @param data - Partial OAuth credentials to save
127
126
  * @private
128
127
  * @throws Error if session doesn't exist (session must be created by controller layer)
129
128
  */
130
- private async saveSessionData(data: Partial<Session>): Promise<void> {
131
- await sessions.update(this.userId, this.sessionId, data);
129
+ private async patchCredentials(data: Partial<SessionCredentials>): Promise<void> {
130
+ await sessions.patchCredentials(this.userId, this.sessionId, data);
132
131
  }
133
132
 
134
133
  /**
135
134
  * Retrieves stored OAuth client information
136
135
  */
137
136
  async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
138
- const data = await this.getSessionData();
137
+ const data = await this.getCredentials();
139
138
 
140
139
  if (data.clientId && !this._clientId) {
141
140
  this._clientId = data.clientId;
@@ -159,7 +158,7 @@ export class StorageOAuthClientProvider implements AgentsOAuthProvider {
159
158
  * Stores OAuth client information
160
159
  */
161
160
  async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> {
162
- await this.saveSessionData({
161
+ await this.patchCredentials({
163
162
  clientInformation,
164
163
  clientId: clientInformation.client_id
165
164
  });
@@ -170,13 +169,7 @@ export class StorageOAuthClientProvider implements AgentsOAuthProvider {
170
169
  * Stores OAuth tokens
171
170
  */
172
171
  async saveTokens(tokens: OAuthTokens): Promise<void> {
173
- const data: Partial<Session> = { tokens };
174
-
175
- if (tokens.expires_in) {
176
- this.tokenExpiresAt = Date.now() + (tokens.expires_in * 1000) - TOKEN_EXPIRY_BUFFER_MS;
177
- }
178
-
179
- await this.saveSessionData(data);
172
+ await this.patchCredentials({ tokens });
180
173
  }
181
174
 
182
175
  get authUrl() {
@@ -184,25 +177,67 @@ export class StorageOAuthClientProvider implements AgentsOAuthProvider {
184
177
  }
185
178
 
186
179
  async state(): Promise<string> {
187
- return this.sessionId;
180
+ const nonce = nanoid(32);
181
+ await this.patchCredentials({
182
+ oauthState: {
183
+ nonce,
184
+ sessionId: this.sessionId,
185
+ serverId: this.serverId,
186
+ createdAt: Date.now(),
187
+ },
188
+ codeVerifier: null,
189
+ });
190
+ return formatOAuthState(nonce, this.sessionId);
188
191
  }
189
192
 
190
- async checkState(_state: string): Promise<{ valid: boolean; serverId?: string; error?: string }> {
191
- const data = await sessions.get(this.userId, this.sessionId);
193
+ async checkState(state: string): Promise<{ valid: boolean; serverId?: string; error?: string }> {
194
+ const parsed = parseOAuthState(state);
195
+ if (!parsed) {
196
+ return { valid: false, error: "Invalid OAuth state" };
197
+ }
198
+
199
+ if (parsed.sessionId !== this.sessionId) {
200
+ return { valid: false, error: "OAuth state mismatch" };
201
+ }
202
+
203
+ const data = await sessions.getCredentials(this.userId, parsed.sessionId);
192
204
 
193
205
  if (!data) {
194
206
  return { valid: false, error: "Session not found" };
195
207
  }
196
208
 
197
- return { valid: true, serverId: this.serverId };
209
+ const oauthState = data.oauthState;
210
+ if (!oauthState) {
211
+ return { valid: false, error: "OAuth state not found" };
212
+ }
213
+
214
+ if (
215
+ oauthState.nonce !== parsed.nonce ||
216
+ oauthState.sessionId !== parsed.sessionId ||
217
+ oauthState.serverId !== this.serverId
218
+ ) {
219
+ return { valid: false, error: "OAuth state mismatch" };
220
+ }
221
+
222
+ if (Date.now() - oauthState.createdAt > STATE_EXPIRATION_MS) {
223
+ return { valid: false, error: "OAuth state expired" };
224
+ }
225
+
226
+ return { valid: true, serverId: oauthState.serverId };
198
227
  }
199
228
 
200
- async consumeState(_state: string): Promise<void> {
201
- // No-op
229
+ async consumeState(state: string): Promise<void> {
230
+ const result = await this.checkState(state);
231
+ if (!result.valid) {
232
+ throw new Error(result.error || "Invalid OAuth state");
233
+ }
234
+
235
+ await this.patchCredentials({ oauthState: null });
202
236
  }
203
237
 
204
238
  async redirectToAuthorization(authUrl: URL): Promise<void> {
205
239
  this._authUrl = authUrl.toString();
240
+ await sessions.update(this.userId, this.sessionId, { authUrl: this._authUrl });
206
241
  if (this.onRedirectCallback) {
207
242
  this.onRedirectCallback(authUrl.toString());
208
243
  }
@@ -214,26 +249,31 @@ export class StorageOAuthClientProvider implements AgentsOAuthProvider {
214
249
  if (scope === "all") {
215
250
  await sessions.delete(this.userId, this.sessionId);
216
251
  } else {
217
- const updates: Partial<Session> = {};
252
+ const updates: Partial<SessionCredentials> = {};
218
253
 
219
254
  if (scope === "client") {
220
- updates.clientInformation = undefined;
221
- updates.clientId = undefined;
255
+ updates.clientInformation = null;
256
+ updates.clientId = null;
222
257
  } else if (scope === "tokens") {
223
- updates.tokens = undefined;
258
+ updates.tokens = null;
224
259
  } else if (scope === "verifier") {
225
- updates.codeVerifier = undefined;
260
+ updates.codeVerifier = null;
226
261
  }
227
- await this.saveSessionData(updates);
262
+ await this.patchCredentials(updates);
228
263
  }
229
264
  }
230
265
 
231
266
  async saveCodeVerifier(verifier: string): Promise<void> {
232
- await this.saveSessionData({ codeVerifier: verifier });
267
+ const data = await this.getCredentials();
268
+ if (data.codeVerifier) {
269
+ return;
270
+ }
271
+
272
+ await this.patchCredentials({ codeVerifier: verifier });
233
273
  }
234
274
 
235
275
  async codeVerifier(): Promise<string> {
236
- const data = await this.getSessionData();
276
+ const data = await this.getCredentials();
237
277
 
238
278
  if (data.clientId && !this._clientId) {
239
279
  this._clientId = data.clientId;
@@ -246,27 +286,16 @@ export class StorageOAuthClientProvider implements AgentsOAuthProvider {
246
286
  }
247
287
 
248
288
  async deleteCodeVerifier(): Promise<void> {
249
- await this.saveSessionData({ codeVerifier: undefined });
289
+ await this.patchCredentials({ codeVerifier: null });
250
290
  }
251
291
 
252
292
  async tokens(): Promise<OAuthTokens | undefined> {
253
- const data = await this.getSessionData();
293
+ const data = await this.getCredentials();
254
294
 
255
295
  if (data.clientId && !this._clientId) {
256
296
  this._clientId = data.clientId;
257
297
  }
258
298
 
259
- return data.tokens;
260
- }
261
-
262
- isTokenExpired(): boolean {
263
- if (!this.tokenExpiresAt) {
264
- return false;
265
- }
266
- return Date.now() >= this.tokenExpiresAt;
267
- }
268
-
269
- setTokenExpiresAt(expiresAt: number): void {
270
- this.tokenExpiresAt = expiresAt;
299
+ return data.tokens ?? undefined;
271
300
  }
272
301
  }