@mcp-ts/sdk 2.3.4 → 2.4.0

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 (82) hide show
  1. package/README.md +113 -30
  2. package/dist/adapters/agui-adapter.d.mts +3 -3
  3. package/dist/adapters/agui-adapter.d.ts +3 -3
  4. package/dist/adapters/agui-middleware.d.mts +3 -3
  5. package/dist/adapters/agui-middleware.d.ts +3 -3
  6. package/dist/adapters/ai-adapter.d.mts +3 -3
  7. package/dist/adapters/ai-adapter.d.ts +3 -3
  8. package/dist/adapters/langchain-adapter.d.mts +3 -3
  9. package/dist/adapters/langchain-adapter.d.ts +3 -3
  10. package/dist/adapters/mastra-adapter.d.mts +1 -1
  11. package/dist/adapters/mastra-adapter.d.ts +1 -1
  12. package/dist/client/index.d.mts +2 -2
  13. package/dist/client/index.d.ts +2 -2
  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 +9 -8
  19. package/dist/client/react.d.ts +9 -8
  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 +6 -5
  25. package/dist/client/vue.d.ts +6 -5
  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/{index-Cfjsme-a.d.mts → index-ByIjEReo.d.mts} +2 -2
  31. package/dist/{index-CmjMd2ac.d.ts → index-CtXvKl8N.d.ts} +2 -2
  32. package/dist/index.d.mts +5 -5
  33. package/dist/index.d.ts +5 -5
  34. package/dist/index.js +728 -306
  35. package/dist/index.js.map +1 -1
  36. package/dist/index.mjs +722 -304
  37. package/dist/index.mjs.map +1 -1
  38. package/dist/{multi-session-client-C7hGqzgM.d.mts → multi-session-client-CIMUGF8S.d.mts} +15 -33
  39. package/dist/{multi-session-client-C_kPHpD5.d.ts → multi-session-client-CnvZEGPY.d.ts} +15 -33
  40. package/dist/server/index.d.mts +45 -17
  41. package/dist/server/index.d.ts +45 -17
  42. package/dist/server/index.js +720 -300
  43. package/dist/server/index.js.map +1 -1
  44. package/dist/server/index.mjs +716 -299
  45. package/dist/server/index.mjs.map +1 -1
  46. package/dist/shared/index.d.mts +9 -8
  47. package/dist/shared/index.d.ts +9 -8
  48. package/dist/shared/index.js +7 -5
  49. package/dist/shared/index.js.map +1 -1
  50. package/dist/shared/index.mjs +5 -4
  51. package/dist/shared/index.mjs.map +1 -1
  52. package/dist/{tool-router-BMzhoNYt.d.ts → tool-router-CZMrOG8J.d.ts} +1 -1
  53. package/dist/{tool-router-BhHsvBfP.d.mts → tool-router-CuApsDiV.d.mts} +1 -1
  54. package/dist/{types-CxFaaZrM.d.mts → types-DCk_IF4L.d.mts} +5 -3
  55. package/dist/{types-CxFaaZrM.d.ts → types-DCk_IF4L.d.ts} +5 -3
  56. package/migrations/neon/20260513010000_install_mcp_sessions.sql +40 -3
  57. package/migrations/neon/20260513020000_add_session_cleanup_cron.sql +4 -4
  58. package/migrations/supabase/20260330195700_install_mcp_sessions.sql +67 -3
  59. package/migrations/supabase/20260421010000_add_session_cleanup_cron.sql +6 -6
  60. package/package.json +13 -3
  61. package/src/client/core/sse-client.ts +2 -2
  62. package/src/client/react/index.ts +1 -1
  63. package/src/client/react/oauth-popup.tsx +34 -13
  64. package/src/client/react/use-mcp.ts +19 -45
  65. package/src/client/utils/session-state.ts +43 -0
  66. package/src/client/vue/use-mcp.ts +18 -47
  67. package/src/server/handlers/sse-handler.ts +9 -4
  68. package/src/server/mcp/multi-session-client.ts +2 -6
  69. package/src/server/mcp/oauth-client.ts +73 -101
  70. package/src/server/mcp/storage-oauth-provider.ts +79 -50
  71. package/src/server/storage/file-backend.ts +74 -18
  72. package/src/server/storage/index.ts +2 -4
  73. package/src/server/storage/memory-backend.ts +49 -13
  74. package/src/server/storage/neon-backend.ts +201 -68
  75. package/src/server/storage/redis-backend.ts +81 -23
  76. package/src/server/storage/session-lifecycle.ts +78 -0
  77. package/src/server/storage/sqlite-backend.ts +89 -15
  78. package/src/server/storage/supabase-backend.ts +188 -63
  79. package/src/server/storage/types.ts +49 -16
  80. package/src/shared/constants.ts +5 -6
  81. package/src/shared/types.ts +5 -3
  82. package/src/shared/utils.ts +26 -0
