@mcp-ts/sdk 1.0.0

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