@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.
- package/README.md +161 -39
- package/dist/adapters/agui-adapter.d.mts +4 -4
- package/dist/adapters/agui-adapter.d.ts +4 -4
- package/dist/adapters/agui-middleware.d.mts +4 -4
- package/dist/adapters/agui-middleware.d.ts +4 -4
- package/dist/adapters/ai-adapter.d.mts +4 -4
- package/dist/adapters/ai-adapter.d.ts +4 -4
- package/dist/adapters/langchain-adapter.d.mts +4 -4
- package/dist/adapters/langchain-adapter.d.ts +4 -4
- package/dist/adapters/mastra-adapter.d.mts +2 -2
- package/dist/adapters/mastra-adapter.d.ts +2 -2
- package/dist/client/index.d.mts +3 -3
- package/dist/client/index.d.ts +3 -3
- 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 +11 -10
- package/dist/client/react.d.ts +11 -10
- 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 +8 -7
- package/dist/client/vue.d.ts +8 -7
- 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/{events-CK3N--3g.d.mts → events-C4m7tK1U.d.mts} +0 -1
- package/dist/{events-CK3N--3g.d.ts → events-C4m7tK1U.d.ts} +0 -1
- package/dist/{index-CmjMd2ac.d.ts → index-Ch7ouNSa.d.ts} +3 -3
- package/dist/{index-Cfjsme-a.d.mts → index-L5XoXgsb.d.mts} +3 -3
- package/dist/index.d.mts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.js +799 -326
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +793 -324
- package/dist/index.mjs.map +1 -1
- package/dist/{multi-session-client-C_kPHpD5.d.ts → multi-session-client-Bwm-efqg.d.ts} +43 -38
- package/dist/{multi-session-client-C7hGqzgM.d.mts → multi-session-client-k-9RvWvi.d.mts} +43 -38
- package/dist/server/index.d.mts +48 -20
- package/dist/server/index.d.ts +48 -20
- package/dist/server/index.js +791 -320
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +787 -319
- package/dist/server/index.mjs.map +1 -1
- package/dist/shared/index.d.mts +10 -9
- package/dist/shared/index.d.ts +10 -9
- 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/migrations/v2.3.4/neon/20260513010000_install_mcp_sessions.sql +69 -0
- package/migrations/v2.3.4/neon/20260513020000_add_session_cleanup_cron.sql +35 -0
- package/migrations/v2.3.4/supabase/20260330195700_install_mcp_sessions.sql +82 -0
- package/migrations/v2.3.4/supabase/20260421010000_add_session_cleanup_cron.sql +32 -0
- 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 +16 -9
- package/src/server/mcp/multi-session-client.ts +48 -11
- package/src/server/mcp/oauth-client.ts +133 -111
- 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/events.ts +0 -1
- package/src/shared/types.ts +5 -3
- 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
|
-
|
|
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
|
-
|
|
283
|
-
if (!
|
|
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 ||
|
|
288
|
-
this.callbackUrl = this.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 ||
|
|
295
|
-
this.serverId = this.serverId ||
|
|
296
|
-
this.headers = this.headers ||
|
|
297
|
-
this.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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
|
|
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
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
420
|
+
await sessions.update(this.userId, this.sessionId, sessionData);
|
|
399
421
|
} else {
|
|
400
|
-
await sessions.create(sessionData
|
|
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}
|
|
503
|
-
await this.saveSession(
|
|
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
|
-
//
|
|
533
|
-
//
|
|
534
|
-
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
//
|
|
573
|
-
// sessions may still hold
|
|
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.
|
|
624
|
+
if (!existingSession || existingSession.status !== 'active') {
|
|
577
625
|
await sessions.delete(this.userId, this.sessionId);
|
|
578
626
|
}
|
|
579
627
|
} catch {
|
|
580
|
-
//
|
|
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
|
-
|
|
670
|
-
|
|
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
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
|
112
|
+
* Loads OAuth credentials from the session store
|
|
114
113
|
* @private
|
|
115
114
|
*/
|
|
116
|
-
private async
|
|
117
|
-
const data = await sessions.
|
|
115
|
+
private async getCredentials(): Promise<SessionCredentials> {
|
|
116
|
+
const data = await sessions.getCredentials(this.userId, this.sessionId);
|
|
118
117
|
if (!data) {
|
|
119
|
-
return {
|
|
118
|
+
return { userId: this.userId, sessionId: this.sessionId };
|
|
120
119
|
}
|
|
121
120
|
return data;
|
|
122
121
|
}
|
|
123
122
|
|
|
124
123
|
/**
|
|
125
|
-
* Saves OAuth
|
|
126
|
-
* @param data - Partial OAuth
|
|
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
|
|
131
|
-
await sessions.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
191
|
-
const
|
|
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
|
-
|
|
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(
|
|
201
|
-
|
|
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<
|
|
252
|
+
const updates: Partial<SessionCredentials> = {};
|
|
218
253
|
|
|
219
254
|
if (scope === "client") {
|
|
220
|
-
updates.clientInformation =
|
|
221
|
-
updates.clientId =
|
|
255
|
+
updates.clientInformation = null;
|
|
256
|
+
updates.clientId = null;
|
|
222
257
|
} else if (scope === "tokens") {
|
|
223
|
-
updates.tokens =
|
|
258
|
+
updates.tokens = null;
|
|
224
259
|
} else if (scope === "verifier") {
|
|
225
|
-
updates.codeVerifier =
|
|
260
|
+
updates.codeVerifier = null;
|
|
226
261
|
}
|
|
227
|
-
await this.
|
|
262
|
+
await this.patchCredentials(updates);
|
|
228
263
|
}
|
|
229
264
|
}
|
|
230
265
|
|
|
231
266
|
async saveCodeVerifier(verifier: string): Promise<void> {
|
|
232
|
-
await this.
|
|
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.
|
|
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.
|
|
289
|
+
await this.patchCredentials({ codeVerifier: null });
|
|
250
290
|
}
|
|
251
291
|
|
|
252
292
|
async tokens(): Promise<OAuthTokens | undefined> {
|
|
253
|
-
const data = await this.
|
|
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
|
}
|