@mcp-ts/sdk 1.3.7 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +398 -404
  3. package/dist/adapters/agui-middleware.js.map +1 -1
  4. package/dist/adapters/agui-middleware.mjs.map +1 -1
  5. package/dist/bin/mcp-ts.js +0 -0
  6. package/dist/bin/mcp-ts.js.map +1 -1
  7. package/dist/bin/mcp-ts.mjs +0 -0
  8. package/dist/bin/mcp-ts.mjs.map +1 -1
  9. package/dist/client/index.js.map +1 -1
  10. package/dist/client/index.mjs.map +1 -1
  11. package/dist/client/react.d.mts +2 -2
  12. package/dist/client/react.d.ts +2 -2
  13. package/dist/client/react.js +25 -2
  14. package/dist/client/react.js.map +1 -1
  15. package/dist/client/react.mjs +26 -3
  16. package/dist/client/react.mjs.map +1 -1
  17. package/dist/client/vue.js.map +1 -1
  18. package/dist/client/vue.mjs.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/server/index.js.map +1 -1
  22. package/dist/server/index.mjs.map +1 -1
  23. package/dist/shared/index.js.map +1 -1
  24. package/dist/shared/index.mjs.map +1 -1
  25. package/package.json +185 -185
  26. package/src/adapters/agui-middleware.ts +382 -382
  27. package/src/bin/mcp-ts.ts +102 -102
  28. package/src/client/core/app-host.ts +417 -417
  29. package/src/client/core/sse-client.ts +371 -371
  30. package/src/client/core/types.ts +31 -31
  31. package/src/client/index.ts +27 -27
  32. package/src/client/react/index.ts +16 -16
  33. package/src/client/react/use-app-host.ts +73 -73
  34. package/src/client/react/use-mcp-apps.tsx +247 -214
  35. package/src/client/react/use-mcp.ts +641 -641
  36. package/src/client/vue/index.ts +10 -10
  37. package/src/client/vue/use-mcp.ts +617 -617
  38. package/src/index.ts +11 -11
  39. package/src/server/handlers/nextjs-handler.ts +204 -204
  40. package/src/server/handlers/sse-handler.ts +631 -631
  41. package/src/server/index.ts +57 -57
  42. package/src/server/mcp/multi-session-client.ts +228 -228
  43. package/src/server/mcp/oauth-client.ts +1188 -1188
  44. package/src/server/mcp/storage-oauth-provider.ts +272 -272
  45. package/src/server/storage/file-backend.ts +157 -157
  46. package/src/server/storage/index.ts +176 -176
  47. package/src/server/storage/memory-backend.ts +123 -123
  48. package/src/server/storage/redis-backend.ts +276 -276
  49. package/src/server/storage/redis.ts +160 -160
  50. package/src/server/storage/sqlite-backend.ts +182 -182
  51. package/src/server/storage/supabase-backend.ts +228 -228
  52. package/src/server/storage/types.ts +116 -116
  53. package/src/shared/constants.ts +29 -29
  54. package/src/shared/errors.ts +133 -133
  55. package/src/shared/event-routing.ts +28 -28
  56. package/src/shared/events.ts +180 -180
  57. package/src/shared/index.ts +75 -75
  58. package/src/shared/tool-utils.ts +61 -61
  59. package/src/shared/types.ts +282 -282
  60. package/src/shared/utils.ts +38 -38
  61. package/supabase/migrations/20260330195700_install_mcp_sessions.sql +84 -84
