@mcp-ts/sdk 2.3.3 → 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.
- package/README.md +113 -30
- package/dist/adapters/agui-adapter.d.mts +3 -3
- package/dist/adapters/agui-adapter.d.ts +3 -3
- package/dist/adapters/agui-middleware.d.mts +3 -3
- package/dist/adapters/agui-middleware.d.ts +3 -3
- package/dist/adapters/ai-adapter.d.mts +3 -3
- package/dist/adapters/ai-adapter.d.ts +3 -3
- package/dist/adapters/langchain-adapter.d.mts +3 -3
- package/dist/adapters/langchain-adapter.d.ts +3 -3
- package/dist/adapters/mastra-adapter.d.mts +1 -1
- package/dist/adapters/mastra-adapter.d.ts +1 -1
- package/dist/client/index.d.mts +2 -2
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +2 -2
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +2 -2
- package/dist/client/index.mjs.map +1 -1
- package/dist/client/react.d.mts +9 -8
- package/dist/client/react.d.ts +9 -8
- package/dist/client/react.js +66 -26
- package/dist/client/react.js.map +1 -1
- package/dist/client/react.mjs +67 -27
- package/dist/client/react.mjs.map +1 -1
- package/dist/client/vue.d.mts +6 -5
- package/dist/client/vue.d.ts +6 -5
- package/dist/client/vue.js +27 -17
- package/dist/client/vue.js.map +1 -1
- package/dist/client/vue.mjs +27 -17
- package/dist/client/vue.mjs.map +1 -1
- package/dist/{index-Cfjsme-a.d.mts → index-ByIjEReo.d.mts} +2 -2
- package/dist/{index-CmjMd2ac.d.ts → index-CtXvKl8N.d.ts} +2 -2
- package/dist/index.d.mts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +729 -397
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +724 -396
- package/dist/index.mjs.map +1 -1
- package/dist/{multi-session-client-DYNe6az3.d.mts → multi-session-client-CIMUGF8S.d.mts} +15 -41
- package/dist/{multi-session-client-BYtguGJm.d.ts → multi-session-client-CnvZEGPY.d.ts} +15 -41
- package/dist/server/index.d.mts +45 -17
- package/dist/server/index.d.ts +45 -17
- package/dist/server/index.js +721 -391
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +718 -391
- package/dist/server/index.mjs.map +1 -1
- package/dist/shared/index.d.mts +9 -8
- package/dist/shared/index.d.ts +9 -8
- package/dist/shared/index.js +7 -5
- package/dist/shared/index.js.map +1 -1
- package/dist/shared/index.mjs +5 -4
- package/dist/shared/index.mjs.map +1 -1
- package/dist/{tool-router-BMzhoNYt.d.ts → tool-router-CZMrOG8J.d.ts} +1 -1
- package/dist/{tool-router-BhHsvBfP.d.mts → tool-router-CuApsDiV.d.mts} +1 -1
- package/dist/{types-CxFaaZrM.d.mts → types-DCk_IF4L.d.mts} +5 -3
- package/dist/{types-CxFaaZrM.d.ts → types-DCk_IF4L.d.ts} +5 -3
- package/migrations/neon/20260513010000_install_mcp_sessions.sql +40 -3
- package/migrations/neon/20260513020000_add_session_cleanup_cron.sql +4 -4
- package/migrations/supabase/20260330195700_install_mcp_sessions.sql +67 -3
- package/migrations/supabase/20260421010000_add_session_cleanup_cron.sql +6 -6
- package/package.json +13 -3
- package/src/client/core/sse-client.ts +2 -2
- package/src/client/react/index.ts +1 -1
- package/src/client/react/oauth-popup.tsx +34 -13
- package/src/client/react/use-mcp.ts +19 -45
- package/src/client/utils/session-state.ts +43 -0
- package/src/client/vue/use-mcp.ts +18 -47
- package/src/server/handlers/sse-handler.ts +9 -4
- package/src/server/mcp/multi-session-client.ts +2 -6
- package/src/server/mcp/oauth-client.ts +73 -220
- package/src/server/mcp/storage-oauth-provider.ts +79 -50
- package/src/server/storage/file-backend.ts +74 -18
- package/src/server/storage/index.ts +2 -4
- package/src/server/storage/memory-backend.ts +49 -13
- package/src/server/storage/neon-backend.ts +201 -68
- package/src/server/storage/redis-backend.ts +81 -23
- package/src/server/storage/session-lifecycle.ts +78 -0
- package/src/server/storage/sqlite-backend.ts +89 -15
- package/src/server/storage/supabase-backend.ts +188 -63
- package/src/server/storage/types.ts +49 -16
- package/src/shared/constants.ts +5 -6
- package/src/shared/types.ts +5 -3
- 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
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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(
|
|
16
|
-
.select(
|
|
19
|
+
.from(table)
|
|
20
|
+
.select(column)
|
|
17
21
|
.limit(0);
|
|
18
22
|
|
|
19
|
-
if (error)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
61
|
-
const
|
|
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,
|
|
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:
|
|
98
|
+
created_at: createdAt,
|
|
99
|
+
updated_at: updatedAt,
|
|
74
100
|
headers: encryptObject(session.headers),
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
93
|
-
const effectiveTtl = ttl ?? this.DEFAULT_TTL;
|
|
94
|
-
const expiresAt = new Date(Date.now() + effectiveTtl * 1000).toISOString();
|
|
113
|
+
}
|
|
95
114
|
|
|
96
|
-
|
|
115
|
+
async update(userId: string, sessionId: string, data: Partial<Session>): Promise<void> {
|
|
97
116
|
const updateData: any = {
|
|
98
|
-
|
|
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 ('
|
|
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 ('
|
|
110
|
-
if ('tokens' in data) updateData.tokens = 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
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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', '');
|
|
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 (
|
|
220
|
-
console.error('[SupabaseStorage] Failed to cleanup expired sessions:',
|
|
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
|
}
|