@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.
- 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 +728 -306
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +722 -304
- package/dist/index.mjs.map +1 -1
- package/dist/{multi-session-client-C7hGqzgM.d.mts → multi-session-client-CIMUGF8S.d.mts} +15 -33
- package/dist/{multi-session-client-C_kPHpD5.d.ts → multi-session-client-CnvZEGPY.d.ts} +15 -33
- package/dist/server/index.d.mts +45 -17
- package/dist/server/index.d.ts +45 -17
- package/dist/server/index.js +720 -300
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +716 -299
- 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 -101
- 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
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import type { SessionStore, Session } from './types.js';
|
|
2
|
-
import {
|
|
1
|
+
import type { SessionStore, Session, SessionCredentials } from './types.js';
|
|
2
|
+
import type { SessionStatus } from './types.js';
|
|
3
|
+
import { DORMANT_SESSION_EXPIRATION_MS } from '../../shared/constants.js';
|
|
3
4
|
import { generateSessionId } from '../../shared/utils.js';
|
|
4
5
|
import { encryptObject, decryptObject } from './crypto.js';
|
|
6
|
+
import { resolveSessionExpiresAt } from './session-lifecycle.js';
|
|
5
7
|
|
|
6
8
|
export interface NeonStorageOptions {
|
|
7
9
|
schema?: string;
|
|
8
10
|
table?: string;
|
|
11
|
+
credentialsTable?: string;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
type NeonSql = {
|
|
@@ -13,6 +16,7 @@ type NeonSql = {
|
|
|
13
16
|
};
|
|
14
17
|
|
|
15
18
|
type NeonSessionRow = {
|
|
19
|
+
id?: string;
|
|
16
20
|
session_id: string;
|
|
17
21
|
server_id?: string | null;
|
|
18
22
|
server_name?: string | null;
|
|
@@ -20,18 +24,27 @@ type NeonSessionRow = {
|
|
|
20
24
|
transport_type: 'sse' | 'streamable-http';
|
|
21
25
|
callback_url: string;
|
|
22
26
|
created_at: string | Date;
|
|
27
|
+
updated_at?: string | Date | null;
|
|
28
|
+
expires_at?: string | Date | null;
|
|
23
29
|
user_id: string;
|
|
24
30
|
headers?: unknown;
|
|
25
|
-
|
|
31
|
+
auth_url?: string | null;
|
|
32
|
+
status?: SessionStatus | null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type NeonCredentialsRow = {
|
|
36
|
+
session_id: string;
|
|
37
|
+
user_id: string;
|
|
26
38
|
client_information?: unknown;
|
|
27
39
|
tokens?: unknown;
|
|
28
|
-
code_verifier?:
|
|
40
|
+
code_verifier?: unknown;
|
|
29
41
|
client_id?: string | null;
|
|
42
|
+
oauth_state?: unknown;
|
|
30
43
|
};
|
|
31
44
|
|
|
32
45
|
export class NeonStorageBackend implements SessionStore {
|
|
33
|
-
private readonly DEFAULT_TTL = SESSION_TTL_SECONDS;
|
|
34
46
|
private readonly tableName: string;
|
|
47
|
+
private readonly credentialsTableName: string;
|
|
35
48
|
|
|
36
49
|
constructor(
|
|
37
50
|
private readonly sql: NeonSql,
|
|
@@ -39,23 +52,29 @@ export class NeonStorageBackend implements SessionStore {
|
|
|
39
52
|
) {
|
|
40
53
|
const schema = options.schema || 'public';
|
|
41
54
|
const table = options.table || 'mcp_sessions';
|
|
55
|
+
const credentialsTable = options.credentialsTable || 'mcp_credentials';
|
|
42
56
|
this.tableName = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(table)}`;
|
|
57
|
+
this.credentialsTableName = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(credentialsTable)}`;
|
|
43
58
|
}
|
|
44
59
|
|
|
45
60
|
async init(): Promise<void> {
|
|
61
|
+
await this.assertTable(this.tableName, 'mcp_sessions');
|
|
62
|
+
await this.assertTable(this.credentialsTableName, 'mcp_credentials');
|
|
63
|
+
console.log('[mcp-ts][Storage] Neon: storage tables verified.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async assertTable(qualifiedName: string, displayName: string): Promise<void> {
|
|
46
67
|
const [{ exists } = { exists: null }] = await this.sql.query(
|
|
47
68
|
'SELECT to_regclass($1) AS exists',
|
|
48
|
-
[
|
|
69
|
+
[qualifiedName.replace(/"/g, '')]
|
|
49
70
|
) as Array<{ exists: string | null }>;
|
|
50
71
|
|
|
51
72
|
if (!exists) {
|
|
52
73
|
throw new Error(
|
|
53
|
-
|
|
74
|
+
`[NeonStorage] Table "${displayName}" not found in your database. ` +
|
|
54
75
|
'Please create it using the Neon storage guide in docs/storage-backends/neon.md.'
|
|
55
76
|
);
|
|
56
77
|
}
|
|
57
|
-
|
|
58
|
-
console.log('[mcp-ts][Storage] Neon: "mcp_sessions" table verified.');
|
|
59
78
|
}
|
|
60
79
|
|
|
61
80
|
generateSessionId(): string {
|
|
@@ -78,22 +97,45 @@ export class NeonStorageBackend implements SessionStore {
|
|
|
78
97
|
transportType: row.transport_type,
|
|
79
98
|
callbackUrl: row.callback_url,
|
|
80
99
|
createdAt: new Date(row.created_at).getTime(),
|
|
100
|
+
updatedAt: new Date(row.updated_at ?? row.created_at).getTime(),
|
|
101
|
+
expiresAt: row.expires_at ? new Date(row.expires_at).getTime() : null,
|
|
81
102
|
userId: row.user_id,
|
|
82
103
|
headers: decryptObject(row.headers),
|
|
83
|
-
|
|
84
|
-
|
|
104
|
+
authUrl: row.auth_url ?? undefined,
|
|
105
|
+
status: row.status ?? 'pending',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private mapRowToCredentials(row: NeonCredentialsRow, userId: string, sessionId: string): SessionCredentials {
|
|
110
|
+
return {
|
|
111
|
+
sessionId,
|
|
112
|
+
userId,
|
|
113
|
+
clientInformation: decryptObject(row.client_information),
|
|
85
114
|
tokens: decryptObject(row.tokens),
|
|
86
|
-
codeVerifier: row.code_verifier
|
|
115
|
+
codeVerifier: decryptObject(row.code_verifier),
|
|
87
116
|
clientId: row.client_id ?? undefined,
|
|
117
|
+
oauthState: row.oauth_state as SessionCredentials['oauthState'],
|
|
88
118
|
};
|
|
89
119
|
}
|
|
90
120
|
|
|
91
|
-
|
|
121
|
+
private hasCredentialData(data: Partial<SessionCredentials>): boolean {
|
|
122
|
+
return (
|
|
123
|
+
'clientInformation' in data ||
|
|
124
|
+
'tokens' in data ||
|
|
125
|
+
'codeVerifier' in data ||
|
|
126
|
+
'clientId' in data ||
|
|
127
|
+
'oauthState' in data
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async create(session: Session): Promise<void> {
|
|
92
132
|
const { sessionId, userId } = session;
|
|
93
133
|
if (!sessionId || !userId) throw new Error('userId and sessionId required');
|
|
94
134
|
|
|
95
|
-
const
|
|
96
|
-
const
|
|
135
|
+
const status = session.status ?? 'pending';
|
|
136
|
+
const createdAt = new Date(session.createdAt || Date.now()).toISOString();
|
|
137
|
+
const updatedAt = new Date(session.updatedAt ?? session.createdAt ?? Date.now()).toISOString();
|
|
138
|
+
const expiresAt = resolveSessionExpiresAt(status, new Date(createdAt).getTime());
|
|
97
139
|
|
|
98
140
|
try {
|
|
99
141
|
await this.sql.query(
|
|
@@ -106,16 +148,14 @@ export class NeonStorageBackend implements SessionStore {
|
|
|
106
148
|
transport_type,
|
|
107
149
|
callback_url,
|
|
108
150
|
created_at,
|
|
151
|
+
updated_at,
|
|
109
152
|
headers,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
tokens,
|
|
113
|
-
code_verifier,
|
|
114
|
-
client_id,
|
|
153
|
+
auth_url,
|
|
154
|
+
status,
|
|
115
155
|
expires_at
|
|
116
156
|
) VALUES (
|
|
117
157
|
$1, $2, $3, $4, $5, $6, $7, $8,
|
|
118
|
-
$9, $10, $11, $12, $13
|
|
158
|
+
$9, $10, $11, $12, $13
|
|
119
159
|
)`,
|
|
120
160
|
[
|
|
121
161
|
sessionId,
|
|
@@ -125,14 +165,12 @@ export class NeonStorageBackend implements SessionStore {
|
|
|
125
165
|
session.serverUrl,
|
|
126
166
|
session.transportType,
|
|
127
167
|
session.callbackUrl,
|
|
128
|
-
|
|
168
|
+
createdAt,
|
|
169
|
+
updatedAt,
|
|
129
170
|
encryptObject(session.headers),
|
|
130
|
-
session.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
session.codeVerifier,
|
|
134
|
-
session.clientId,
|
|
135
|
-
expiresAt,
|
|
171
|
+
session.authUrl ?? null,
|
|
172
|
+
status,
|
|
173
|
+
expiresAt === null ? null : new Date(expiresAt).toISOString(),
|
|
136
174
|
]
|
|
137
175
|
);
|
|
138
176
|
} catch (error: any) {
|
|
@@ -141,57 +179,106 @@ export class NeonStorageBackend implements SessionStore {
|
|
|
141
179
|
}
|
|
142
180
|
throw new Error(`Failed to create session in Neon: ${error.message}`);
|
|
143
181
|
}
|
|
182
|
+
|
|
144
183
|
}
|
|
145
184
|
|
|
146
|
-
async update(userId: string, sessionId: string, data: Partial<Session
|
|
185
|
+
async update(userId: string, sessionId: string, data: Partial<Session>): Promise<void> {
|
|
147
186
|
const currentSession = await this.get(userId, sessionId);
|
|
148
187
|
if (!currentSession) {
|
|
149
188
|
throw new Error(`Session ${sessionId} not found for userId ${userId}`);
|
|
150
189
|
}
|
|
151
190
|
|
|
152
191
|
const updatedSession = { ...currentSession, ...data };
|
|
153
|
-
const
|
|
154
|
-
const expiresAt =
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
192
|
+
const status = updatedSession.status ?? 'pending';
|
|
193
|
+
const expiresAt = resolveSessionExpiresAt(status);
|
|
194
|
+
|
|
195
|
+
const shouldUpdateSession = (
|
|
196
|
+
'serverId' in data ||
|
|
197
|
+
'serverName' in data ||
|
|
198
|
+
'serverUrl' in data ||
|
|
199
|
+
'transportType' in data ||
|
|
200
|
+
'callbackUrl' in data ||
|
|
201
|
+
'status' in data ||
|
|
202
|
+
'headers' in data ||
|
|
203
|
+
'authUrl' in data
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
if (shouldUpdateSession) {
|
|
207
|
+
const updatedRows = await this.sql.query(
|
|
208
|
+
`UPDATE ${this.tableName}
|
|
209
|
+
SET
|
|
210
|
+
server_id = $1,
|
|
211
|
+
server_name = $2,
|
|
212
|
+
server_url = $3,
|
|
213
|
+
transport_type = $4,
|
|
214
|
+
callback_url = $5,
|
|
215
|
+
status = $6,
|
|
216
|
+
headers = $7,
|
|
217
|
+
auth_url = $8,
|
|
218
|
+
expires_at = $9,
|
|
219
|
+
updated_at = now()
|
|
220
|
+
WHERE user_id = $10 AND session_id = $11
|
|
221
|
+
RETURNING id`,
|
|
222
|
+
[
|
|
223
|
+
updatedSession.serverId,
|
|
224
|
+
updatedSession.serverName,
|
|
225
|
+
updatedSession.serverUrl,
|
|
226
|
+
updatedSession.transportType,
|
|
227
|
+
updatedSession.callbackUrl,
|
|
228
|
+
status,
|
|
229
|
+
encryptObject(updatedSession.headers),
|
|
230
|
+
updatedSession.authUrl ?? null,
|
|
231
|
+
expiresAt === null ? null : new Date(expiresAt).toISOString(),
|
|
232
|
+
userId,
|
|
233
|
+
sessionId,
|
|
234
|
+
]
|
|
235
|
+
) as Array<{ id: string }>;
|
|
236
|
+
|
|
237
|
+
if (updatedRows.length === 0) {
|
|
238
|
+
throw new Error(`Session ${sessionId} not found for userId ${userId}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async patchCredentials(userId: string, sessionId: string, data: Partial<SessionCredentials>): Promise<void> {
|
|
245
|
+
if (!this.hasCredentialData(data)) return;
|
|
246
|
+
|
|
247
|
+
await this.sql.query(
|
|
248
|
+
`INSERT INTO ${this.credentialsTableName} (
|
|
249
|
+
user_id,
|
|
250
|
+
session_id,
|
|
251
|
+
client_information,
|
|
252
|
+
tokens,
|
|
253
|
+
code_verifier,
|
|
254
|
+
client_id,
|
|
255
|
+
oauth_state,
|
|
256
|
+
updated_at
|
|
257
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
|
258
|
+
ON CONFLICT (user_id, session_id)
|
|
259
|
+
DO UPDATE SET
|
|
260
|
+
client_information = CASE WHEN $8 THEN EXCLUDED.client_information ELSE ${this.credentialsTableName}.client_information END,
|
|
261
|
+
tokens = CASE WHEN $9 THEN EXCLUDED.tokens ELSE ${this.credentialsTableName}.tokens END,
|
|
262
|
+
code_verifier = CASE WHEN $10 THEN EXCLUDED.code_verifier ELSE ${this.credentialsTableName}.code_verifier END,
|
|
263
|
+
client_id = CASE WHEN $11 THEN EXCLUDED.client_id ELSE ${this.credentialsTableName}.client_id END,
|
|
264
|
+
oauth_state = CASE WHEN $12 THEN EXCLUDED.oauth_state ELSE ${this.credentialsTableName}.oauth_state END,
|
|
265
|
+
updated_at = now()`,
|
|
174
266
|
[
|
|
175
|
-
updatedSession.serverId,
|
|
176
|
-
updatedSession.serverName,
|
|
177
|
-
updatedSession.serverUrl,
|
|
178
|
-
updatedSession.transportType,
|
|
179
|
-
updatedSession.callbackUrl,
|
|
180
|
-
updatedSession.active ?? false,
|
|
181
|
-
encryptObject(updatedSession.headers),
|
|
182
|
-
updatedSession.clientInformation,
|
|
183
|
-
updatedSession.tokens === undefined ? null : encryptObject(updatedSession.tokens),
|
|
184
|
-
updatedSession.codeVerifier,
|
|
185
|
-
updatedSession.clientId,
|
|
186
|
-
expiresAt,
|
|
187
267
|
userId,
|
|
188
268
|
sessionId,
|
|
269
|
+
'clientInformation' in data ? (data.clientInformation == null ? null : encryptObject(data.clientInformation)) : null,
|
|
270
|
+
'tokens' in data ? (data.tokens == null ? null : encryptObject(data.tokens)) : null,
|
|
271
|
+
'codeVerifier' in data ? (data.codeVerifier == null ? null : encryptObject(data.codeVerifier)) : null,
|
|
272
|
+
'clientId' in data ? (data.clientId ?? null) : null,
|
|
273
|
+
'oauthState' in data ? (data.oauthState ?? null) : null,
|
|
274
|
+
'clientInformation' in data,
|
|
275
|
+
'tokens' in data,
|
|
276
|
+
'codeVerifier' in data,
|
|
277
|
+
'clientId' in data,
|
|
278
|
+
'oauthState' in data,
|
|
189
279
|
]
|
|
190
|
-
)
|
|
280
|
+
);
|
|
191
281
|
|
|
192
|
-
if (updatedRows.length === 0) {
|
|
193
|
-
throw new Error(`Session ${sessionId} not found for userId ${userId}`);
|
|
194
|
-
}
|
|
195
282
|
}
|
|
196
283
|
|
|
197
284
|
async get(userId: string, sessionId: string): Promise<Session | null> {
|
|
@@ -200,13 +287,39 @@ export class NeonStorageBackend implements SessionStore {
|
|
|
200
287
|
`SELECT * FROM ${this.tableName} WHERE user_id = $1 AND session_id = $2`,
|
|
201
288
|
[userId, sessionId]
|
|
202
289
|
) as NeonSessionRow[];
|
|
203
|
-
|
|
290
|
+
|
|
291
|
+
if (!rows[0]) return null;
|
|
292
|
+
|
|
293
|
+
return this.mapRowToSessionData(rows[0]);
|
|
204
294
|
} catch (error) {
|
|
205
295
|
console.error('[NeonStorage] Failed to get session:', error);
|
|
206
296
|
return null;
|
|
207
297
|
}
|
|
208
298
|
}
|
|
209
299
|
|
|
300
|
+
async getCredentials(userId: string, sessionId: string): Promise<SessionCredentials | null> {
|
|
301
|
+
try {
|
|
302
|
+
const credentialRows = await this.sql.query(
|
|
303
|
+
`SELECT * FROM ${this.credentialsTableName} WHERE user_id = $1 AND session_id = $2`,
|
|
304
|
+
[userId, sessionId]
|
|
305
|
+
) as NeonCredentialsRow[];
|
|
306
|
+
|
|
307
|
+
if (credentialRows[0]) {
|
|
308
|
+
return this.mapRowToCredentials(credentialRows[0], userId, sessionId);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const sessionRows = await this.sql.query(
|
|
312
|
+
`SELECT id FROM ${this.tableName} WHERE user_id = $1 AND session_id = $2`,
|
|
313
|
+
[userId, sessionId]
|
|
314
|
+
) as Array<{ id: string }>;
|
|
315
|
+
|
|
316
|
+
return sessionRows[0] ? { sessionId, userId } : null;
|
|
317
|
+
} catch (error) {
|
|
318
|
+
console.error('[NeonStorage] Failed to get credentials:', error);
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
210
323
|
async list(userId: string): Promise<Session[]> {
|
|
211
324
|
try {
|
|
212
325
|
const rows = await this.sql.query(
|
|
@@ -220,6 +333,17 @@ export class NeonStorageBackend implements SessionStore {
|
|
|
220
333
|
}
|
|
221
334
|
}
|
|
222
335
|
|
|
336
|
+
async clearCredentials(userId: string, sessionId: string): Promise<void> {
|
|
337
|
+
try {
|
|
338
|
+
await this.sql.query(
|
|
339
|
+
`DELETE FROM ${this.credentialsTableName} WHERE user_id = $1 AND session_id = $2`,
|
|
340
|
+
[userId, sessionId]
|
|
341
|
+
);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
console.error('[NeonStorage] Failed to clear credentials:', error);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
223
347
|
async delete(userId: string, sessionId: string): Promise<void> {
|
|
224
348
|
try {
|
|
225
349
|
await this.sql.query(
|
|
@@ -258,6 +382,7 @@ export class NeonStorageBackend implements SessionStore {
|
|
|
258
382
|
|
|
259
383
|
async clearAll(): Promise<void> {
|
|
260
384
|
try {
|
|
385
|
+
await this.sql.query(`DELETE FROM ${this.credentialsTableName}`);
|
|
261
386
|
await this.sql.query(`DELETE FROM ${this.tableName}`);
|
|
262
387
|
} catch (error) {
|
|
263
388
|
console.error('[NeonStorage] Failed to clear sessions:', error);
|
|
@@ -267,9 +392,17 @@ export class NeonStorageBackend implements SessionStore {
|
|
|
267
392
|
async cleanupExpired(): Promise<void> {
|
|
268
393
|
try {
|
|
269
394
|
await this.sql.query(
|
|
270
|
-
`DELETE FROM ${this.tableName}
|
|
395
|
+
`DELETE FROM ${this.tableName}
|
|
396
|
+
WHERE expires_at IS NOT NULL
|
|
397
|
+
AND expires_at < $1
|
|
398
|
+
AND status <> 'active'`,
|
|
271
399
|
[new Date().toISOString()]
|
|
272
400
|
);
|
|
401
|
+
await this.sql.query(
|
|
402
|
+
`DELETE FROM ${this.tableName}
|
|
403
|
+
WHERE status = 'active' AND updated_at < $1`,
|
|
404
|
+
[new Date(Date.now() - DORMANT_SESSION_EXPIRATION_MS).toISOString()]
|
|
405
|
+
);
|
|
273
406
|
} catch (error) {
|
|
274
407
|
console.error('[NeonStorage] Failed to cleanup expired sessions:', error);
|
|
275
408
|
}
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import type { Redis } from 'ioredis';
|
|
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';
|
|
4
|
+
import {
|
|
5
|
+
mergeSessionUpdate,
|
|
6
|
+
normalizeNewSession,
|
|
7
|
+
normalizeStoredSession,
|
|
8
|
+
resolveSessionRedisTtlSeconds,
|
|
9
|
+
} from './session-lifecycle.js';
|
|
5
10
|
|
|
6
11
|
/**
|
|
7
12
|
* Redis implementation of SessionStore
|
|
8
13
|
*/
|
|
9
14
|
export class RedisStorageBackend implements SessionStore {
|
|
10
|
-
private readonly DEFAULT_TTL = SESSION_TTL_SECONDS;
|
|
11
15
|
private readonly KEY_PREFIX = 'mcp:session:';
|
|
16
|
+
private readonly CREDENTIALS_KEY_PREFIX = 'mcp:credentials:';
|
|
12
17
|
private readonly USER_ID_KEY_PREFIX = 'mcp:userId:';
|
|
13
18
|
private readonly USER_ID_KEY_SUFFIX = ':sessions';
|
|
14
19
|
|
|
@@ -31,6 +36,10 @@ export class RedisStorageBackend implements SessionStore {
|
|
|
31
36
|
return `${this.KEY_PREFIX}${userId}:${sessionId}`;
|
|
32
37
|
}
|
|
33
38
|
|
|
39
|
+
private getCredentialsKey(userId: string, sessionId: string): string {
|
|
40
|
+
return `${this.CREDENTIALS_KEY_PREFIX}${userId}:${sessionId}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
/**
|
|
35
44
|
* Generates Redis key for tracking all sessions for a user
|
|
36
45
|
* @private
|
|
@@ -78,18 +87,18 @@ export class RedisStorageBackend implements SessionStore {
|
|
|
78
87
|
return generateSessionId();
|
|
79
88
|
}
|
|
80
89
|
|
|
81
|
-
async create(session: Session
|
|
90
|
+
async create(session: Session): Promise<void> {
|
|
82
91
|
const { sessionId, userId } = session;
|
|
83
92
|
if (!sessionId || !userId) throw new Error('userId and sessionId required');
|
|
84
93
|
|
|
85
94
|
const sessionKey = this.getSessionKey(userId, sessionId);
|
|
86
95
|
const userIdKey = this.getUserIdKey(userId);
|
|
87
|
-
const
|
|
96
|
+
const sessionWithLifecycle = normalizeNewSession(session);
|
|
97
|
+
const effectiveTtl = resolveSessionRedisTtlSeconds(sessionWithLifecycle);
|
|
88
98
|
|
|
89
|
-
/** ioredis syntax: set(key, val, 'EX', ttl, 'NX') */
|
|
90
99
|
const result = await this.redis.set(
|
|
91
100
|
sessionKey,
|
|
92
|
-
JSON.stringify(
|
|
101
|
+
JSON.stringify(sessionWithLifecycle),
|
|
93
102
|
'EX',
|
|
94
103
|
effectiveTtl,
|
|
95
104
|
'NX'
|
|
@@ -101,11 +110,10 @@ export class RedisStorageBackend implements SessionStore {
|
|
|
101
110
|
|
|
102
111
|
await this.redis.sadd(userIdKey, sessionId);
|
|
103
112
|
}
|
|
104
|
-
async update(userId: string, sessionId: string, data: Partial<Session
|
|
113
|
+
async update(userId: string, sessionId: string, data: Partial<Session>): Promise<void> {
|
|
105
114
|
const sessionKey = this.getSessionKey(userId, sessionId);
|
|
106
|
-
const effectiveTtl = ttl ?? this.DEFAULT_TTL;
|
|
107
115
|
|
|
108
|
-
/** Lua script for atomic
|
|
116
|
+
/** Lua script for atomic get-and-set with expiration refresh. */
|
|
109
117
|
const script = `
|
|
110
118
|
local currentStr = redis.call("GET", KEYS[1])
|
|
111
119
|
if not currentStr then
|
|
@@ -113,21 +121,25 @@ export class RedisStorageBackend implements SessionStore {
|
|
|
113
121
|
end
|
|
114
122
|
|
|
115
123
|
local current = cjson.decode(currentStr)
|
|
116
|
-
local
|
|
124
|
+
local updated = cjson.decode(ARGV[1])
|
|
117
125
|
|
|
118
|
-
|
|
119
|
-
current[k] = v
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
redis.call("SET", KEYS[1], cjson.encode(current), "EX", ARGV[2])
|
|
126
|
+
redis.call("SET", KEYS[1], cjson.encode(updated), "EX", ARGV[2])
|
|
123
127
|
return 1
|
|
124
128
|
`;
|
|
125
129
|
|
|
130
|
+
const current = await this.get(userId, sessionId);
|
|
131
|
+
if (!current) {
|
|
132
|
+
throw new Error(`Session ${sessionId} not found for userId ${userId}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const updated = mergeSessionUpdate(current, data);
|
|
136
|
+
const effectiveTtl = resolveSessionRedisTtlSeconds(updated);
|
|
137
|
+
|
|
126
138
|
const result = await this.redis.eval(
|
|
127
139
|
script,
|
|
128
140
|
1,
|
|
129
141
|
sessionKey,
|
|
130
|
-
JSON.stringify(
|
|
142
|
+
JSON.stringify(updated),
|
|
131
143
|
effectiveTtl
|
|
132
144
|
);
|
|
133
145
|
|
|
@@ -136,6 +148,29 @@ export class RedisStorageBackend implements SessionStore {
|
|
|
136
148
|
}
|
|
137
149
|
}
|
|
138
150
|
|
|
151
|
+
async patchCredentials(userId: string, sessionId: string, data: Partial<SessionCredentials>): Promise<void> {
|
|
152
|
+
const sessionKey = this.getSessionKey(userId, sessionId);
|
|
153
|
+
const credentialsKey = this.getCredentialsKey(userId, sessionId);
|
|
154
|
+
const sessionExists = await this.redis.exists(sessionKey);
|
|
155
|
+
if (!sessionExists) {
|
|
156
|
+
throw new Error(`Session ${sessionId} not found for userId ${userId}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const session = await this.get(userId, sessionId);
|
|
160
|
+
const currentTtl = await this.redis.ttl(sessionKey);
|
|
161
|
+
const effectiveTtl = currentTtl > 0 && session
|
|
162
|
+
? currentTtl
|
|
163
|
+
: resolveSessionRedisTtlSeconds(session ?? { status: 'pending' });
|
|
164
|
+
const currentStr = await this.redis.get(credentialsKey);
|
|
165
|
+
const current = currentStr ? JSON.parse(currentStr) as SessionCredentials : { sessionId, userId };
|
|
166
|
+
const credentials = { ...current, ...data, sessionId, userId };
|
|
167
|
+
if (effectiveTtl > 0) {
|
|
168
|
+
await this.redis.set(credentialsKey, JSON.stringify(credentials), 'EX', effectiveTtl);
|
|
169
|
+
} else {
|
|
170
|
+
await this.redis.set(credentialsKey, JSON.stringify(credentials));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
139
174
|
async get(userId: string, sessionId: string): Promise<Session | null> {
|
|
140
175
|
try {
|
|
141
176
|
const sessionKey = this.getSessionKey(userId, sessionId);
|
|
@@ -145,14 +180,32 @@ export class RedisStorageBackend implements SessionStore {
|
|
|
145
180
|
return null;
|
|
146
181
|
}
|
|
147
182
|
|
|
148
|
-
const
|
|
149
|
-
return
|
|
183
|
+
const session: Session = JSON.parse(sessionDataStr);
|
|
184
|
+
return normalizeStoredSession(session);
|
|
150
185
|
} catch (error) {
|
|
151
186
|
console.error('[RedisStorageBackend] Failed to get session:', error);
|
|
152
187
|
return null;
|
|
153
188
|
}
|
|
154
189
|
}
|
|
155
190
|
|
|
191
|
+
async getCredentials(userId: string, sessionId: string): Promise<SessionCredentials | null> {
|
|
192
|
+
const session = await this.get(userId, sessionId);
|
|
193
|
+
if (!session) return null;
|
|
194
|
+
|
|
195
|
+
const credentialsStr = await this.redis.get(this.getCredentialsKey(userId, sessionId));
|
|
196
|
+
return credentialsStr ? JSON.parse(credentialsStr) as SessionCredentials : { sessionId, userId };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async clearCredentials(userId: string, sessionId: string): Promise<void> {
|
|
200
|
+
await this.patchCredentials(userId, sessionId, {
|
|
201
|
+
clientInformation: null,
|
|
202
|
+
tokens: null,
|
|
203
|
+
codeVerifier: null,
|
|
204
|
+
clientId: null,
|
|
205
|
+
oauthState: null,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
156
209
|
async listIds(userId: string): Promise<string[]> {
|
|
157
210
|
const sessions = await this.list(userId);
|
|
158
211
|
return sessions.map((session) => session.sessionId);
|
|
@@ -176,7 +229,9 @@ export class RedisStorageBackend implements SessionStore {
|
|
|
176
229
|
await this.redis.srem(userIdKey, ...staleSessionIds);
|
|
177
230
|
}
|
|
178
231
|
|
|
179
|
-
return results
|
|
232
|
+
return results
|
|
233
|
+
.filter((session): session is Session => session !== null)
|
|
234
|
+
.map((session) => normalizeStoredSession(session));
|
|
180
235
|
} catch (error) {
|
|
181
236
|
console.error(`[RedisStorageBackend] Failed to get session data for ${userId}:`, error);
|
|
182
237
|
return [];
|
|
@@ -186,10 +241,11 @@ export class RedisStorageBackend implements SessionStore {
|
|
|
186
241
|
async delete(userId: string, sessionId: string): Promise<void> {
|
|
187
242
|
try {
|
|
188
243
|
const sessionKey = this.getSessionKey(userId, sessionId);
|
|
244
|
+
const credentialsKey = this.getCredentialsKey(userId, sessionId);
|
|
189
245
|
const userIdKey = this.getUserIdKey(userId);
|
|
190
246
|
|
|
191
247
|
await this.redis.srem(userIdKey, sessionId);
|
|
192
|
-
await this.redis.del(sessionKey);
|
|
248
|
+
await this.redis.del(sessionKey, credentialsKey);
|
|
193
249
|
} catch (error) {
|
|
194
250
|
console.error('[RedisStorageBackend] Failed to remove session:', error);
|
|
195
251
|
}
|
|
@@ -206,7 +262,7 @@ export class RedisStorageBackend implements SessionStore {
|
|
|
206
262
|
}
|
|
207
263
|
|
|
208
264
|
try {
|
|
209
|
-
return (JSON.parse(data) as Session).sessionId;
|
|
265
|
+
return normalizeStoredSession(JSON.parse(data) as Session).sessionId;
|
|
210
266
|
} catch (error) {
|
|
211
267
|
console.error('[RedisStorageBackend] Failed to parse session while listing all session IDs:', error);
|
|
212
268
|
return null;
|
|
@@ -224,8 +280,9 @@ export class RedisStorageBackend implements SessionStore {
|
|
|
224
280
|
async clearAll(): Promise<void> {
|
|
225
281
|
try {
|
|
226
282
|
const keys = await this.scanKeys(`${this.KEY_PREFIX}*`);
|
|
283
|
+
const credentialKeys = await this.scanKeys(`${this.CREDENTIALS_KEY_PREFIX}*`);
|
|
227
284
|
const userIdKeys = await this.scanKeys(`${this.USER_ID_KEY_PREFIX}*${this.USER_ID_KEY_SUFFIX}`);
|
|
228
|
-
const allKeys = [...keys, ...userIdKeys];
|
|
285
|
+
const allKeys = [...keys, ...credentialKeys, ...userIdKeys];
|
|
229
286
|
if (allKeys.length > 0) {
|
|
230
287
|
await this.redis.del(...allKeys);
|
|
231
288
|
}
|
|
@@ -254,6 +311,7 @@ export class RedisStorageBackend implements SessionStore {
|
|
|
254
311
|
const staleSessionIds = sessionIds.filter((_, index) => existenceChecks[index] === 0);
|
|
255
312
|
if (staleSessionIds.length > 0) {
|
|
256
313
|
await this.redis.srem(userIdKey, ...staleSessionIds);
|
|
314
|
+
await this.redis.del(...staleSessionIds.map((sessionId) => this.getCredentialsKey(userId, sessionId)));
|
|
257
315
|
}
|
|
258
316
|
|
|
259
317
|
const remainingCount = await this.redis.scard(userIdKey);
|