@@ -1,1188 +1,1188 @@
1
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
- import { nanoid } from 'nanoid';
3
- import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4
- import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
5
- import {
6
- UnauthorizedError as SDKUnauthorizedError,
7
- refreshAuthorization,
8
- discoverOAuthProtectedResourceMetadata,
9
- discoverAuthorizationServerMetadata,
10
- } from '@modelcontextprotocol/sdk/client/auth.js';
11
- import {
12
- ListToolsRequest,
13
- ListToolsResult,
14
- ListToolsResultSchema,
15
- CallToolRequest,
16
- CallToolResult,
17
- CallToolResultSchema,
18
- ListPromptsRequest,
19
- ListPromptsResult,
20
- ListPromptsResultSchema,
21
- GetPromptRequest,
22
- GetPromptResult,
23
- GetPromptResultSchema,
24
- ListResourcesRequest,
25
- ListResourcesResult,
26
- ListResourcesResultSchema,
27
- ReadResourceRequest,
28
- ReadResourceResult,
29
- ReadResourceResultSchema,
30
- } from '@modelcontextprotocol/sdk/types.js';
31
- import type { OAuthTokens, OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js';
32
- import { StorageOAuthClientProvider, type AgentsOAuthProvider } from './storage-oauth-provider.js';
33
- import { sanitizeServerLabel } from '../../shared/utils.js';
34
- import { Emitter, type McpConnectionEvent, type McpObservabilityEvent, type McpConnectionState } from '../../shared/events.js';
35
- import { UnauthorizedError } from '../../shared/errors.js';
36
- import { storage } from '../storage/index.js';
37
- import {
38
- MCP_CLIENT_NAME,
39
- MCP_CLIENT_VERSION,
40
- SESSION_TTL_SECONDS,
41
- STATE_EXPIRATION_MS,
42
- } from '../../shared/constants.js';
43
-
44
- /**
45
- * Supported MCP transport types
46
- */
47
- export type TransportType = 'sse' | 'streamable_http';
48
-
49
- /**
50
- * Extended capabilities including MCP App support
51
- */
52
- import type { ClientCapabilities } from '@modelcontextprotocol/sdk/types.js';
53
-
54
- interface McpAppClientCapabilities extends ClientCapabilities {
55
- extensions?: {
56
- 'io.modelcontextprotocol/ui'?: {
57
- mimeTypes: string[];
58
- };
59
- [key: string]: unknown;
60
- };
61
- }
62
-
63
- export interface MCPOAuthClientOptions {
64
- serverUrl?: string;
65
- serverName?: string;
66
- callbackUrl?: string;
67
- onRedirect?: (url: string) => void;
68
- identity: string;
69
- serverId?: string; /** Optional - loaded from session if not provided */
70
- sessionId: string; /** Required - primary key for session lookup */
71
- transportType?: TransportType;
72
- clientId?: string;
73
- clientSecret?: string;
74
- headers?: Record<string, string>;
75
- /** OAuth Client Metadata (optional - user application info) */
76
- clientName?: string;
77
- clientUri?: string;
78
- logoUri?: string;
79
- policyUri?: string;
80
- }
81
-
82
- /**
83
- * MCP Client with OAuth 2.1 authentication support
84
- * Manages connections to MCP servers with automatic token refresh and session restoration
85
- * Emits connection lifecycle events for observability
86
- */
87
- export class MCPClient {
88
- private client: Client | null = null;
89
- public oauthProvider: AgentsOAuthProvider | null = null;
90
- private transport: StreamableHTTPClientTransport | SSEClientTransport | null = null;
91
- private identity: string;
92
- private serverId?: string;
93
- private sessionId: string;
94
- private serverName?: string;
95
- private transportType: TransportType | undefined;
96
- private serverUrl: string | undefined;
97
- private callbackUrl: string | undefined;
98
- private onRedirect: ((url: string) => void) | undefined;
99
- private clientId?: string;
100
- private clientSecret?: string;
101
- private headers?: Record<string, string>;
102
- /** OAuth Client Metadata */
103
- private clientName?: string;
104
- private clientUri?: string;
105
- private logoUri?: string;
106
- private policyUri?: string;
107
- private createdAt?: number;
108
-
109
-
110
- /** Event emitters for connection lifecycle */
111
- private readonly _onConnectionEvent = new Emitter<McpConnectionEvent>();
112
- public readonly onConnectionEvent = this._onConnectionEvent.event;
113
-
114
- private readonly _onObservabilityEvent = new Emitter<McpObservabilityEvent>();
115
- public readonly onObservabilityEvent = this._onObservabilityEvent.event;
116
-
117
- private currentState: McpConnectionState = 'DISCONNECTED';
118
-
119
- /**
120
- * Creates a new MCP client instance
121
- * Can be initialized with minimal options (identity + sessionId) for session restoration
122
- * @param options - Client configuration options
123
- */
124
- constructor(options: MCPOAuthClientOptions) {
125
- this.serverUrl = options.serverUrl;
126
- this.serverName = options.serverName;
127
- this.callbackUrl = options.callbackUrl;
128
- this.onRedirect = options.onRedirect;
129
- this.identity = options.identity;
130
- this.serverId = options.serverId;
131
- this.sessionId = options.sessionId;
132
- this.transportType = options.transportType;
133
- this.clientId = options.clientId;
134
- this.clientSecret = options.clientSecret;
135
- this.headers = options.headers;
136
- this.clientName = options.clientName;
137
- this.clientUri = options.clientUri;
138
- this.logoUri = options.logoUri;
139
- this.policyUri = options.policyUri;
140
- }
141
-
142
- /**
143
- * Emit a connection state change event
144
- * @private
145
- */
146
- private emitStateChange(newState: McpConnectionState): void {
147
- const previousState = this.currentState;
148
- this.currentState = newState;
149
-
150
- if (!this.serverId) return;
151
-
152
- this._onConnectionEvent.fire({
153
- type: 'state_changed',
154
- sessionId: this.sessionId,
155
- serverId: this.serverId,
156
- serverName: this.serverName || this.serverId,
157
- serverUrl: this.serverUrl || '',
158
- createdAt: this.createdAt,
159
- state: newState,
160
- previousState,
161
- timestamp: Date.now(),
162
- });
163
-
164
- this._onObservabilityEvent.fire({
165
- type: 'mcp:client:state_change',
166
- level: 'info',
167
- message: `Connection state: ${previousState} → ${newState}`,
168
- displayMessage: `State changed to ${newState}`,
169
- sessionId: this.sessionId,
170
- serverId: this.serverId,
171
- payload: { previousState, newState },
172
- timestamp: Date.now(),
173
- id: nanoid(),
174
- });
175
- }
176
-
177
- /**
178
- * Emit an error event
179
- * @private
180
- */
181
- private emitError(error: string, errorType: 'connection' | 'auth' | 'validation' | 'unknown' = 'unknown'): void {
182
- if (!this.serverId) return;
183
-
184
- this._onConnectionEvent.fire({
185
- type: 'error',
186
- sessionId: this.sessionId,
187
- serverId: this.serverId,
188
- error,
189
- errorType,
190
- timestamp: Date.now(),
191
- });
192
-
193
- this._onObservabilityEvent.fire({
194
- type: 'mcp:client:error',
195
- level: 'error',
196
- message: error,
197
- displayMessage: error,
198
- sessionId: this.sessionId,
199
- serverId: this.serverId,
200
- payload: { errorType, error },
201
- timestamp: Date.now(),
202
- id: nanoid(),
203
- });
204
- }
205
-
206
- /**
207
- * Emit a progress event
208
- * @private
209
- */
210
- private emitProgress(message: string): void {
211
- if (!this.serverId) return;
212
-
213
- this._onConnectionEvent.fire({
214
- type: 'progress',
215
- sessionId: this.sessionId,
216
- serverId: this.serverId,
217
- message,
218
- timestamp: Date.now(),
219
- });
220
- }
221
-
222
- /**
223
- * Get current connection state
224
- */
225
- getConnectionState(): McpConnectionState {
226
- return this.currentState;
227
- }
228
-
229
- /**
230
- * Helper to create a transport instance
231
- * @param type - The transport type to create
232
- * @returns Configured transport instance
233
- * @private
234
- */
235
- private getTransport(type: TransportType): StreamableHTTPClientTransport | SSEClientTransport {
236
- if (!this.serverUrl) {
237
- throw new Error('Server URL is required to create transport');
238
- }
239
-
240
- const baseUrl = new URL(this.serverUrl);
241
- const transportOptions = {
242
- authProvider: this.oauthProvider!,
243
- ...(this.headers && { headers: this.headers }),
244
- /**
245
- * Custom fetch implementation to handle connection timeouts.
246
- * Observation: SDK 1.24.0+ connections may hang indefinitely in some environments.
247
- * This wrapper enforces a timeout and properly uses AbortController to unblock the request.
248
- */
249
- fetch: (url: RequestInfo | URL, init?: RequestInit) => {
250
- const timeout = 30000;
251
- const controller = new AbortController();
252
- const timeoutId = setTimeout(() => controller.abort(), timeout);
253
- const signal = init?.signal ?
254
- // @ts-ignore: AbortSignal.any is available in Node 20+
255
- (AbortSignal.any ? AbortSignal.any([init.signal, controller.signal]) : controller.signal) :
256
- controller.signal;
257
-
258
- return fetch(url, { ...init, signal }).finally(() => clearTimeout(timeoutId));
259
- }
260
- };
261
-
262
- if (type === 'sse') {
263
- return new SSEClientTransport(baseUrl, transportOptions);
264
- } else {
265
- return new StreamableHTTPClientTransport(baseUrl, transportOptions);
266
- }
267
- }
268
-
269
- /**
270
- * Initializes client components (client, transport, OAuth provider)
271
- * Loads missing configuration from Redis session store if needed
272
- * This method is idempotent and safe to call multiple times
273
- * @private
274
- */
275
- private async initialize(): Promise<void> {
276
- if (this.client && this.oauthProvider) {
277
- return;
278
- }
279
-
280
- this.emitStateChange('INITIALIZING');
281
- this.emitProgress('Loading session configuration...');
282
-
283
- if (!this.serverUrl || !this.callbackUrl || !this.serverId) {
284
- const sessionData = await storage.getSession(this.identity, this.sessionId);
285
- if (!sessionData) {
286
- throw new Error(`Session not found: ${this.sessionId}`);
287
- }
288
-
289
- this.serverUrl = this.serverUrl || sessionData.serverUrl;
290
- this.callbackUrl = this.callbackUrl || sessionData.callbackUrl;
291
- /**
292
- * Do NOT load transportType from session if not explicitly provided.
293
- * We want to re-negotiate (try streamable -> sse) on new connections if in "Auto" mode.
294
- * this.transportType = this.transportType || sessionData.transportType;
295
- */
296
- this.serverName = this.serverName || sessionData.serverName;
297
- this.serverId = this.serverId || sessionData.serverId || 'unknown';
298
- this.headers = this.headers || sessionData.headers;
299
- this.createdAt = sessionData.createdAt;
300
- }
301
-
302
- if (!this.serverUrl || !this.callbackUrl || !this.serverId) {
303
- throw new Error('Missing required connection metadata');
304
- }
305
-
306
- if (!this.oauthProvider) {
307
- if (!this.serverId) {
308
- throw new Error('serverId required for OAuth provider initialization');
309
- }
310
- this.oauthProvider = new StorageOAuthClientProvider({
311
- identity: this.identity,
312
- serverId: this.serverId,
313
- sessionId: this.sessionId,
314
- redirectUrl: this.callbackUrl!,
315
- clientName: this.clientName,
316
- clientUri: this.clientUri,
317
- logoUri: this.logoUri,
318
- policyUri: this.policyUri,
319
- clientId: this.clientId,
320
- clientSecret: this.clientSecret,
321
- onRedirect: (redirectUrl: string) => {
322
- if (this.onRedirect) {
323
- this.onRedirect(redirectUrl);
324
- }
325
- }
326
- });
327
- }
328
-
329
- if (!this.client) {
330
- this.client = new Client(
331
- {
332
- name: MCP_CLIENT_NAME,
333
- version: MCP_CLIENT_VERSION,
334
- },
335
- {
336
- capabilities: {
337
- extensions: {
338
- 'io.modelcontextprotocol/ui': {
339
- mimeTypes: ['text/html+mcp'],
340
- },
341
- },
342
- } as McpAppClientCapabilities
343
- }
344
- );
345
- }
346
-
347
- // Create session in storage if it doesn't exist yet
348
- // This is needed BEFORE OAuth flow starts because the OAuth provider
349
- // will call saveCodeVerifier() which requires the session to exist
350
- const existingSession = await storage.getSession(this.identity, this.sessionId);
351
- if (!existingSession && this.serverId && this.serverUrl && this.callbackUrl) {
352
- this.createdAt = Date.now();
353
- console.log(`[MCPClient] Creating initial session ${this.sessionId} for OAuth flow`);
354
- await storage.createSession({
355
- sessionId: this.sessionId,
356
- identity: this.identity,
357
- serverId: this.serverId,
358
- serverName: this.serverName,
359
- serverUrl: this.serverUrl,
360
- callbackUrl: this.callbackUrl,
361
- transportType: this.transportType || 'streamable_http',
362
- createdAt: this.createdAt,
363
- active: false,
364
- }, Math.floor(STATE_EXPIRATION_MS / 1000)); // Short TTL until connection succeeds
365
- }
366
- }
367
-
368
- /**
369
- * Saves current session state to storage
370
- * Creates new session if it doesn't exist, updates if it does
371
- * @param ttl - Time-to-live in seconds (defaults to 12hr for connected sessions)
372
- * @param active - Session status marker used to avoid unnecessary TTL rewrites
373
- * @private
374
- */
375
- private async saveSession(
376
- ttl: number = SESSION_TTL_SECONDS,
377
- active: boolean = true
378
- ): Promise<void> {
379
- if (!this.sessionId || !this.serverId || !this.serverUrl || !this.callbackUrl) {
380
- return;
381
- }
382
-
383
- const sessionData = {
384
- sessionId: this.sessionId,
385
- identity: this.identity,
386
- serverId: this.serverId,
387
- serverName: this.serverName,
388
- serverUrl: this.serverUrl,
389
- callbackUrl: this.callbackUrl,
390
- transportType: (this.transportType || 'streamable_http') as TransportType,
391
- createdAt: this.createdAt || Date.now(),
392
- active,
393
- };
394
-
395
- // Try to update first, create if doesn't exist
396
- const existingSession = await storage.getSession(this.identity, this.sessionId);
397
- if (existingSession) {
398
- await storage.updateSession(this.identity, this.sessionId, sessionData, ttl);
399
- } else {
400
- await storage.createSession(sessionData, ttl);
401
- }
402
- }
403
-
404
- /**
405
- * Try to connect using available transports
406
- * @returns The corrected transport type object if successful
407
- * @private
408
- */
409
- private async tryConnect(): Promise<{ transportType: TransportType }> {
410
- /**
411
- * If exact transport type is known, only try that.
412
- * Otherwise (auto mode), try streamable_http first, then sse.
413
- */
414
- const transportsToTry: TransportType[] = this.transportType
415
- ? [this.transportType]
416
- : ['streamable_http', 'sse'];
417
-
418
- let lastError: unknown;
419
-
420
- for (const currentType of transportsToTry) {
421
- const isLastAttempt = currentType === transportsToTry[transportsToTry.length - 1];
422
-
423
- try {
424
- const transport = this.getTransport(currentType);
425
-
426
- /** Update local state with the transport we are about to try */
427
- this.transport = transport;
428
-
429
- /** Race connection against timeout */
430
- await this.client!.connect(transport);
431
-
432
- /** Success! Return the type that worked */
433
- return { transportType: currentType };
434
-
435
- } catch (error: any) {
436
- lastError = error;
437
-
438
- /** Check for Auth Errors - these should fail immediately, no fallback */
439
- const isAuthError = error instanceof SDKUnauthorizedError ||
440
- (error instanceof Error && error.message.toLowerCase().includes('unauthorized'));
441
-
442
- if (isAuthError) {
443
- throw error;
444
- }
445
-
446
- /** If this was the last transport to try, throw the error */
447
- if (isLastAttempt) {
448
- throw error;
449
- }
450
-
451
- /** Otherwise, log and continue to next transport */
452
- const errorMessage = error instanceof Error ? error.message : String(error);
453
- this.emitProgress(`Connection attempt with ${currentType} failed: ${errorMessage}. Retrying...`);
454
- this._onObservabilityEvent.fire({
455
- level: 'warn',
456
- message: `Transport ${currentType} failed, falling back`,
457
- sessionId: this.sessionId,
458
- serverId: this.serverId,
459
- metadata: {
460
- failedTransport: currentType,
461
- error: errorMessage
462
- },
463
- timestamp: Date.now(),
464
- });
465
- }
466
- }
467
-
468
- throw lastError || new Error('No transports available');
469
- }
470
-
471
- /**
472
- * Connects to the MCP server
473
- * Automatically validates and refreshes OAuth tokens if needed
474
- * Saves session to Redis on first successful connection
475
- * @throws {UnauthorizedError} When OAuth authorization is required
476
- * @throws {Error} When connection fails for other reasons
477
- */
478
- async connect(): Promise<void> {
479
- await this.initialize();
480
-
481
- if (!this.client || !this.oauthProvider) {
482
- const error = 'Client or OAuth provider not initialized';
483
- this.emitError(error, 'connection');
484
- this.emitStateChange('FAILED');
485
- throw new Error(error);
486
- }
487
-
488
- try {
489
- this.emitProgress('Validating OAuth tokens...');
490
- await this.getValidTokens();
491
-
492
- this.emitStateChange('CONNECTING');
493
-
494
- /** Use the tryConnect loop to handle transport fallbacks */
495
- const { transportType } = await this.tryConnect();
496
-
497
- /** Update transport type to the one that actually worked */
498
- this.transportType = transportType;
499
-
500
- this.emitStateChange('CONNECTED');
501
- this.emitProgress('Connected successfully');
502
-
503
- // Promote short-lived OAuth-pending session TTL to long-lived active TTL once.
504
- // Also persist when transport negotiation changed the effective transport.
505
- const existingSession = await storage.getSession(this.identity, this.sessionId);
506
- const needsTransportUpdate = !existingSession || existingSession.transportType !== this.transportType;
507
- const needsTtlPromotion = !existingSession || existingSession.active !== true;
508
-
509
- if (needsTransportUpdate || needsTtlPromotion) {
510
- console.log(`[MCPClient] Saving session ${this.sessionId} with 12hr TTL (connect success)`);
511
- await this.saveSession(SESSION_TTL_SECONDS, true);
512
- }
513
- } catch (error) {
514
- /** Handle Authentication Errors */
515
- if (
516
- error instanceof SDKUnauthorizedError ||
517
- (error instanceof Error && error.message.toLowerCase().includes('unauthorized'))
518
- ) {
519
- this.emitStateChange('AUTHENTICATING');
520
- // Save session with 10min TTL for OAuth pending state
521
- console.log(`[MCPClient] Saving session ${this.sessionId} with 10min TTL (OAuth pending)`);
522
- await this.saveSession(Math.floor(STATE_EXPIRATION_MS / 1000), false);
523
-
524
- /** Get OAuth authorization URL if available */
525
- let authUrl = '';
526
- if (this.oauthProvider) {
527
- authUrl = this.oauthProvider.authUrl || '';
528
- }
529
-
530
- if (this.serverId) {
531
- this._onConnectionEvent.fire({
532
- type: 'auth_required',
533
- sessionId: this.sessionId,
534
- serverId: this.serverId,
535
- authUrl,
536
- timestamp: Date.now(),
537
- });
538
-
539
- if (authUrl && this.onRedirect) {
540
- this.onRedirect(authUrl);
541
- }
542
- }
543
-
544
- throw new UnauthorizedError('OAuth authorization required');
545
- }
546
-
547
- /** Handle Generic Errors */
548
- const errorMessage = error instanceof Error ? error.message : 'Connection failed';
549
- this.emitError(errorMessage, 'connection');
550
- this.emitStateChange('FAILED');
551
- throw error;
552
- }
553
- }
554
-
555
- /**
556
- * Completes OAuth authorization flow by exchanging authorization code for tokens
557
- * Creates new authenticated client and transport, then establishes connection
558
- * Saves active session to Redis after successful authentication
559
- * @param authCode - Authorization code received from OAuth callback
560
- */
561
-
562
- // TODO: needs to be optimized
563
- async finishAuth(authCode: string): Promise<void> {
564
- this.emitStateChange('AUTHENTICATING');
565
- this.emitProgress('Exchanging authorization code for tokens...');
566
-
567
- await this.initialize();
568
-
569
- if (!this.oauthProvider) {
570
- const error = 'OAuth provider not initialized';
571
- this.emitError(error, 'auth');
572
- this.emitStateChange('FAILED');
573
- throw new Error(error);
574
- }
575
-
576
- /**
577
- * Determine which transports to try for finishing auth
578
- * If transportType is set, use only that. Otherwise try streamable_http then sse.
579
- */
580
- const transportsToTry: TransportType[] = this.transportType
581
- ? [this.transportType]
582
- : ['streamable_http', 'sse'];
583
-
584
- let lastError: unknown;
585
- let tokensExchanged = false;
586
-
587
- for (const currentType of transportsToTry) {
588
- const isLastAttempt = currentType === transportsToTry[transportsToTry.length - 1];
589
-
590
- try {
591
- const transport = this.getTransport(currentType);
592
-
593
- /** Update local state with the transport we are about to try */
594
- this.transport = transport;
595
-
596
- if (!tokensExchanged) {
597
- await transport.finishAuth(authCode);
598
- tokensExchanged = true;
599
- } else {
600
- this.emitProgress(`Tokens already exchanged, skipping auth step for ${currentType}...`);
601
- }
602
-
603
- /** Success! Update transport type */
604
- this.transportType = currentType;
605
-
606
- this.emitStateChange('AUTHENTICATED');
607
- this.emitProgress('Creating authenticated client...');
608
-
609
- this.client = new Client(
610
- {
611
- name: MCP_CLIENT_NAME,
612
- version: MCP_CLIENT_VERSION,
613
- },
614
- {
615
- capabilities: {
616
- extensions: {
617
- 'io.modelcontextprotocol/ui': {
618
- mimeTypes: ['text/html+mcp'],
619
- },
620
- },
621
- } as McpAppClientCapabilities
622
- }
623
- );
624
-
625
- this.emitStateChange('CONNECTING');
626
-
627
- /** We explicitly try to connect with the transport we just auth'd with first */
628
- await this.client.connect(this.transport);
629
-
630
- this.emitStateChange('CONNECTED');
631
- // Update session with 12hr TTL after successful OAuth
632
- console.log(`[MCPClient] Updating session ${this.sessionId} to 12hr TTL (OAuth complete)`);
633
- await this.saveSession(SESSION_TTL_SECONDS, true);
634
-
635
- return; // Success, exit function
636
-
637
- } catch (error) {
638
- lastError = error;
639
-
640
- const isAuthError = error instanceof SDKUnauthorizedError ||
641
- (error instanceof Error && error.message.toLowerCase().includes('unauthorized'));
642
-
643
- if (isAuthError) {
644
- throw error;
645
- }
646
-
647
- const errorMessage = error instanceof Error ? error.message : String(error);
648
-
649
- // Don't retry if the authorization code was rejected (it's one-time use)
650
- if (!tokensExchanged && errorMessage.toLowerCase().includes('invalid authorization code')) {
651
- const msg = error instanceof Error ? error.message : 'Authentication failed';
652
- this.emitError(msg, 'auth');
653
- this.emitStateChange('FAILED');
654
- throw error;
655
- }
656
-
657
- if (isLastAttempt) {
658
- const msg = error instanceof Error ? error.message : 'Authentication failed';
659
- this.emitError(msg, 'auth');
660
- this.emitStateChange('FAILED');
661
- throw error;
662
- }
663
-
664
- // Log and retry
665
- this.emitProgress(`Auth attempt with ${currentType} failed: ${errorMessage}. Retrying...`);
666
- }
667
- }
668
-
669
- if (lastError) {
670
- const errorMessage = lastError instanceof Error ? lastError.message : 'Authentication failed';
671
- this.emitError(errorMessage, 'auth');
672
- this.emitStateChange('FAILED');
673
- throw lastError;
674
- }
675
- }
676
-
677
- /**
678
- * Lists all available tools from the connected MCP server
679
- * @returns List of tools with their schemas and descriptions
680
- * @throws {Error} When client is not connected
681
- */
682
- async listTools(): Promise<ListToolsResult> {
683
- if (!this.client) {
684
- throw new Error('Not connected to server');
685
- }
686
-
687
- this.emitStateChange('DISCOVERING');
688
-
689
- try {
690
- const request: ListToolsRequest = {
691
- method: 'tools/list',
692
- params: {},
693
- };
694
-
695
- const result = await this.client.request(request, ListToolsResultSchema);
696
-
697
- if (this.serverId) {
698
- this._onConnectionEvent.fire({
699
- type: 'tools_discovered',
700
- sessionId: this.sessionId,
701
- serverId: this.serverId,
702
- toolCount: result.tools.length,
703
- tools: result.tools,
704
- timestamp: Date.now(),
705
- });
706
- }
707
-
708
- this.emitStateChange('READY');
709
- this.emitProgress(`Discovered ${result.tools.length} tools`);
710
-
711
- return result;
712
- } catch (error) {
713
- const errorMessage = error instanceof Error ? error.message : 'Failed to list tools';
714
- this.emitError(errorMessage, 'validation');
715
- this.emitStateChange('FAILED');
716
- throw error;
717
- }
718
- }
719
-
720
- /**
721
- * Executes a tool on the connected MCP server
722
- * @param toolName - Name of the tool to execute
723
- * @param toolArgs - Arguments to pass to the tool
724
- * @returns Tool execution result
725
- * @throws {Error} When client is not connected
726
- */
727
- async callTool(toolName: string, toolArgs: Record<string, unknown>): Promise<CallToolResult> {
728
- if (!this.client) {
729
- throw new Error('Not connected to server');
730
- }
731
-
732
- const request: CallToolRequest = {
733
- method: 'tools/call',
734
- params: {
735
- name: toolName,
736
- arguments: toolArgs,
737
- },
738
- };
739
-
740
- try {
741
- const result = await this.client.request(request, CallToolResultSchema);
742
-
743
- this._onObservabilityEvent.fire({
744
- type: 'mcp:client:tool_call',
745
- level: 'info',
746
- message: `Tool ${toolName} called successfully`,
747
- displayMessage: `Called tool ${toolName}`,
748
- sessionId: this.sessionId,
749
- serverId: this.serverId,
750
- payload: {
751
- toolName,
752
- args: toolArgs,
753
- },
754
- timestamp: Date.now(),
755
- id: nanoid(),
756
- });
757
-
758
- return result;
759
- } catch (error) {
760
- const errorMessage = error instanceof Error ? error.message : `Failed to call tool ${toolName}`;
761
-
762
- this._onObservabilityEvent.fire({
763
- type: 'mcp:client:error',
764
- level: 'error',
765
- message: errorMessage,
766
- displayMessage: `Failed to call tool ${toolName}`,
767
- sessionId: this.sessionId,
768
- serverId: this.serverId,
769
- payload: {
770
- errorType: 'tool_execution',
771
- error: errorMessage,
772
- toolName,
773
- args: toolArgs,
774
- },
775
- timestamp: Date.now(),
776
- id: nanoid(),
777
- });
778
-
779
- throw error;
780
- }
781
- }
782
-
783
- /**
784
- * Lists all available prompts from the connected MCP server
785
- * @returns List of available prompts
786
- * @throws {Error} When client is not connected
787
- */
788
- async listPrompts(): Promise<ListPromptsResult> {
789
- if (!this.client) {
790
- throw new Error('Not connected to server');
791
- }
792
-
793
- this.emitStateChange('DISCOVERING');
794
-
795
- try {
796
- const request: ListPromptsRequest = {
797
- method: 'prompts/list',
798
- params: {},
799
- };
800
-
801
- const result = await this.client.request(request, ListPromptsResultSchema);
802
-
803
- this.emitStateChange('READY');
804
- this.emitProgress(`Discovered ${result.prompts.length} prompts`);
805
-
806
- return result;
807
- } catch (error) {
808
- const errorMessage = error instanceof Error ? error.message : 'Failed to list prompts';
809
- this.emitError(errorMessage, 'validation');
810
- this.emitStateChange('FAILED');
811
- throw error;
812
- }
813
- }
814
-
815
- /**
816
- * Gets a specific prompt with arguments
817
- * @param name - Name of the prompt
818
- * @param args - Arguments for the prompt
819
- * @returns Prompt content
820
- * @throws {Error} When client is not connected
821
- */
822
- async getPrompt(name: string, args?: Record<string, string>): Promise<GetPromptResult> {
823
- if (!this.client) {
824
- throw new Error('Not connected to server');
825
- }
826
-
827
- const request: GetPromptRequest = {
828
- method: 'prompts/get',
829
- params: {
830
- name,
831
- arguments: args,
832
- },
833
- };
834
-
835
- return await this.client.request(request, GetPromptResultSchema);
836
- }
837
-
838
- /**
839
- * Lists all available resources from the connected MCP server
840
- * @returns List of available resources
841
- * @throws {Error} When client is not connected
842
- */
843
- async listResources(): Promise<ListResourcesResult> {
844
- if (!this.client) {
845
- throw new Error('Not connected to server');
846
- }
847
-
848
- this.emitStateChange('DISCOVERING');
849
-
850
- try {
851
- const request: ListResourcesRequest = {
852
- method: 'resources/list',
853
- params: {},
854
- };
855
-
856
- const result = await this.client.request(request, ListResourcesResultSchema);
857
-
858
- this.emitStateChange('READY');
859
- this.emitProgress(`Discovered ${result.resources.length} resources`);
860
-
861
- return result;
862
- } catch (error) {
863
- const errorMessage = error instanceof Error ? error.message : 'Failed to list resources';
864
- this.emitError(errorMessage, 'validation');
865
- this.emitStateChange('FAILED');
866
- throw error;
867
- }
868
- }
869
-
870
- /**
871
- * Reads a specific resource
872
- * @param uri - URI of the resource to read
873
- * @returns Resource content
874
- * @throws {Error} When client is not connected
875
- */
876
- async readResource(uri: string): Promise<ReadResourceResult> {
877
- if (!this.client) {
878
- throw new Error('Not connected to server');
879
- }
880
-
881
- const request: ReadResourceRequest = {
882
- method: 'resources/read',
883
- params: {
884
- uri,
885
- },
886
- };
887
-
888
- return await this.client.request(request, ReadResourceResultSchema);
889
- }
890
-
891
- /**
892
- * Refreshes the OAuth access token using the refresh token
893
- * Discovers OAuth metadata from server and exchanges refresh token for new access token
894
- * @returns True if refresh was successful, false otherwise
895
- */
896
- async refreshToken(): Promise<boolean> {
897
- await this.initialize();
898
-
899
- if (!this.oauthProvider) {
900
- return false;
901
- }
902
-
903
- const tokens = await this.oauthProvider.tokens();
904
- if (!tokens || !tokens.refresh_token) {
905
- return false;
906
- }
907
-
908
- const clientInformation = await this.oauthProvider.clientInformation();
909
- if (!clientInformation) {
910
- return false;
911
- }
912
-
913
- try {
914
- const resourceMetadata = await discoverOAuthProtectedResourceMetadata(this.serverUrl!);
915
- const authServerUrl = resourceMetadata?.authorization_servers?.[0] || this.serverUrl!;
916
- const authMetadata = await discoverAuthorizationServerMetadata(authServerUrl);
917
-
918
- const newTokens = await refreshAuthorization(authServerUrl, {
919
- metadata: authMetadata,
920
- clientInformation,
921
- refreshToken: tokens.refresh_token,
922
- });
923
-
924
- await this.oauthProvider.saveTokens(newTokens);
925
- return true;
926
- } catch (error) {
927
- console.error('[OAuth] Token refresh failed:', error);
928
- return false;
929
- }
930
- }
931
-
932
- /**
933
- * Ensures OAuth tokens are valid, refreshing them if expired
934
- * Called automatically by connect() - rarely needs to be called manually
935
- * @returns True if valid tokens are available, false otherwise
936
- */
937
- async getValidTokens(): Promise<boolean> {
938
- await this.initialize();
939
-
940
- if (!this.oauthProvider) {
941
- return false;
942
- }
943
-
944
- const tokens = await this.oauthProvider.tokens();
945
- if (!tokens) {
946
- return false;
947
- }
948
-
949
- if (this.oauthProvider.isTokenExpired()) {
950
- return await this.refreshToken();
951
- }
952
-
953
- return true;
954
- }
955
-
956
- /**
957
- * Reconnects to MCP server using existing OAuth provider from Redis
958
- * Used for session restoration in serverless environments
959
- * Creates new client and transport without re-initializing OAuth provider
960
- * @throws {Error} When OAuth provider is not initialized
961
- */
962
- async reconnect(): Promise<void> {
963
- await this.initialize();
964
-
965
- if (!this.oauthProvider) {
966
- throw new Error('OAuth provider not initialized');
967
- }
968
-
969
- this.client = new Client(
970
- {
971
- name: MCP_CLIENT_NAME,
972
- version: MCP_CLIENT_VERSION,
973
- },
974
- { capabilities: {} }
975
- );
976
-
977
- // Use default logic to get transport, defaulting to what's stored or auto
978
- const tt = this.transportType || 'streamable_http';
979
- this.transport = this.getTransport(tt);
980
-
981
- await this.client.connect(this.transport);
982
- }
983
-
984
- /**
985
- * Completely removes the session from Redis including all OAuth data
986
- * Invalidates credentials and disconnects the client
987
- */
988
- async clearSession(): Promise<void> {
989
- try {
990
- await this.initialize();
991
- } catch (error) {
992
- console.warn('[MCPClient] Initialization failed during clearSession:', error);
993
- }
994
-
995
- if (this.oauthProvider) {
996
- await (this.oauthProvider as any).invalidateCredentials('all');
997
- }
998
-
999
- await storage.removeSession(this.identity, this.sessionId);
1000
- this.disconnect();
1001
- }
1002
-
1003
- /**
1004
- * Checks if the client is currently connected to an MCP server
1005
- * @returns True if connected, false otherwise
1006
- */
1007
- isConnected(): boolean {
1008
- return this.client !== null;
1009
- }
1010
-
1011
- /**
1012
- * Disconnects from the MCP server and cleans up resources
1013
- * Does not remove session from Redis - use clearSession() for that
1014
- */
1015
- disconnect(reason?: string): void {
1016
- if (this.client) {
1017
- this.client.close();
1018
- }
1019
- this.client = null;
1020
- this.oauthProvider = null;
1021
- this.transport = null;
1022
-
1023
- // Emit disconnected event
1024
- if (this.serverId) {
1025
- this._onConnectionEvent.fire({
1026
- type: 'disconnected',
1027
- sessionId: this.sessionId,
1028
- serverId: this.serverId,
1029
- reason,
1030
- timestamp: Date.now(),
1031
- });
1032
-
1033
- this._onObservabilityEvent.fire({
1034
- type: 'mcp:client:disconnect',
1035
- level: 'info',
1036
- message: `Disconnected from ${this.serverId}`,
1037
- sessionId: this.sessionId,
1038
- serverId: this.serverId,
1039
- payload: {
1040
- reason: reason || 'unknown',
1041
- },
1042
- timestamp: Date.now(),
1043
- id: nanoid(),
1044
- });
1045
- }
1046
-
1047
- this.emitStateChange('DISCONNECTED');
1048
- }
1049
-
1050
- /**
1051
- * Dispose of all event emitters
1052
- * Call this when the client is no longer needed
1053
- */
1054
- dispose(): void {
1055
- this._onConnectionEvent.dispose();
1056
- this._onObservabilityEvent.dispose();
1057
- }
1058
-
1059
- /**
1060
- * Gets the server URL
1061
- * @returns Server URL or empty string if not set
1062
- */
1063
- getServerUrl(): string {
1064
- return this.serverUrl || '';
1065
- }
1066
-
1067
- /**
1068
- * Gets the OAuth callback URL
1069
- * @returns Callback URL or empty string if not set
1070
- */
1071
- getCallbackUrl(): string {
1072
- return this.callbackUrl || '';
1073
- }
1074
-
1075
- /**
1076
- * Gets the transport type being used
1077
- * @returns Transport type (defaults to 'streamable_http')
1078
- */
1079
- getTransportType(): TransportType {
1080
- return this.transportType || 'streamable_http';
1081
- }
1082
-
1083
- /**
1084
- * Gets the human-readable server name
1085
- * @returns Server name or undefined
1086
- */
1087
- getServerName(): string | undefined {
1088
- return this.serverName;
1089
- }
1090
-
1091
- /**
1092
- * Gets the server ID
1093
- * @returns Server ID or undefined
1094
- */
1095
- getServerId(): string | undefined {
1096
- return this.serverId;
1097
- }
1098
-
1099
- /**
1100
- * Gets the session ID
1101
- * @returns Session ID
1102
- */
1103
- getSessionId(): string {
1104
- return this.sessionId;
1105
- }
1106
-
1107
- /**
1108
- * Gets MCP server configuration for all active user sessions
1109
- * Loads sessions from Redis, validates OAuth tokens, refreshes if expired
1110
- * Returns ready-to-use configuration with valid auth headers
1111
- * @param identity - User ID to fetch sessions for
1112
- * @returns Object keyed by sanitized server labels containing transport, url, headers, etc.
1113
- * @static
1114
- */
1115
- static async getMcpServerConfig(identity: string): Promise<Record<string, any>> {
1116
- const mcpConfig: Record<string, any> = {};
1117
- const sessions = await storage.getIdentitySessionsData(identity);
1118
-
1119
- await Promise.all(
1120
- sessions.map(async (sessionData) => {
1121
- const { sessionId } = sessionData;
1122
-
1123
- try {
1124
- // Validate session - remove if missing required fields
1125
- if (
1126
- !sessionData.serverId ||
1127
- !sessionData.transportType ||
1128
- !sessionData.serverUrl ||
1129
- !sessionData.callbackUrl
1130
- ) {
1131
- await storage.removeSession(identity, sessionId);
1132
- return;
1133
- }
1134
-
1135
- // Get OAuth headers if session requires authentication
1136
- let headers: Record<string, string> | undefined;
1137
- try {
1138
- // Inject existing session data to avoid redundant storage reads in initialize()
1139
- const client = new MCPClient({
1140
- identity,
1141
- sessionId,
1142
- serverId: sessionData.serverId,
1143
- serverUrl: sessionData.serverUrl,
1144
- callbackUrl: sessionData.callbackUrl,
1145
- serverName: sessionData.serverName,
1146
- transportType: sessionData.transportType,
1147
- headers: sessionData.headers,
1148
- });
1149
-
1150
- await client.initialize();
1151
-
1152
- const hasValidTokens = await client.getValidTokens();
1153
- if (hasValidTokens && client.oauthProvider) {
1154
- const tokens = await client.oauthProvider.tokens();
1155
- if (tokens?.access_token) {
1156
- headers = { Authorization: `Bearer ${tokens.access_token}` };
1157
- }
1158
- }
1159
- } catch (error) {
1160
- console.warn(`[MCP] Failed to get OAuth tokens for ${sessionId}:`, error);
1161
- }
1162
-
1163
- // Build server config
1164
- const label = sanitizeServerLabel(
1165
- sessionData.serverName || sessionData.serverId || 'server'
1166
- );
1167
-
1168
- mcpConfig[label] = {
1169
- transport: sessionData.transportType,
1170
- url: sessionData.serverUrl,
1171
- ...(sessionData.serverName && {
1172
- serverName: sessionData.serverName,
1173
- serverLabel: label,
1174
- }),
1175
- ...(headers && { headers }),
1176
- };
1177
- } catch (error) {
1178
- await storage.removeSession(identity, sessionId);
1179
- console.warn(`[MCP] Failed to process session ${sessionId}:`, error);
1180
- }
1181
- })
1182
- );
1183
-
1184
- return mcpConfig;
1185
- }
1186
-
1187
- }
1188
-
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { nanoid } from 'nanoid';
3
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
5
+ import {
6
+ UnauthorizedError as SDKUnauthorizedError,
7
+ refreshAuthorization,
8
+ discoverOAuthProtectedResourceMetadata,
9
+ discoverAuthorizationServerMetadata,
10
+ } from '@modelcontextprotocol/sdk/client/auth.js';
11
+ import {
12
+ ListToolsRequest,
13
+ ListToolsResult,
14
+ ListToolsResultSchema,
15
+ CallToolRequest,
16
+ CallToolResult,
17
+ CallToolResultSchema,
18
+ ListPromptsRequest,
19
+ ListPromptsResult,
20
+ ListPromptsResultSchema,
21
+ GetPromptRequest,
22
+ GetPromptResult,
23
+ GetPromptResultSchema,
24
+ ListResourcesRequest,
25
+ ListResourcesResult,
26
+ ListResourcesResultSchema,
27
+ ReadResourceRequest,
28
+ ReadResourceResult,
29
+ ReadResourceResultSchema,
30
+ } from '@modelcontextprotocol/sdk/types.js';
31
+ import type { OAuthTokens, OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js';
32
+ import { StorageOAuthClientProvider, type AgentsOAuthProvider } from './storage-oauth-provider.js';
33
+ import { sanitizeServerLabel } from '../../shared/utils.js';
34
+ import { Emitter, type McpConnectionEvent, type McpObservabilityEvent, type McpConnectionState } from '../../shared/events.js';
35
+ import { UnauthorizedError } from '../../shared/errors.js';
36
+ import { storage } from '../storage/index.js';
37
+ import {
38
+ MCP_CLIENT_NAME,
39
+ MCP_CLIENT_VERSION,
40
+ SESSION_TTL_SECONDS,
41
+ STATE_EXPIRATION_MS,
42
+ } from '../../shared/constants.js';
43
+
44
+ /**
45
+ * Supported MCP transport types
46
+ */
47
+ export type TransportType = 'sse' | 'streamable_http';
48
+
49
+ /**
50
+ * Extended capabilities including MCP App support
51
+ */
52
+ import type { ClientCapabilities } from '@modelcontextprotocol/sdk/types.js';
53
+
54
+ interface McpAppClientCapabilities extends ClientCapabilities {
55
+ extensions?: {
56
+ 'io.modelcontextprotocol/ui'?: {
57
+ mimeTypes: string[];
58
+ };
59
+ [key: string]: unknown;
60
+ };
61
+ }
62
+
63
+ export interface MCPOAuthClientOptions {
64
+ serverUrl?: string;
65
+ serverName?: string;
66
+ callbackUrl?: string;
67
+ onRedirect?: (url: string) => void;
68
+ identity: string;
69
+ serverId?: string; /** Optional - loaded from session if not provided */
70
+ sessionId: string; /** Required - primary key for session lookup */
71
+ transportType?: TransportType;
72
+ clientId?: string;
73
+ clientSecret?: string;
74
+ headers?: Record<string, string>;
75
+ /** OAuth Client Metadata (optional - user application info) */
76
+ clientName?: string;
77
+ clientUri?: string;
78
+ logoUri?: string;
79
+ policyUri?: string;
80
+ }
81
+
82
+ /**
83
+ * MCP Client with OAuth 2.1 authentication support
84
+ * Manages connections to MCP servers with automatic token refresh and session restoration
85
+ * Emits connection lifecycle events for observability
86
+ */
87
+ export class MCPClient {
88
+ private client: Client | null = null;
89
+ public oauthProvider: AgentsOAuthProvider | null = null;
90
+ private transport: StreamableHTTPClientTransport | SSEClientTransport | null = null;
91
+ private identity: string;
92
+ private serverId?: string;
93
+ private sessionId: string;
94
+ private serverName?: string;
95
+ private transportType: TransportType | undefined;
96
+ private serverUrl: string | undefined;
97
+ private callbackUrl: string | undefined;
98
+ private onRedirect: ((url: string) => void) | undefined;
99
+ private clientId?: string;
100
+ private clientSecret?: string;
101
+ private headers?: Record<string, string>;
102
+ /** OAuth Client Metadata */
103
+ private clientName?: string;
104
+ private clientUri?: string;
105
+ private logoUri?: string;
106
+ private policyUri?: string;
107
+ private createdAt?: number;
108
+
109
+
110
+ /** Event emitters for connection lifecycle */
111
+ private readonly _onConnectionEvent = new Emitter<McpConnectionEvent>();
112
+ public readonly onConnectionEvent = this._onConnectionEvent.event;
113
+
114
+ private readonly _onObservabilityEvent = new Emitter<McpObservabilityEvent>();
115
+ public readonly onObservabilityEvent = this._onObservabilityEvent.event;
116
+
117
+ private currentState: McpConnectionState = 'DISCONNECTED';
118
+
119
+ /**
120
+ * Creates a new MCP client instance
121
+ * Can be initialized with minimal options (identity + sessionId) for session restoration
122
+ * @param options - Client configuration options
123
+ */
124
+ constructor(options: MCPOAuthClientOptions) {
125
+ this.serverUrl = options.serverUrl;
126
+ this.serverName = options.serverName;
127
+ this.callbackUrl = options.callbackUrl;
128
+ this.onRedirect = options.onRedirect;
129
+ this.identity = options.identity;
130
+ this.serverId = options.serverId;
131
+ this.sessionId = options.sessionId;
132
+ this.transportType = options.transportType;
133
+ this.clientId = options.clientId;
134
+ this.clientSecret = options.clientSecret;
135
+ this.headers = options.headers;
136
+ this.clientName = options.clientName;
137
+ this.clientUri = options.clientUri;
138
+ this.logoUri = options.logoUri;
139
+ this.policyUri = options.policyUri;
140
+ }
141
+
142
+ /**
143
+ * Emit a connection state change event
144
+ * @private
145
+ */
146
+ private emitStateChange(newState: McpConnectionState): void {
147
+ const previousState = this.currentState;
148
+ this.currentState = newState;
149
+
150
+ if (!this.serverId) return;
151
+
152
+ this._onConnectionEvent.fire({
153
+ type: 'state_changed',
154
+ sessionId: this.sessionId,
155
+ serverId: this.serverId,
156
+ serverName: this.serverName || this.serverId,
157
+ serverUrl: this.serverUrl || '',
158
+ createdAt: this.createdAt,
159
+ state: newState,
160
+ previousState,
161
+ timestamp: Date.now(),
162
+ });
163
+
164
+ this._onObservabilityEvent.fire({
165
+ type: 'mcp:client:state_change',
166
+ level: 'info',
167
+ message: `Connection state: ${previousState} → ${newState}`,
168
+ displayMessage: `State changed to ${newState}`,
169
+ sessionId: this.sessionId,
170
+ serverId: this.serverId,
171
+ payload: { previousState, newState },
172
+ timestamp: Date.now(),
173
+ id: nanoid(),
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Emit an error event
179
+ * @private
180
+ */
181
+ private emitError(error: string, errorType: 'connection' | 'auth' | 'validation' | 'unknown' = 'unknown'): void {
182
+ if (!this.serverId) return;
183
+
184
+ this._onConnectionEvent.fire({
185
+ type: 'error',
186
+ sessionId: this.sessionId,
187
+ serverId: this.serverId,
188
+ error,
189
+ errorType,
190
+ timestamp: Date.now(),
191
+ });
192
+
193
+ this._onObservabilityEvent.fire({
194
+ type: 'mcp:client:error',
195
+ level: 'error',
196
+ message: error,
197
+ displayMessage: error,
198
+ sessionId: this.sessionId,
199
+ serverId: this.serverId,
200
+ payload: { errorType, error },
201
+ timestamp: Date.now(),
202
+ id: nanoid(),
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Emit a progress event
208
+ * @private
209
+ */
210
+ private emitProgress(message: string): void {
211
+ if (!this.serverId) return;
212
+
213
+ this._onConnectionEvent.fire({
214
+ type: 'progress',
215
+ sessionId: this.sessionId,
216
+ serverId: this.serverId,
217
+ message,
218
+ timestamp: Date.now(),
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Get current connection state
224
+ */
225
+ getConnectionState(): McpConnectionState {
226
+ return this.currentState;
227
+ }
228
+
229
+ /**
230
+ * Helper to create a transport instance
231
+ * @param type - The transport type to create
232
+ * @returns Configured transport instance
233
+ * @private
234
+ */
235
+ private getTransport(type: TransportType): StreamableHTTPClientTransport | SSEClientTransport {
236
+ if (!this.serverUrl) {
237
+ throw new Error('Server URL is required to create transport');
238
+ }
239
+
240
+ const baseUrl = new URL(this.serverUrl);
241
+ const transportOptions = {
242
+ authProvider: this.oauthProvider!,
243
+ ...(this.headers && { headers: this.headers }),
244
+ /**
245
+ * Custom fetch implementation to handle connection timeouts.
246
+ * Observation: SDK 1.24.0+ connections may hang indefinitely in some environments.
247
+ * This wrapper enforces a timeout and properly uses AbortController to unblock the request.
248
+ */
249
+ fetch: (url: RequestInfo | URL, init?: RequestInit) => {
250
+ const timeout = 30000;
251
+ const controller = new AbortController();
252
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
253
+ const signal = init?.signal ?
254
+ // @ts-ignore: AbortSignal.any is available in Node 20+
255
+ (AbortSignal.any ? AbortSignal.any([init.signal, controller.signal]) : controller.signal) :
256
+ controller.signal;
257
+
258
+ return fetch(url, { ...init, signal }).finally(() => clearTimeout(timeoutId));
259
+ }
260
+ };
261
+
262
+ if (type === 'sse') {
263
+ return new SSEClientTransport(baseUrl, transportOptions);
264
+ } else {
265
+ return new StreamableHTTPClientTransport(baseUrl, transportOptions);
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Initializes client components (client, transport, OAuth provider)
271
+ * Loads missing configuration from Redis session store if needed
272
+ * This method is idempotent and safe to call multiple times
273
+ * @private
274
+ */
275
+ private async initialize(): Promise<void> {
276
+ if (this.client && this.oauthProvider) {
277
+ return;
278
+ }
279
+
280
+ this.emitStateChange('INITIALIZING');
281
+ this.emitProgress('Loading session configuration...');
282
+
283
+ if (!this.serverUrl || !this.callbackUrl || !this.serverId) {
284
+ const sessionData = await storage.getSession(this.identity, this.sessionId);
285
+ if (!sessionData) {
286
+ throw new Error(`Session not found: ${this.sessionId}`);
287
+ }
288
+
289
+ this.serverUrl = this.serverUrl || sessionData.serverUrl;
290
+ this.callbackUrl = this.callbackUrl || sessionData.callbackUrl;
291
+ /**
292
+ * Do NOT load transportType from session if not explicitly provided.
293
+ * We want to re-negotiate (try streamable -> sse) on new connections if in "Auto" mode.
294
+ * this.transportType = this.transportType || sessionData.transportType;
295
+ */
296
+ this.serverName = this.serverName || sessionData.serverName;
297
+ this.serverId = this.serverId || sessionData.serverId || 'unknown';
298
+ this.headers = this.headers || sessionData.headers;
299
+ this.createdAt = sessionData.createdAt;
300
+ }
301
+
302
+ if (!this.serverUrl || !this.callbackUrl || !this.serverId) {
303
+ throw new Error('Missing required connection metadata');
304
+ }
305
+
306
+ if (!this.oauthProvider) {
307
+ if (!this.serverId) {
308
+ throw new Error('serverId required for OAuth provider initialization');
309
+ }
310
+ this.oauthProvider = new StorageOAuthClientProvider({
311
+ identity: this.identity,
312
+ serverId: this.serverId,
313
+ sessionId: this.sessionId,
314
+ redirectUrl: this.callbackUrl!,
315
+ clientName: this.clientName,
316
+ clientUri: this.clientUri,
317
+ logoUri: this.logoUri,
318
+ policyUri: this.policyUri,
319
+ clientId: this.clientId,
320
+ clientSecret: this.clientSecret,
321
+ onRedirect: (redirectUrl: string) => {
322
+ if (this.onRedirect) {
323
+ this.onRedirect(redirectUrl);
324
+ }
325
+ }
326
+ });
327
+ }
328
+
329
+ if (!this.client) {
330
+ this.client = new Client(
331
+ {
332
+ name: MCP_CLIENT_NAME,
333
+ version: MCP_CLIENT_VERSION,
334
+ },
335
+ {
336
+ capabilities: {
337
+ extensions: {
338
+ 'io.modelcontextprotocol/ui': {
339
+ mimeTypes: ['text/html+mcp'],
340
+ },
341
+ },
342
+ } as McpAppClientCapabilities
343
+ }
344
+ );
345
+ }
346
+
347
+ // Create session in storage if it doesn't exist yet
348
+ // This is needed BEFORE OAuth flow starts because the OAuth provider
349
+ // will call saveCodeVerifier() which requires the session to exist
350
+ const existingSession = await storage.getSession(this.identity, this.sessionId);
351
+ if (!existingSession && this.serverId && this.serverUrl && this.callbackUrl) {
352
+ this.createdAt = Date.now();
353
+ console.log(`[MCPClient] Creating initial session ${this.sessionId} for OAuth flow`);
354
+ await storage.createSession({
355
+ sessionId: this.sessionId,
356
+ identity: this.identity,
357
+ serverId: this.serverId,
358
+ serverName: this.serverName,
359
+ serverUrl: this.serverUrl,
360
+ callbackUrl: this.callbackUrl,
361
+ transportType: this.transportType || 'streamable_http',
362
+ createdAt: this.createdAt,
363
+ active: false,
364
+ }, Math.floor(STATE_EXPIRATION_MS / 1000)); // Short TTL until connection succeeds
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Saves current session state to storage
370
+ * Creates new session if it doesn't exist, updates if it does
371
+ * @param ttl - Time-to-live in seconds (defaults to 12hr for connected sessions)
372
+ * @param active - Session status marker used to avoid unnecessary TTL rewrites
373
+ * @private
374
+ */
375
+ private async saveSession(
376
+ ttl: number = SESSION_TTL_SECONDS,
377
+ active: boolean = true
378
+ ): Promise<void> {
379
+ if (!this.sessionId || !this.serverId || !this.serverUrl || !this.callbackUrl) {
380
+ return;
381
+ }
382
+
383
+ const sessionData = {
384
+ sessionId: this.sessionId,
385
+ identity: this.identity,
386
+ serverId: this.serverId,
387
+ serverName: this.serverName,
388
+ serverUrl: this.serverUrl,
389
+ callbackUrl: this.callbackUrl,
390
+ transportType: (this.transportType || 'streamable_http') as TransportType,
391
+ createdAt: this.createdAt || Date.now(),
392
+ active,
393
+ };
394
+
395
+ // Try to update first, create if doesn't exist
396
+ const existingSession = await storage.getSession(this.identity, this.sessionId);
397
+ if (existingSession) {
398
+ await storage.updateSession(this.identity, this.sessionId, sessionData, ttl);
399
+ } else {
400
+ await storage.createSession(sessionData, ttl);
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Try to connect using available transports
406
+ * @returns The corrected transport type object if successful
407
+ * @private
408
+ */
409
+ private async tryConnect(): Promise<{ transportType: TransportType }> {
410
+ /**
411
+ * If exact transport type is known, only try that.
412
+ * Otherwise (auto mode), try streamable_http first, then sse.
413
+ */
414
+ const transportsToTry: TransportType[] = this.transportType
415
+ ? [this.transportType]
416
+ : ['streamable_http', 'sse'];
417
+
418
+ let lastError: unknown;
419
+
420
+ for (const currentType of transportsToTry) {
421
+ const isLastAttempt = currentType === transportsToTry[transportsToTry.length - 1];
422
+
423
+ try {
424
+ const transport = this.getTransport(currentType);
425
+
426
+ /** Update local state with the transport we are about to try */
427
+ this.transport = transport;
428
+
429
+ /** Race connection against timeout */
430
+ await this.client!.connect(transport);
431
+
432
+ /** Success! Return the type that worked */
433
+ return { transportType: currentType };
434
+
435
+ } catch (error: any) {
436
+ lastError = error;
437
+
438
+ /** Check for Auth Errors - these should fail immediately, no fallback */
439
+ const isAuthError = error instanceof SDKUnauthorizedError ||
440
+ (error instanceof Error && error.message.toLowerCase().includes('unauthorized'));
441
+
442
+ if (isAuthError) {
443
+ throw error;
444
+ }
445
+
446
+ /** If this was the last transport to try, throw the error */
447
+ if (isLastAttempt) {
448
+ throw error;
449
+ }
450
+
451
+ /** Otherwise, log and continue to next transport */
452
+ const errorMessage = error instanceof Error ? error.message : String(error);
453
+ this.emitProgress(`Connection attempt with ${currentType} failed: ${errorMessage}. Retrying...`);
454
+ this._onObservabilityEvent.fire({
455
+ level: 'warn',
456
+ message: `Transport ${currentType} failed, falling back`,
457
+ sessionId: this.sessionId,
458
+ serverId: this.serverId,
459
+ metadata: {
460
+ failedTransport: currentType,
461
+ error: errorMessage
462
+ },
463
+ timestamp: Date.now(),
464
+ });
465
+ }
466
+ }
467
+
468
+ throw lastError || new Error('No transports available');
469
+ }
470
+
471
+ /**
472
+ * Connects to the MCP server
473
+ * Automatically validates and refreshes OAuth tokens if needed
474
+ * Saves session to Redis on first successful connection
475
+ * @throws {UnauthorizedError} When OAuth authorization is required
476
+ * @throws {Error} When connection fails for other reasons
477
+ */
478
+ async connect(): Promise<void> {
479
+ await this.initialize();
480
+
481
+ if (!this.client || !this.oauthProvider) {
482
+ const error = 'Client or OAuth provider not initialized';
483
+ this.emitError(error, 'connection');
484
+ this.emitStateChange('FAILED');
485
+ throw new Error(error);
486
+ }
487
+
488
+ try {
489
+ this.emitProgress('Validating OAuth tokens...');
490
+ await this.getValidTokens();
491
+
492
+ this.emitStateChange('CONNECTING');
493
+
494
+ /** Use the tryConnect loop to handle transport fallbacks */
495
+ const { transportType } = await this.tryConnect();
496
+
497
+ /** Update transport type to the one that actually worked */
498
+ this.transportType = transportType;
499
+
500
+ this.emitStateChange('CONNECTED');
501
+ this.emitProgress('Connected successfully');
502
+
503
+ // Promote short-lived OAuth-pending session TTL to long-lived active TTL once.
504
+ // Also persist when transport negotiation changed the effective transport.
505
+ const existingSession = await storage.getSession(this.identity, this.sessionId);
506
+ const needsTransportUpdate = !existingSession || existingSession.transportType !== this.transportType;
507
+ const needsTtlPromotion = !existingSession || existingSession.active !== true;
508
+
509
+ if (needsTransportUpdate || needsTtlPromotion) {
510
+ console.log(`[MCPClient] Saving session ${this.sessionId} with 12hr TTL (connect success)`);
511
+ await this.saveSession(SESSION_TTL_SECONDS, true);
512
+ }
513
+ } catch (error) {
514
+ /** Handle Authentication Errors */
515
+ if (
516
+ error instanceof SDKUnauthorizedError ||
517
+ (error instanceof Error && error.message.toLowerCase().includes('unauthorized'))
518
+ ) {
519
+ this.emitStateChange('AUTHENTICATING');
520
+ // Save session with 10min TTL for OAuth pending state
521
+ console.log(`[MCPClient] Saving session ${this.sessionId} with 10min TTL (OAuth pending)`);
522
+ await this.saveSession(Math.floor(STATE_EXPIRATION_MS / 1000), false);
523
+
524
+ /** Get OAuth authorization URL if available */
525
+ let authUrl = '';
526
+ if (this.oauthProvider) {
527
+ authUrl = this.oauthProvider.authUrl || '';
528
+ }
529
+
530
+ if (this.serverId) {
531
+ this._onConnectionEvent.fire({
532
+ type: 'auth_required',
533
+ sessionId: this.sessionId,
534
+ serverId: this.serverId,
535
+ authUrl,
536
+ timestamp: Date.now(),
537
+ });
538
+
539
+ if (authUrl && this.onRedirect) {
540
+ this.onRedirect(authUrl);
541
+ }
542
+ }
543
+
544
+ throw new UnauthorizedError('OAuth authorization required');
545
+ }
546
+
547
+ /** Handle Generic Errors */
548
+ const errorMessage = error instanceof Error ? error.message : 'Connection failed';
549
+ this.emitError(errorMessage, 'connection');
550
+ this.emitStateChange('FAILED');
551
+ throw error;
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Completes OAuth authorization flow by exchanging authorization code for tokens
557
+ * Creates new authenticated client and transport, then establishes connection
558
+ * Saves active session to Redis after successful authentication
559
+ * @param authCode - Authorization code received from OAuth callback
560
+ */
561
+
562
+ // TODO: needs to be optimized
563
+ async finishAuth(authCode: string): Promise<void> {
564
+ this.emitStateChange('AUTHENTICATING');
565
+ this.emitProgress('Exchanging authorization code for tokens...');
566
+
567
+ await this.initialize();
568
+
569
+ if (!this.oauthProvider) {
570
+ const error = 'OAuth provider not initialized';
571
+ this.emitError(error, 'auth');
572
+ this.emitStateChange('FAILED');
573
+ throw new Error(error);
574
+ }
575
+
576
+ /**
577
+ * Determine which transports to try for finishing auth
578
+ * If transportType is set, use only that. Otherwise try streamable_http then sse.
579
+ */
580
+ const transportsToTry: TransportType[] = this.transportType
581
+ ? [this.transportType]
582
+ : ['streamable_http', 'sse'];
583
+
584
+ let lastError: unknown;
585
+ let tokensExchanged = false;
586
+
587
+ for (const currentType of transportsToTry) {
588
+ const isLastAttempt = currentType === transportsToTry[transportsToTry.length - 1];
589
+
590
+ try {
591
+ const transport = this.getTransport(currentType);
592
+
593
+ /** Update local state with the transport we are about to try */
594
+ this.transport = transport;
595
+
596
+ if (!tokensExchanged) {
597
+ await transport.finishAuth(authCode);
598
+ tokensExchanged = true;
599
+ } else {
600
+ this.emitProgress(`Tokens already exchanged, skipping auth step for ${currentType}...`);
601
+ }
602
+
603
+ /** Success! Update transport type */
604
+ this.transportType = currentType;
605
+
606
+ this.emitStateChange('AUTHENTICATED');
607
+ this.emitProgress('Creating authenticated client...');
608
+
609
+ this.client = new Client(
610
+ {
611
+ name: MCP_CLIENT_NAME,
612
+ version: MCP_CLIENT_VERSION,
613
+ },
614
+ {
615
+ capabilities: {
616
+ extensions: {
617
+ 'io.modelcontextprotocol/ui': {
618
+ mimeTypes: ['text/html+mcp'],
619
+ },
620
+ },
621
+ } as McpAppClientCapabilities
622
+ }
623
+ );
624
+
625
+ this.emitStateChange('CONNECTING');
626
+
627
+ /** We explicitly try to connect with the transport we just auth'd with first */
628
+ await this.client.connect(this.transport);
629
+
630
+ this.emitStateChange('CONNECTED');
631
+ // Update session with 12hr TTL after successful OAuth
632
+ console.log(`[MCPClient] Updating session ${this.sessionId} to 12hr TTL (OAuth complete)`);
633
+ await this.saveSession(SESSION_TTL_SECONDS, true);
634
+
635
+ return; // Success, exit function
636
+
637
+ } catch (error) {
638
+ lastError = error;
639
+
640
+ const isAuthError = error instanceof SDKUnauthorizedError ||
641
+ (error instanceof Error && error.message.toLowerCase().includes('unauthorized'));
642
+
643
+ if (isAuthError) {
644
+ throw error;
645
+ }
646
+
647
+ const errorMessage = error instanceof Error ? error.message : String(error);
648
+
649
+ // Don't retry if the authorization code was rejected (it's one-time use)
650
+ if (!tokensExchanged && errorMessage.toLowerCase().includes('invalid authorization code')) {
651
+ const msg = error instanceof Error ? error.message : 'Authentication failed';
652
+ this.emitError(msg, 'auth');
653
+ this.emitStateChange('FAILED');
654
+ throw error;
655
+ }
656
+
657
+ if (isLastAttempt) {
658
+ const msg = error instanceof Error ? error.message : 'Authentication failed';
659
+ this.emitError(msg, 'auth');
660
+ this.emitStateChange('FAILED');
661
+ throw error;
662
+ }
663
+
664
+ // Log and retry
665
+ this.emitProgress(`Auth attempt with ${currentType} failed: ${errorMessage}. Retrying...`);
666
+ }
667
+ }
668
+
669
+ if (lastError) {
670
+ const errorMessage = lastError instanceof Error ? lastError.message : 'Authentication failed';
671
+ this.emitError(errorMessage, 'auth');
672
+ this.emitStateChange('FAILED');
673
+ throw lastError;
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Lists all available tools from the connected MCP server
679
+ * @returns List of tools with their schemas and descriptions
680
+ * @throws {Error} When client is not connected
681
+ */
682
+ async listTools(): Promise<ListToolsResult> {
683
+ if (!this.client) {
684
+ throw new Error('Not connected to server');
685
+ }
686
+
687
+ this.emitStateChange('DISCOVERING');
688
+
689
+ try {
690
+ const request: ListToolsRequest = {
691
+ method: 'tools/list',
692
+ params: {},
693
+ };
694
+
695
+ const result = await this.client.request(request, ListToolsResultSchema);
696
+
697
+ if (this.serverId) {
698
+ this._onConnectionEvent.fire({
699
+ type: 'tools_discovered',
700
+ sessionId: this.sessionId,
701
+ serverId: this.serverId,
702
+ toolCount: result.tools.length,
703
+ tools: result.tools,
704
+ timestamp: Date.now(),
705
+ });
706
+ }
707
+
708
+ this.emitStateChange('READY');
709
+ this.emitProgress(`Discovered ${result.tools.length} tools`);
710
+
711
+ return result;
712
+ } catch (error) {
713
+ const errorMessage = error instanceof Error ? error.message : 'Failed to list tools';
714
+ this.emitError(errorMessage, 'validation');
715
+ this.emitStateChange('FAILED');
716
+ throw error;
717
+ }
718
+ }
719
+
720
+ /**
721
+ * Executes a tool on the connected MCP server
722
+ * @param toolName - Name of the tool to execute
723
+ * @param toolArgs - Arguments to pass to the tool
724
+ * @returns Tool execution result
725
+ * @throws {Error} When client is not connected
726
+ */
727
+ async callTool(toolName: string, toolArgs: Record<string, unknown>): Promise<CallToolResult> {
728
+ if (!this.client) {
729
+ throw new Error('Not connected to server');
730
+ }
731
+
732
+ const request: CallToolRequest = {
733
+ method: 'tools/call',
734
+ params: {
735
+ name: toolName,
736
+ arguments: toolArgs,
737
+ },
738
+ };
739
+
740
+ try {
741
+ const result = await this.client.request(request, CallToolResultSchema);
742
+
743
+ this._onObservabilityEvent.fire({
744
+ type: 'mcp:client:tool_call',
745
+ level: 'info',
746
+ message: `Tool ${toolName} called successfully`,
747
+ displayMessage: `Called tool ${toolName}`,
748
+ sessionId: this.sessionId,
749
+ serverId: this.serverId,
750
+ payload: {
751
+ toolName,
752
+ args: toolArgs,
753
+ },
754
+ timestamp: Date.now(),
755
+ id: nanoid(),
756
+ });
757
+
758
+ return result;
759
+ } catch (error) {
760
+ const errorMessage = error instanceof Error ? error.message : `Failed to call tool ${toolName}`;
761
+
762
+ this._onObservabilityEvent.fire({
763
+ type: 'mcp:client:error',
764
+ level: 'error',
765
+ message: errorMessage,
766
+ displayMessage: `Failed to call tool ${toolName}`,
767
+ sessionId: this.sessionId,
768
+ serverId: this.serverId,
769
+ payload: {
770
+ errorType: 'tool_execution',
771
+ error: errorMessage,
772
+ toolName,
773
+ args: toolArgs,
774
+ },
775
+ timestamp: Date.now(),
776
+ id: nanoid(),
777
+ });
778
+
779
+ throw error;
780
+ }
781
+ }
782
+
783
+ /**
784
+ * Lists all available prompts from the connected MCP server
785
+ * @returns List of available prompts
786
+ * @throws {Error} When client is not connected
787
+ */
788
+ async listPrompts(): Promise<ListPromptsResult> {
789
+ if (!this.client) {
790
+ throw new Error('Not connected to server');
791
+ }
792
+
793
+ this.emitStateChange('DISCOVERING');
794
+
795
+ try {
796
+ const request: ListPromptsRequest = {
797
+ method: 'prompts/list',
798
+ params: {},
799
+ };
800
+
801
+ const result = await this.client.request(request, ListPromptsResultSchema);
802
+
803
+ this.emitStateChange('READY');
804
+ this.emitProgress(`Discovered ${result.prompts.length} prompts`);
805
+
806
+ return result;
807
+ } catch (error) {
808
+ const errorMessage = error instanceof Error ? error.message : 'Failed to list prompts';
809
+ this.emitError(errorMessage, 'validation');
810
+ this.emitStateChange('FAILED');
811
+ throw error;
812
+ }
813
+ }
814
+
815
+ /**
816
+ * Gets a specific prompt with arguments
817
+ * @param name - Name of the prompt
818
+ * @param args - Arguments for the prompt
819
+ * @returns Prompt content
820
+ * @throws {Error} When client is not connected
821
+ */
822
+ async getPrompt(name: string, args?: Record<string, string>): Promise<GetPromptResult> {
823
+ if (!this.client) {
824
+ throw new Error('Not connected to server');
825
+ }
826
+
827
+ const request: GetPromptRequest = {
828
+ method: 'prompts/get',
829
+ params: {
830
+ name,
831
+ arguments: args,
832
+ },
833
+ };
834
+
835
+ return await this.client.request(request, GetPromptResultSchema);
836
+ }
837
+
838
+ /**
839
+ * Lists all available resources from the connected MCP server
840
+ * @returns List of available resources
841
+ * @throws {Error} When client is not connected
842
+ */
843
+ async listResources(): Promise<ListResourcesResult> {
844
+ if (!this.client) {
845
+ throw new Error('Not connected to server');
846
+ }
847
+
848
+ this.emitStateChange('DISCOVERING');
849
+
850
+ try {
851
+ const request: ListResourcesRequest = {
852
+ method: 'resources/list',
853
+ params: {},
854
+ };
855
+
856
+ const result = await this.client.request(request, ListResourcesResultSchema);
857
+
858
+ this.emitStateChange('READY');
859
+ this.emitProgress(`Discovered ${result.resources.length} resources`);
860
+
861
+ return result;
862
+ } catch (error) {
863
+ const errorMessage = error instanceof Error ? error.message : 'Failed to list resources';
864
+ this.emitError(errorMessage, 'validation');
865
+ this.emitStateChange('FAILED');
866
+ throw error;
867
+ }
868
+ }
869
+
870
+ /**
871
+ * Reads a specific resource
872
+ * @param uri - URI of the resource to read
873
+ * @returns Resource content
874
+ * @throws {Error} When client is not connected
875
+ */
876
+ async readResource(uri: string): Promise<ReadResourceResult> {
877
+ if (!this.client) {
878
+ throw new Error('Not connected to server');
879
+ }
880
+
881
+ const request: ReadResourceRequest = {
882
+ method: 'resources/read',
883
+ params: {
884
+ uri,
885
+ },
886
+ };
887
+
888
+ return await this.client.request(request, ReadResourceResultSchema);
889
+ }
890
+
891
+ /**
892
+ * Refreshes the OAuth access token using the refresh token
893
+ * Discovers OAuth metadata from server and exchanges refresh token for new access token
894
+ * @returns True if refresh was successful, false otherwise
895
+ */
896
+ async refreshToken(): Promise<boolean> {
897
+ await this.initialize();
898
+
899
+ if (!this.oauthProvider) {
900
+ return false;
901
+ }
902
+
903
+ const tokens = await this.oauthProvider.tokens();
904
+ if (!tokens || !tokens.refresh_token) {
905
+ return false;
906
+ }
907
+
908
+ const clientInformation = await this.oauthProvider.clientInformation();
909
+ if (!clientInformation) {
910
+ return false;
911
+ }
912
+
913
+ try {
914
+ const resourceMetadata = await discoverOAuthProtectedResourceMetadata(this.serverUrl!);
915
+ const authServerUrl = resourceMetadata?.authorization_servers?.[0] || this.serverUrl!;
916
+ const authMetadata = await discoverAuthorizationServerMetadata(authServerUrl);
917
+
918
+ const newTokens = await refreshAuthorization(authServerUrl, {
919
+ metadata: authMetadata,
920
+ clientInformation,
921
+ refreshToken: tokens.refresh_token,
922
+ });
923
+
924
+ await this.oauthProvider.saveTokens(newTokens);
925
+ return true;
926
+ } catch (error) {
927
+ console.error('[OAuth] Token refresh failed:', error);
928
+ return false;
929
+ }
930
+ }
931
+
932
+ /**
933
+ * Ensures OAuth tokens are valid, refreshing them if expired
934
+ * Called automatically by connect() - rarely needs to be called manually
935
+ * @returns True if valid tokens are available, false otherwise
936
+ */
937
+ async getValidTokens(): Promise<boolean> {
938
+ await this.initialize();
939
+
940
+ if (!this.oauthProvider) {
941
+ return false;
942
+ }
943
+
944
+ const tokens = await this.oauthProvider.tokens();
945
+ if (!tokens) {
946
+ return false;
947
+ }
948
+
949
+ if (this.oauthProvider.isTokenExpired()) {
950
+ return await this.refreshToken();
951
+ }
952
+
953
+ return true;
954
+ }
955
+
956
+ /**
957
+ * Reconnects to MCP server using existing OAuth provider from Redis
958
+ * Used for session restoration in serverless environments
959
+ * Creates new client and transport without re-initializing OAuth provider
960
+ * @throws {Error} When OAuth provider is not initialized
961
+ */
962
+ async reconnect(): Promise<void> {
963
+ await this.initialize();
964
+
965
+ if (!this.oauthProvider) {
966
+ throw new Error('OAuth provider not initialized');
967
+ }
968
+
969
+ this.client = new Client(
970
+ {
971
+ name: MCP_CLIENT_NAME,
972
+ version: MCP_CLIENT_VERSION,
973
+ },
974
+ { capabilities: {} }
975
+ );
976
+
977
+ // Use default logic to get transport, defaulting to what's stored or auto
978
+ const tt = this.transportType || 'streamable_http';
979
+ this.transport = this.getTransport(tt);
980
+
981
+ await this.client.connect(this.transport);
982
+ }
983
+
984
+ /**
985
+ * Completely removes the session from Redis including all OAuth data
986
+ * Invalidates credentials and disconnects the client
987
+ */
988
+ async clearSession(): Promise<void> {
989
+ try {
990
+ await this.initialize();
991
+ } catch (error) {
992
+ console.warn('[MCPClient] Initialization failed during clearSession:', error);
993
+ }
994
+
995
+ if (this.oauthProvider) {
996
+ await (this.oauthProvider as any).invalidateCredentials('all');
997
+ }
998
+
999
+ await storage.removeSession(this.identity, this.sessionId);
1000
+ this.disconnect();
1001
+ }
1002
+
1003
+ /**
1004
+ * Checks if the client is currently connected to an MCP server
1005
+ * @returns True if connected, false otherwise
1006
+ */
1007
+ isConnected(): boolean {
1008
+ return this.client !== null;
1009
+ }
1010
+
1011
+ /**
1012
+ * Disconnects from the MCP server and cleans up resources
1013
+ * Does not remove session from Redis - use clearSession() for that
1014
+ */
1015
+ disconnect(reason?: string): void {
1016
+ if (this.client) {
1017
+ this.client.close();
1018
+ }
1019
+ this.client = null;
1020
+ this.oauthProvider = null;
1021
+ this.transport = null;
1022
+
1023
+ // Emit disconnected event
1024
+ if (this.serverId) {
1025
+ this._onConnectionEvent.fire({
1026
+ type: 'disconnected',
1027
+ sessionId: this.sessionId,
1028
+ serverId: this.serverId,
1029
+ reason,
1030
+ timestamp: Date.now(),
1031
+ });
1032
+
1033
+ this._onObservabilityEvent.fire({
1034
+ type: 'mcp:client:disconnect',
1035
+ level: 'info',
1036
+ message: `Disconnected from ${this.serverId}`,
1037
+ sessionId: this.sessionId,
1038
+ serverId: this.serverId,
1039
+ payload: {
1040
+ reason: reason || 'unknown',
1041
+ },
1042
+ timestamp: Date.now(),
1043
+ id: nanoid(),
1044
+ });
1045
+ }
1046
+
1047
+ this.emitStateChange('DISCONNECTED');
1048
+ }
1049
+
1050
+ /**
1051
+ * Dispose of all event emitters
1052
+ * Call this when the client is no longer needed
1053
+ */
1054
+ dispose(): void {
1055
+ this._onConnectionEvent.dispose();
1056
+ this._onObservabilityEvent.dispose();
1057
+ }
1058
+
1059
+ /**
1060
+ * Gets the server URL
1061
+ * @returns Server URL or empty string if not set
1062
+ */
1063
+ getServerUrl(): string {
1064
+ return this.serverUrl || '';
1065
+ }
1066
+
1067
+ /**
1068
+ * Gets the OAuth callback URL
1069
+ * @returns Callback URL or empty string if not set
1070
+ */
1071
+ getCallbackUrl(): string {
1072
+ return this.callbackUrl || '';
1073
+ }
1074
+
1075
+ /**
1076
+ * Gets the transport type being used
1077
+ * @returns Transport type (defaults to 'streamable_http')
1078
+ */
1079
+ getTransportType(): TransportType {
1080
+ return this.transportType || 'streamable_http';
1081
+ }
1082
+
1083
+ /**
1084
+ * Gets the human-readable server name
1085
+ * @returns Server name or undefined
1086
+ */
1087
+ getServerName(): string | undefined {
1088
+ return this.serverName;
1089
+ }
1090
+
1091
+ /**
1092
+ * Gets the server ID
1093
+ * @returns Server ID or undefined
1094
+ */
1095
+ getServerId(): string | undefined {
1096
+ return this.serverId;
1097
+ }
1098
+
1099
+ /**
1100
+ * Gets the session ID
1101
+ * @returns Session ID
1102
+ */
1103
+ getSessionId(): string {
1104
+ return this.sessionId;
1105
+ }
1106
+
1107
+ /**
1108
+ * Gets MCP server configuration for all active user sessions
1109
+ * Loads sessions from Redis, validates OAuth tokens, refreshes if expired
1110
+ * Returns ready-to-use configuration with valid auth headers
1111
+ * @param identity - User ID to fetch sessions for
1112
+ * @returns Object keyed by sanitized server labels containing transport, url, headers, etc.
1113
+ * @static
1114
+ */
1115
+ static async getMcpServerConfig(identity: string): Promise<Record<string, any>> {
1116
+ const mcpConfig: Record<string, any> = {};
1117
+ const sessions = await storage.getIdentitySessionsData(identity);
1118
+
1119
+ await Promise.all(
1120
+ sessions.map(async (sessionData) => {
1121
+ const { sessionId } = sessionData;
1122
+
1123
+ try {
1124
+ // Validate session - remove if missing required fields
1125
+ if (
1126
+ !sessionData.serverId ||
1127
+ !sessionData.transportType ||
1128
+ !sessionData.serverUrl ||
1129
+ !sessionData.callbackUrl
1130
+ ) {
1131
+ await storage.removeSession(identity, sessionId);
1132
+ return;
1133
+ }
1134
+
1135
+ // Get OAuth headers if session requires authentication
1136
+ let headers: Record<string, string> | undefined;
1137
+ try {
1138
+ // Inject existing session data to avoid redundant storage reads in initialize()
1139
+ const client = new MCPClient({
1140
+ identity,
1141
+ sessionId,
1142
+ serverId: sessionData.serverId,
1143
+ serverUrl: sessionData.serverUrl,
1144
+ callbackUrl: sessionData.callbackUrl,
1145
+ serverName: sessionData.serverName,
1146
+ transportType: sessionData.transportType,
1147
+ headers: sessionData.headers,
1148
+ });
1149
+
1150
+ await client.initialize();
1151
+
1152
+ const hasValidTokens = await client.getValidTokens();
1153
+ if (hasValidTokens && client.oauthProvider) {
1154
+ const tokens = await client.oauthProvider.tokens();
1155
+ if (tokens?.access_token) {
1156
+ headers = { Authorization: `Bearer ${tokens.access_token}` };
1157
+ }
1158
+ }
1159
+ } catch (error) {
1160
+ console.warn(`[MCP] Failed to get OAuth tokens for ${sessionId}:`, error);
1161
+ }
1162
+
1163
+ // Build server config
1164
+ const label = sanitizeServerLabel(
1165
+ sessionData.serverName || sessionData.serverId || 'server'
1166
+ );
1167
+
1168
+ mcpConfig[label] = {
1169
+ transport: sessionData.transportType,
1170
+ url: sessionData.serverUrl,
1171
+ ...(sessionData.serverName && {
1172
+ serverName: sessionData.serverName,
1173
+ serverLabel: label,
1174
+ }),
1175
+ ...(headers && { headers }),
1176
+ };
1177
+ } catch (error) {
1178
+ await storage.removeSession(identity, sessionId);
1179
+ console.warn(`[MCP] Failed to process session ${sessionId}:`, error);
1180
+ }
1181
+ })
1182
+ );
1183
+
1184
+ return mcpConfig;
1185
+ }
1186
+
1187
+ }
1188
+