@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.
- package/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/adapters/agui-adapter.d.mts +119 -0
- package/dist/adapters/agui-adapter.d.ts +119 -0
- package/dist/adapters/agui-adapter.js +109 -0
- package/dist/adapters/agui-adapter.js.map +1 -0
- package/dist/adapters/agui-adapter.mjs +107 -0
- package/dist/adapters/agui-adapter.mjs.map +1 -0
- package/dist/adapters/agui-middleware.d.mts +171 -0
- package/dist/adapters/agui-middleware.d.ts +171 -0
- package/dist/adapters/agui-middleware.js +429 -0
- package/dist/adapters/agui-middleware.js.map +1 -0
- package/dist/adapters/agui-middleware.mjs +417 -0
- package/dist/adapters/agui-middleware.mjs.map +1 -0
- package/dist/adapters/ai-adapter.d.mts +38 -0
- package/dist/adapters/ai-adapter.d.ts +38 -0
- package/dist/adapters/ai-adapter.js +82 -0
- package/dist/adapters/ai-adapter.js.map +1 -0
- package/dist/adapters/ai-adapter.mjs +80 -0
- package/dist/adapters/ai-adapter.mjs.map +1 -0
- package/dist/adapters/langchain-adapter.d.mts +46 -0
- package/dist/adapters/langchain-adapter.d.ts +46 -0
- package/dist/adapters/langchain-adapter.js +102 -0
- package/dist/adapters/langchain-adapter.js.map +1 -0
- package/dist/adapters/langchain-adapter.mjs +100 -0
- package/dist/adapters/langchain-adapter.mjs.map +1 -0
- package/dist/adapters/mastra-adapter.d.mts +49 -0
- package/dist/adapters/mastra-adapter.d.ts +49 -0
- package/dist/adapters/mastra-adapter.js +95 -0
- package/dist/adapters/mastra-adapter.js.map +1 -0
- package/dist/adapters/mastra-adapter.mjs +93 -0
- package/dist/adapters/mastra-adapter.mjs.map +1 -0
- package/dist/client/index.d.mts +119 -0
- package/dist/client/index.d.ts +119 -0
- package/dist/client/index.js +225 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/index.mjs +223 -0
- package/dist/client/index.mjs.map +1 -0
- package/dist/client/react.d.mts +151 -0
- package/dist/client/react.d.ts +151 -0
- package/dist/client/react.js +492 -0
- package/dist/client/react.js.map +1 -0
- package/dist/client/react.mjs +489 -0
- package/dist/client/react.mjs.map +1 -0
- package/dist/client/vue.d.mts +157 -0
- package/dist/client/vue.d.ts +157 -0
- package/dist/client/vue.js +474 -0
- package/dist/client/vue.js.map +1 -0
- package/dist/client/vue.mjs +471 -0
- package/dist/client/vue.mjs.map +1 -0
- package/dist/events-BP6WyRNh.d.mts +110 -0
- package/dist/events-BP6WyRNh.d.ts +110 -0
- package/dist/index.d.mts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +2784 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2723 -0
- package/dist/index.mjs.map +1 -0
- package/dist/multi-session-client-BOFgPypS.d.ts +389 -0
- package/dist/multi-session-client-DMF3ED2O.d.mts +389 -0
- package/dist/server/index.d.mts +269 -0
- package/dist/server/index.d.ts +269 -0
- package/dist/server/index.js +2444 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/index.mjs +2414 -0
- package/dist/server/index.mjs.map +1 -0
- package/dist/shared/index.d.mts +24 -0
- package/dist/shared/index.d.ts +24 -0
- package/dist/shared/index.js +223 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/shared/index.mjs +190 -0
- package/dist/shared/index.mjs.map +1 -0
- package/dist/types-SbDlA2VX.d.mts +153 -0
- package/dist/types-SbDlA2VX.d.ts +153 -0
- package/dist/utils-0qmYrqoa.d.mts +92 -0
- package/dist/utils-0qmYrqoa.d.ts +92 -0
- package/package.json +165 -0
- package/src/adapters/agui-adapter.ts +210 -0
- package/src/adapters/agui-middleware.ts +512 -0
- package/src/adapters/ai-adapter.ts +115 -0
- package/src/adapters/langchain-adapter.ts +127 -0
- package/src/adapters/mastra-adapter.ts +126 -0
- package/src/client/core/sse-client.ts +340 -0
- package/src/client/index.ts +26 -0
- package/src/client/react/index.ts +10 -0
- package/src/client/react/useMcp.ts +558 -0
- package/src/client/vue/index.ts +10 -0
- package/src/client/vue/useMcp.ts +542 -0
- package/src/index.ts +11 -0
- package/src/server/handlers/nextjs-handler.ts +216 -0
- package/src/server/handlers/sse-handler.ts +699 -0
- package/src/server/index.ts +57 -0
- package/src/server/mcp/multi-session-client.ts +132 -0
- package/src/server/mcp/oauth-client.ts +1168 -0
- package/src/server/mcp/storage-oauth-provider.ts +239 -0
- package/src/server/storage/file-backend.ts +169 -0
- package/src/server/storage/index.ts +115 -0
- package/src/server/storage/memory-backend.ts +132 -0
- package/src/server/storage/redis-backend.ts +210 -0
- package/src/server/storage/redis.ts +160 -0
- package/src/server/storage/types.ts +109 -0
- package/src/shared/constants.ts +29 -0
- package/src/shared/errors.ts +133 -0
- package/src/shared/events.ts +166 -0
- package/src/shared/index.ts +70 -0
- package/src/shared/types.ts +274 -0
- 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
|
+
|