@@ -0,0 +1,78 @@
1
+ import {
2
+ DORMANT_SESSION_EXPIRATION_MS,
3
+ DORMANT_SESSION_EXPIRATION_SECONDS,
4
+ STATE_EXPIRATION_MS,
5
+ } from '../../shared/constants.js';
6
+ import type { Session, SessionStatus } from './types.js';
7
+
8
+ export function resolveSessionExpiresAt(status: SessionStatus = 'pending', referenceTime = Date.now()): number | null {
9
+ return status === 'active' ? null : referenceTime + STATE_EXPIRATION_MS;
10
+ }
11
+
12
+ export function resolveSessionRedisTtlSeconds(session: Pick<Session, 'status'>): number {
13
+ return session.status === 'active'
14
+ ? DORMANT_SESSION_EXPIRATION_SECONDS
15
+ : Math.floor(STATE_EXPIRATION_MS / 1000);
16
+ }
17
+
18
+ export function normalizeNewSession(session: Session, now = Date.now()): Session {
19
+ const createdAt = session.createdAt || now;
20
+ const updatedAt = session.updatedAt ?? createdAt;
21
+ const status = session.status ?? 'pending';
22
+
23
+ return {
24
+ ...session,
25
+ status,
26
+ createdAt,
27
+ updatedAt,
28
+ expiresAt: resolveSessionExpiresAt(status, status === 'active' ? updatedAt : createdAt),
29
+ };
30
+ }
31
+
32
+ export function mergeSessionUpdate(
33
+ current: Session,
34
+ data: Partial<Session>,
35
+ now = Date.now()
36
+ ): Session {
37
+ const updatedAt = data.updatedAt ?? now;
38
+ const updated = {
39
+ ...current,
40
+ ...data,
41
+ updatedAt,
42
+ };
43
+ const status = updated.status ?? 'pending';
44
+
45
+ return {
46
+ ...updated,
47
+ status,
48
+ expiresAt: resolveSessionExpiresAt(status, updatedAt),
49
+ };
50
+ }
51
+
52
+ export function normalizeStoredSession(session: Session): Session {
53
+ const createdAt = session.createdAt || Date.now();
54
+ const updatedAt = session.updatedAt ?? createdAt;
55
+ const status = session.status ?? 'pending';
56
+ const expiresAt = status === 'active'
57
+ ? null
58
+ : session.expiresAt ?? resolveSessionExpiresAt(status, createdAt);
59
+
60
+ return {
61
+ ...session,
62
+ status,
63
+ createdAt,
64
+ updatedAt,
65
+ expiresAt,
66
+ };
67
+ }
68
+
69
+ export function isSessionExpired(session: Session, now = Date.now()): boolean {
70
+ const hydrated = normalizeStoredSession(session);
71
+
72
+ if (hydrated.status === 'active') {
73
+ return hydrated.updatedAt !== undefined &&
74
+ hydrated.updatedAt < now - DORMANT_SESSION_EXPIRATION_MS;
75
+ }
76
+
77
+ return typeof hydrated.expiresAt === 'number' && hydrated.expiresAt < now;
78
+ }
@@ -1,8 +1,14 @@
1
1
  import type { Database } from 'better-sqlite3';
2
- import type { SessionStore, Session } from './types.js';
2
+ import type { SessionStore, Session, SessionCredentials } from './types.js';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
5
  import { generateSessionId } from '../../shared/utils.js';
6
+ import {
7
+ mergeSessionUpdate,
8
+ normalizeNewSession,
9
+ normalizeStoredSession,
10
+ isSessionExpired,
11
+ } from './session-lifecycle.js';
6
12
 
7
13
  export interface SqliteStorageOptions {
8
14
  path?: string;
@@ -12,12 +18,14 @@ export interface SqliteStorageOptions {
12
18
  export class SqliteStorage implements SessionStore {
13
19
  private db: Database | null = null;
14
20
  private table: string;
21
+ private credentialsTable: string;
15
22
  private initialized = false;
16
23
  private dbPath: string;
17
24
 
18
25
  constructor(options: SqliteStorageOptions = {}) {
19
26
  this.dbPath = options.path || './sessions.db';
20
27
  this.table = options.table || 'mcp_sessions';
28
+ this.credentialsTable = `${this.table}_credentials`;
21
29
  }
22
30
 
23
31
  async init(): Promise<void> {
@@ -34,6 +42,7 @@ export class SqliteStorage implements SessionStore {
34
42
  }
35
43
 
36
44
  this.db = new DatabaseConstructor(this.dbPath);
45
+ this.db.pragma('foreign_keys = ON');
37
46
  this.db.exec(`
38
47
  CREATE TABLE IF NOT EXISTS ${this.table} (
39
48
  sessionId TEXT PRIMARY KEY,
@@ -42,6 +51,13 @@ export class SqliteStorage implements SessionStore {
42
51
  expiresAt INTEGER
43
52
  );
44
53
  CREATE INDEX IF NOT EXISTS idx_${this.table}_userId ON ${this.table}(userId);
54
+ CREATE TABLE IF NOT EXISTS ${this.credentialsTable} (
55
+ sessionId TEXT NOT NULL,
56
+ userId TEXT NOT NULL,
57
+ data TEXT NOT NULL,
58
+ PRIMARY KEY (userId, sessionId),
59
+ FOREIGN KEY (sessionId) REFERENCES ${this.table}(sessionId) ON DELETE CASCADE
60
+ );
45
61
  `);
46
62
 
47
63
  this.initialized = true;
@@ -66,7 +82,7 @@ export class SqliteStorage implements SessionStore {
66
82
  return generateSessionId();
67
83
  }
68
84
 
69
- async create(session: Session, ttl?: number): Promise<void> {
85
+ async create(session: Session): Promise<void> {
70
86
  this.ensureInitialized();
71
87
  const { sessionId, userId } = session;
72
88
 
@@ -74,13 +90,13 @@ export class SqliteStorage implements SessionStore {
74
90
  throw new Error('userId and sessionId required');
75
91
  }
76
92
 
77
- const expiresAt = ttl ? Date.now() + ttl * 1000 : null;
93
+ const sessionWithLifecycle = normalizeNewSession(session);
78
94
 
79
95
  try {
80
96
  const stmt = this.db!.prepare(
81
97
  `INSERT INTO ${this.table} (sessionId, userId, data, expiresAt) VALUES (?, ?, ?, ?)`
82
98
  );
83
- stmt.run(sessionId, userId, JSON.stringify(session), expiresAt);
99
+ stmt.run(sessionId, userId, JSON.stringify(sessionWithLifecycle), sessionWithLifecycle.expiresAt ?? null);
84
100
  } catch (error: any) {
85
101
  if (error.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') {
86
102
  throw new Error(`Session ${sessionId} already exists`);
@@ -89,7 +105,7 @@ export class SqliteStorage implements SessionStore {
89
105
  }
90
106
  }
91
107
 
92
- async update(userId: string, sessionId: string, data: Partial<Session>, ttl?: number): Promise<void> {
108
+ async update(userId: string, sessionId: string, data: Partial<Session>): Promise<void> {
93
109
  this.ensureInitialized();
94
110
  if (!sessionId || !userId) {
95
111
  throw new Error('userId and sessionId required');
@@ -100,14 +116,30 @@ export class SqliteStorage implements SessionStore {
100
116
  throw new Error(`Session ${sessionId} not found for userId ${userId}`);
101
117
  }
102
118
 
103
- const updatedSession = { ...currentSession, ...data };
104
- const expiresAt = ttl ? Date.now() + ttl * 1000 : null;
119
+ const updatedSession = mergeSessionUpdate(currentSession, data);
105
120
 
106
121
  const stmt = this.db!.prepare(
107
122
  `UPDATE ${this.table} SET data = ?, expiresAt = ? WHERE sessionId = ? AND userId = ?`
108
123
  );
109
124
 
110
- stmt.run(JSON.stringify(updatedSession), expiresAt, sessionId, userId);
125
+ stmt.run(JSON.stringify(updatedSession), updatedSession.expiresAt ?? null, sessionId, userId);
126
+ }
127
+
128
+ async patchCredentials(userId: string, sessionId: string, data: Partial<SessionCredentials>): Promise<void> {
129
+ this.ensureInitialized();
130
+ if (!await this.get(userId, sessionId)) {
131
+ throw new Error(`Session ${sessionId} not found for userId ${userId}`);
132
+ }
133
+
134
+ const current = await this.getCredentials(userId, sessionId) ?? { sessionId, userId };
135
+ const credentials = { ...current, ...data, sessionId, userId };
136
+ const stmt = this.db!.prepare(
137
+ `INSERT INTO ${this.credentialsTable} (sessionId, userId, data)
138
+ VALUES (?, ?, ?)
139
+ ON CONFLICT(userId, sessionId) DO UPDATE SET data = excluded.data`
140
+ );
141
+ stmt.run(sessionId, userId, JSON.stringify(credentials));
142
+
111
143
  }
112
144
 
113
145
  async get(userId: string, sessionId: string): Promise<Session | null> {
@@ -119,7 +151,28 @@ export class SqliteStorage implements SessionStore {
119
151
  const row = stmt.get(sessionId, userId) as { data: string } | undefined;
120
152
 
121
153
  if (!row) return null;
122
- return JSON.parse(row.data) as Session;
154
+ return normalizeStoredSession(JSON.parse(row.data) as Session);
155
+ }
156
+
157
+ async getCredentials(userId: string, sessionId: string): Promise<SessionCredentials | null> {
158
+ this.ensureInitialized();
159
+ if (!await this.get(userId, sessionId)) return null;
160
+
161
+ const stmt = this.db!.prepare(
162
+ `SELECT data FROM ${this.credentialsTable} WHERE sessionId = ? AND userId = ?`
163
+ );
164
+ const row = stmt.get(sessionId, userId) as { data: string } | undefined;
165
+ return row ? JSON.parse(row.data) as SessionCredentials : { sessionId, userId };
166
+ }
167
+
168
+ async clearCredentials(userId: string, sessionId: string): Promise<void> {
169
+ await this.patchCredentials(userId, sessionId, {
170
+ clientInformation: null,
171
+ tokens: null,
172
+ codeVerifier: null,
173
+ clientId: null,
174
+ oauthState: null,
175
+ });
123
176
  }
124
177
 
125
178
  async list(userId: string): Promise<Session[]> {
@@ -130,7 +183,7 @@ export class SqliteStorage implements SessionStore {
130
183
  );
131
184
  const rows = stmt.all(userId) as { data: string }[];
132
185
 
133
- return rows.map(row => JSON.parse(row.data) as Session);
186
+ return rows.map(row => normalizeStoredSession(JSON.parse(row.data) as Session));
134
187
  }
135
188
 
136
189
  async listIds(userId: string): Promise<string[]> {
@@ -149,6 +202,9 @@ export class SqliteStorage implements SessionStore {
149
202
  const stmt = this.db!.prepare(
150
203
  `DELETE FROM ${this.table} WHERE sessionId = ? AND userId = ?`
151
204
  );
205
+ this.db!.prepare(
206
+ `DELETE FROM ${this.credentialsTable} WHERE sessionId = ? AND userId = ?`
207
+ ).run(sessionId, userId);
152
208
  stmt.run(sessionId, userId);
153
209
  }
154
210
 
@@ -161,17 +217,35 @@ export class SqliteStorage implements SessionStore {
161
217
 
162
218
  async clearAll(): Promise<void> {
163
219
  this.ensureInitialized();
220
+ this.db!.prepare(`DELETE FROM ${this.credentialsTable}`).run();
164
221
  const stmt = this.db!.prepare(`DELETE FROM ${this.table}`);
165
222
  stmt.run();
166
223
  }
167
224
 
168
225
  async cleanupExpired(): Promise<void> {
169
226
  this.ensureInitialized();
170
- const now = Date.now();
171
- const stmt = this.db!.prepare(
172
- `DELETE FROM ${this.table} WHERE expiresAt IS NOT NULL AND expiresAt < ?`
173
- );
174
- stmt.run(now);
227
+ const rows = this.db!.prepare(`SELECT sessionId, userId, data FROM ${this.table}`).all() as {
228
+ sessionId: string;
229
+ userId: string;
230
+ data: string;
231
+ }[];
232
+ const deleteStmt = this.db!.prepare(`DELETE FROM ${this.table} WHERE sessionId = ? AND userId = ?`);
233
+
234
+ for (const row of rows) {
235
+ const session = normalizeStoredSession(JSON.parse(row.data) as Session);
236
+ if (isSessionExpired(session)) {
237
+ deleteStmt.run(row.sessionId, row.userId);
238
+ }
239
+ }
240
+
241
+ this.db!.prepare(
242
+ `DELETE FROM ${this.credentialsTable}
243
+ WHERE NOT EXISTS (
244
+ SELECT 1 FROM ${this.table}
245
+ WHERE ${this.table}.sessionId = ${this.credentialsTable}.sessionId
246
+ AND ${this.table}.userId = ${this.credentialsTable}.userId
247
+ )`
248
+ ).run();
175
249
  }
176
250
 
177
251
  async disconnect(): Promise<void> {
@@ -1,33 +1,35 @@
1
1
  import type { SupabaseClient } from '@supabase/supabase-js';
2
- import type { SessionStore, Session } from './types.js';
3
- import { SESSION_TTL_SECONDS } from '../../shared/constants.js';
2
+ import type { SessionStore, Session, SessionCredentials } from './types.js';
4
3
  import { generateSessionId } from '../../shared/utils.js';
5
4
  import { encryptObject, decryptObject } from './crypto.js';
5
+ import { resolveSessionExpiresAt } from './session-lifecycle.js';
6
+ import { DORMANT_SESSION_EXPIRATION_MS } from '../../shared/constants.js';
6
7
 
7
8
  export class SupabaseStorageBackend implements SessionStore {
8
- private readonly DEFAULT_TTL = SESSION_TTL_SECONDS;
9
-
10
9
  constructor(private supabase: SupabaseClient) {}
11
-
10
+
12
11
  async init(): Promise<void> {
13
- // Validate that the table exists
12
+ await this.assertTable('mcp_sessions', 'session_id');
13
+ await this.assertTable('mcp_credentials', 'session_id');
14
+ console.log('[mcp-ts][Storage] Supabase: storage tables verified.');
15
+ }
16
+
17
+ private async assertTable(table: string, column: string): Promise<void> {
14
18
  const { error } = await this.supabase
15
- .from('mcp_sessions')
16
- .select('session_id')
19
+ .from(table)
20
+ .select(column)
17
21
  .limit(0);
18
22
 
19
- if (error) {
20
- // Postgres error code 42P01 is "relation does not exist"
21
- if (error.code === '42P01') {
22
- throw new Error(
23
- '[SupabaseStorage] Table "mcp_sessions" not found in your database. ' +
24
- 'Please run "npx mcp-ts supabase-init" in your project to set up the required table and RLS policies.'
25
- );
26
- }
27
- throw new Error(`[SupabaseStorage] Initialization check failed: ${error.message}`);
23
+ if (!error) return;
24
+
25
+ if (error.code === '42P01') {
26
+ throw new Error(
27
+ `[SupabaseStorage] Table "${table}" not found in your database. ` +
28
+ 'Please run "npx mcp-ts supabase-init" to set up the required storage schema.'
29
+ );
28
30
  }
29
31
 
30
- console.log('[mcp-ts][Storage] Supabase: "mcp_sessions" table verified.');
32
+ throw new Error(`[SupabaseStorage] Initialization check failed for "${table}": ${error.message}`);
31
33
  }
32
34
 
33
35
  generateSessionId(): string {
@@ -43,60 +45,76 @@ export class SupabaseStorageBackend implements SessionStore {
43
45
  transportType: row.transport_type,
44
46
  callbackUrl: row.callback_url,
45
47
  createdAt: new Date(row.created_at).getTime(),
48
+ updatedAt: new Date(row.updated_at ?? row.created_at).getTime(),
49
+ expiresAt: row.expires_at ? new Date(row.expires_at).getTime() : null,
46
50
  userId: row.user_id,
47
51
  headers: decryptObject(row.headers),
48
- active: row.active,
49
- clientInformation: row.client_information,
50
- tokens: decryptObject(row.tokens),
51
- codeVerifier: row.code_verifier,
52
- clientId: row.client_id,
52
+ authUrl: row.auth_url,
53
+ status: row.status ?? 'pending',
54
+ };
55
+ }
56
+
57
+ private mapRowToCredentials(row: any, userId: string, sessionId: string): SessionCredentials {
58
+ return {
59
+ sessionId,
60
+ userId,
61
+ clientInformation: decryptObject(row?.client_information),
62
+ tokens: decryptObject(row?.tokens),
63
+ codeVerifier: decryptObject(row?.code_verifier),
64
+ clientId: row?.client_id,
65
+ oauthState: row?.oauth_state,
53
66
  };
54
67
  }
55
68
 
56
- async create(session: Session, ttl?: number): Promise<void> {
69
+ private hasCredentialData(data: Partial<SessionCredentials>): boolean {
70
+ return (
71
+ 'clientInformation' in data ||
72
+ 'tokens' in data ||
73
+ 'codeVerifier' in data ||
74
+ 'clientId' in data ||
75
+ 'oauthState' in data
76
+ );
77
+ }
78
+
79
+ async create(session: Session): Promise<void> {
57
80
  const { sessionId, userId } = session;
58
81
  if (!sessionId || !userId) throw new Error('userId and sessionId required');
59
82
 
60
- const effectiveTtl = ttl ?? this.DEFAULT_TTL;
61
- const expiresAt = new Date(Date.now() + effectiveTtl * 1000).toISOString();
83
+ const status = session.status ?? 'pending';
84
+ const createdAt = new Date(session.createdAt || Date.now()).toISOString();
85
+ const updatedAt = new Date(session.updatedAt ?? session.createdAt ?? Date.now()).toISOString();
86
+ const expiresAt = resolveSessionExpiresAt(status, new Date(createdAt).getTime());
62
87
 
63
88
  const { error } = await this.supabase
64
89
  .from('mcp_sessions')
65
90
  .insert({
66
91
  session_id: sessionId,
67
- user_id: userId, // Maps user_id to userId to support RLS using auth.uid()
92
+ user_id: userId,
68
93
  server_id: session.serverId,
69
94
  server_name: session.serverName,
70
95
  server_url: session.serverUrl,
71
96
  transport_type: session.transportType,
72
97
  callback_url: session.callbackUrl,
73
- created_at: new Date(session.createdAt || Date.now()).toISOString(),
98
+ created_at: createdAt,
99
+ updated_at: updatedAt,
74
100
  headers: encryptObject(session.headers),
75
- active: session.active ?? false,
76
- client_information: session.clientInformation,
77
- tokens: encryptObject(session.tokens),
78
- code_verifier: session.codeVerifier,
79
- client_id: session.clientId,
80
- expires_at: expiresAt
101
+ auth_url: session.authUrl ?? null,
102
+ status,
103
+ expires_at: expiresAt === null ? null : new Date(expiresAt).toISOString(),
81
104
  });
82
105
 
83
106
  if (error) {
84
- // Postgres error code 23505 is unique violation
85
107
  if (error.code === '23505') {
86
108
  throw new Error(`Session ${sessionId} already exists`);
87
109
  }
88
110
  throw new Error(`Failed to create session in Supabase: ${error.message}`);
89
111
  }
90
- }
91
112
 
92
- async update(userId: string, sessionId: string, data: Partial<Session>, ttl?: number): Promise<void> {
93
- const effectiveTtl = ttl ?? this.DEFAULT_TTL;
94
- const expiresAt = new Date(Date.now() + effectiveTtl * 1000).toISOString();
113
+ }
95
114
 
96
- // Convert the camelCase keys to snake_case for Supabase
115
+ async update(userId: string, sessionId: string, data: Partial<Session>): Promise<void> {
97
116
  const updateData: any = {
98
- expires_at: expiresAt,
99
- updated_at: new Date().toISOString()
117
+ updated_at: new Date().toISOString(),
100
118
  };
101
119
 
102
120
  if ('serverId' in data) updateData.server_id = data.serverId;
@@ -104,27 +122,72 @@ export class SupabaseStorageBackend implements SessionStore {
104
122
  if ('serverUrl' in data) updateData.server_url = data.serverUrl;
105
123
  if ('transportType' in data) updateData.transport_type = data.transportType;
106
124
  if ('callbackUrl' in data) updateData.callback_url = data.callbackUrl;
107
- if ('active' in data) updateData.active = data.active;
125
+ if ('status' in data) {
126
+ const status = data.status ?? 'pending';
127
+ const expiresAt = resolveSessionExpiresAt(status);
128
+ updateData.status = status;
129
+ updateData.expires_at = expiresAt === null ? null : new Date(expiresAt).toISOString();
130
+ }
108
131
  if ('headers' in data) updateData.headers = encryptObject(data.headers);
109
- if ('clientInformation' in data) updateData.client_information = data.clientInformation;
110
- if ('tokens' in data) updateData.tokens = data.tokens === undefined ? null : encryptObject(data.tokens);
111
- if ('codeVerifier' in data) updateData.code_verifier = data.codeVerifier;
112
- if ('clientId' in data) updateData.client_id = data.clientId;
132
+ if ('authUrl' in data) updateData.auth_url = data.authUrl ?? null;
113
133
 
114
- const { data: updatedRows, error } = await this.supabase
115
- .from('mcp_sessions')
116
- .update(updateData)
117
- .eq('user_id', userId)
118
- .eq('session_id', sessionId)
119
- .select('id');
134
+ const shouldUpdateSession = Object.keys(updateData).some((key) => key !== 'updated_at');
120
135
 
121
- if (error) {
122
- throw new Error(`Failed to update session: ${error.message}`);
136
+ let updatedRows: any[] | null = null;
137
+ if (shouldUpdateSession) {
138
+ const result = await this.supabase
139
+ .from('mcp_sessions')
140
+ .update(updateData)
141
+ .eq('user_id', userId)
142
+ .eq('session_id', sessionId)
143
+ .select('id');
144
+
145
+ if (result.error) {
146
+ throw new Error(`Failed to update session: ${result.error.message}`);
147
+ }
148
+ updatedRows = result.data;
149
+ } else {
150
+ const result = await this.supabase
151
+ .from('mcp_sessions')
152
+ .select('id')
153
+ .eq('user_id', userId)
154
+ .eq('session_id', sessionId);
155
+
156
+ if (result.error) {
157
+ throw new Error(`Failed to update session: ${result.error.message}`);
158
+ }
159
+ updatedRows = result.data;
123
160
  }
124
161
 
125
162
  if (!updatedRows || updatedRows.length === 0) {
126
163
  throw new Error(`Session ${sessionId} not found for userId ${userId}`);
127
164
  }
165
+
166
+ }
167
+
168
+ async patchCredentials(userId: string, sessionId: string, data: Partial<SessionCredentials>): Promise<void> {
169
+ if (!this.hasCredentialData(data)) return;
170
+
171
+ const row: any = {
172
+ user_id: userId,
173
+ session_id: sessionId,
174
+ updated_at: new Date().toISOString(),
175
+ };
176
+
177
+ if ('clientInformation' in data) row.client_information = data.clientInformation == null ? null : encryptObject(data.clientInformation);
178
+ if ('tokens' in data) row.tokens = data.tokens == null ? null : encryptObject(data.tokens);
179
+ if ('codeVerifier' in data) row.code_verifier = data.codeVerifier == null ? null : encryptObject(data.codeVerifier);
180
+ if ('clientId' in data) row.client_id = data.clientId ?? null;
181
+ if ('oauthState' in data) row.oauth_state = data.oauthState ?? null;
182
+
183
+ const { error } = await this.supabase
184
+ .from('mcp_credentials')
185
+ .upsert(row, { onConflict: 'user_id,session_id' });
186
+
187
+ if (error) {
188
+ throw new Error(`Failed to update credentials: ${error.message}`);
189
+ }
190
+
128
191
  }
129
192
 
130
193
  async get(userId: string, sessionId: string): Promise<Session | null> {
@@ -145,6 +208,36 @@ export class SupabaseStorageBackend implements SessionStore {
145
208
  return this.mapRowToSessionData(data);
146
209
  }
147
210
 
211
+ async getCredentials(userId: string, sessionId: string): Promise<SessionCredentials | null> {
212
+ const { data, error } = await this.supabase
213
+ .from('mcp_credentials')
214
+ .select('*')
215
+ .eq('user_id', userId)
216
+ .eq('session_id', sessionId)
217
+ .maybeSingle();
218
+
219
+ if (error) {
220
+ console.error('[SupabaseStorage] Failed to get credentials:', error);
221
+ return null;
222
+ }
223
+
224
+ if (data) {
225
+ return this.mapRowToCredentials(data, userId, sessionId);
226
+ }
227
+
228
+ const { data: sessionRows, error: sessionError } = await this.supabase
229
+ .from('mcp_sessions')
230
+ .select('id')
231
+ .eq('user_id', userId)
232
+ .eq('session_id', sessionId);
233
+
234
+ if (sessionError || !sessionRows || sessionRows.length === 0) {
235
+ return null;
236
+ }
237
+
238
+ return { sessionId, userId };
239
+ }
240
+
148
241
  async list(userId: string): Promise<Session[]> {
149
242
  const { data, error } = await this.supabase
150
243
  .from('mcp_sessions')
@@ -159,6 +252,18 @@ export class SupabaseStorageBackend implements SessionStore {
159
252
  return data.map(row => this.mapRowToSessionData(row));
160
253
  }
161
254
 
255
+ async clearCredentials(userId: string, sessionId: string): Promise<void> {
256
+ const { error } = await this.supabase
257
+ .from('mcp_credentials')
258
+ .delete()
259
+ .eq('user_id', userId)
260
+ .eq('session_id', sessionId);
261
+
262
+ if (error) {
263
+ throw new Error(`Failed to clear credentials: ${error.message}`);
264
+ }
265
+ }
266
+
162
267
  async delete(userId: string, sessionId: string): Promise<void> {
163
268
  const { error } = await this.supabase
164
269
  .from('mcp_sessions')
@@ -199,30 +304,50 @@ export class SupabaseStorageBackend implements SessionStore {
199
304
  }
200
305
 
201
306
  async clearAll(): Promise<void> {
202
- // Warning: This deletes everything. Typically only used in testing.
307
+ const { error: credentialsError } = await this.supabase
308
+ .from('mcp_credentials')
309
+ .delete()
310
+ .neq('session_id', '');
311
+
312
+ if (credentialsError) {
313
+ console.error('[SupabaseStorage] Failed to clear credentials:', credentialsError);
314
+ }
315
+
203
316
  const { error } = await this.supabase
204
317
  .from('mcp_sessions')
205
318
  .delete()
206
- .neq('session_id', ''); // Delete all rows trick
207
-
319
+ .neq('session_id', '');
320
+
208
321
  if (error) {
209
322
  console.error('[SupabaseStorage] Failed to clear sessions:', error);
210
323
  }
211
324
  }
212
325
 
213
326
  async cleanupExpired(): Promise<void> {
214
- const { error } = await this.supabase
327
+ const { error: transientError } = await this.supabase
215
328
  .from('mcp_sessions')
216
329
  .delete()
330
+ .not('expires_at', 'is', null)
331
+ .neq('status', 'active')
217
332
  .lt('expires_at', new Date().toISOString());
218
333
 
219
- if (error) {
220
- console.error('[SupabaseStorage] Failed to cleanup expired sessions:', error);
334
+ if (transientError) {
335
+ console.error('[SupabaseStorage] Failed to cleanup expired inactive sessions:', transientError);
336
+ }
337
+
338
+ const dormantCutoff = new Date(Date.now() - DORMANT_SESSION_EXPIRATION_MS).toISOString();
339
+ const { error: dormantError } = await this.supabase
340
+ .from('mcp_sessions')
341
+ .delete()
342
+ .eq('status', 'active')
343
+ .lt('updated_at', dormantCutoff);
344
+
345
+ if (dormantError) {
346
+ console.error('[SupabaseStorage] Failed to cleanup dormant active sessions:', dormantError);
221
347
  }
222
348
  }
223
349
 
224
350
  async disconnect(): Promise<void> {
225
- // Supabase client handles its own connection pooling over HTTP,
226
- // there is no explicit disconnect method.
351
+ // Supabase client handles its own connection pooling over HTTP.
227
352
  }
228
353
  }