@mcp-ts/sdk 2.4.0 → 2.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -19
- package/dist/adapters/agui-adapter.d.mts +3 -4
- package/dist/adapters/agui-adapter.d.ts +3 -4
- package/dist/adapters/agui-middleware.d.mts +3 -4
- package/dist/adapters/agui-middleware.d.ts +3 -4
- package/dist/adapters/ai-adapter.d.mts +3 -4
- package/dist/adapters/ai-adapter.d.ts +3 -4
- package/dist/adapters/langchain-adapter.d.mts +3 -4
- package/dist/adapters/langchain-adapter.d.ts +3 -4
- package/dist/adapters/mastra-adapter.d.mts +2 -2
- package/dist/adapters/mastra-adapter.d.ts +2 -2
- package/dist/client/index.d.mts +2 -3
- package/dist/client/index.d.ts +2 -3
- package/dist/client/react.d.mts +4 -6
- package/dist/client/react.d.ts +4 -6
- package/dist/client/vue.d.mts +4 -6
- package/dist/client/vue.d.ts +4 -6
- package/dist/{index-CtXvKl8N.d.ts → index-B8kJSrBJ.d.ts} +1 -2
- package/dist/{index-ByIjEReo.d.mts → index-DiJsm_lK.d.mts} +1 -2
- package/dist/index.d.mts +5 -6
- package/dist/index.d.ts +5 -6
- package/dist/index.js +149 -90
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +149 -90
- package/dist/index.mjs.map +1 -1
- package/dist/{multi-session-client-CnvZEGPY.d.ts → multi-session-client-BluyCPo9.d.ts} +219 -66
- package/dist/{multi-session-client-CIMUGF8S.d.mts → multi-session-client-CWs-AE78.d.mts} +219 -66
- package/dist/server/index.d.mts +6 -129
- package/dist/server/index.d.ts +6 -129
- package/dist/server/index.js +149 -90
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +149 -90
- package/dist/server/index.mjs.map +1 -1
- package/dist/shared/index.d.mts +4 -5
- package/dist/shared/index.d.ts +4 -5
- package/dist/shared/index.js.map +1 -1
- package/dist/shared/index.mjs.map +1 -1
- package/dist/{tool-router-CuApsDiV.d.mts → tool-router-CbG4Tum6.d.mts} +1 -1
- package/dist/{tool-router-CZMrOG8J.d.ts → tool-router-ChIhPwgP.d.ts} +1 -1
- package/dist/{types-DCk_IF4L.d.ts → types-CjczQwNX.d.mts} +122 -1
- package/dist/{types-DCk_IF4L.d.mts → types-CjczQwNX.d.ts} +122 -1
- package/migrations/v2.3.4/neon/20260513010000_install_mcp_sessions.sql +69 -0
- package/migrations/v2.3.4/neon/20260513020000_add_session_cleanup_cron.sql +35 -0
- package/migrations/v2.3.4/supabase/20260330195700_install_mcp_sessions.sql +82 -0
- package/migrations/v2.3.4/supabase/20260421010000_add_session_cleanup_cron.sql +32 -0
- package/package.json +1 -1
- package/src/server/handlers/sse-handler.ts +7 -5
- package/src/server/mcp/multi-session-client.ts +186 -100
- package/src/server/mcp/oauth-client.ts +60 -10
- package/src/shared/events.ts +0 -1
- package/dist/events-CK3N--3g.d.mts +0 -123
- package/dist/events-CK3N--3g.d.ts +0 -123
|
@@ -1,54 +1,90 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
1
|
import { MCPClient } from './oauth-client.js';
|
|
4
2
|
import { sessions, type Session } from '../storage/index.js';
|
|
3
|
+
import type { ToolClientProvider } from '../../shared/types.js';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Constants
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
5
8
|
|
|
6
9
|
const DEFAULT_TIMEOUT_MS = 15000;
|
|
7
10
|
const DEFAULT_MAX_RETRIES = 2;
|
|
8
11
|
const DEFAULT_RETRY_DELAY_MS = 1000;
|
|
9
12
|
const CONNECTION_BATCH_SIZE = 5;
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Types
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
15
18
|
export interface MultiSessionOptions {
|
|
16
19
|
/**
|
|
17
|
-
* Connection timeout in milliseconds
|
|
20
|
+
* Connection timeout in milliseconds.
|
|
18
21
|
* @default 15000
|
|
19
22
|
*/
|
|
20
23
|
timeout?: number;
|
|
24
|
+
|
|
21
25
|
/**
|
|
22
|
-
* Maximum number of retry attempts
|
|
26
|
+
* Maximum number of retry attempts per session.
|
|
23
27
|
* @default 2
|
|
24
28
|
*/
|
|
25
29
|
maxRetries?: number;
|
|
30
|
+
|
|
26
31
|
/**
|
|
27
|
-
* Delay between
|
|
32
|
+
* Delay between retry attempts in milliseconds.
|
|
28
33
|
* @default 1000
|
|
29
34
|
*/
|
|
30
35
|
retryDelay?: number;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Custom session provider. When provided, `connect()` calls this
|
|
39
|
+
* instead of querying the storage backend. Useful when sessions are
|
|
40
|
+
* synced externally (e.g. via Supabase Realtime, WebSocket, or an
|
|
41
|
+
* in-memory cache).
|
|
42
|
+
*
|
|
43
|
+
* Default: reads from the storage backend via `sessions.list(userId)`.
|
|
44
|
+
*/
|
|
45
|
+
sessionProvider?: () => Promise<Session[]>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Called after a session is successfully connected.
|
|
49
|
+
*/
|
|
50
|
+
onSessionConnected?: (sessionId: string, client: MCPClient) => void;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Called when a session is evicted from the in-memory client list
|
|
54
|
+
* because it no longer exists in the active sessions list.
|
|
55
|
+
*/
|
|
56
|
+
onSessionEvicted?: (sessionId: string) => void;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Called when all retry attempts for a session have been exhausted.
|
|
60
|
+
*/
|
|
61
|
+
onSessionFailed?: (sessionId: string, error: unknown) => void;
|
|
31
62
|
}
|
|
32
63
|
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// MultiSessionClient
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
33
68
|
/**
|
|
34
69
|
* Manages multiple MCP client connections for a single user.
|
|
35
70
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
71
|
+
* Cached instances stay alive between requests on long-running servers.
|
|
72
|
+
* On serverless, the underlying session data persists in storage — the
|
|
73
|
+
* client rehydrates from `sessionProvider` (or the default storage backend)
|
|
74
|
+
* each time `connect()` is called.
|
|
75
|
+
*
|
|
76
|
+
* @implements {ToolClientProvider}
|
|
40
77
|
*/
|
|
41
|
-
export class MultiSessionClient {
|
|
78
|
+
export class MultiSessionClient implements ToolClientProvider {
|
|
42
79
|
private clients: MCPClient[] = [];
|
|
80
|
+
private connectionPromises = new Map<string, Promise<void>>();
|
|
43
81
|
private userId: string;
|
|
44
|
-
private options: MultiSessionOptions
|
|
82
|
+
private options: Required<Pick<MultiSessionOptions, 'timeout' | 'maxRetries' | 'retryDelay'>> &
|
|
83
|
+
Pick<MultiSessionOptions, 'sessionProvider' | 'onSessionConnected' | 'onSessionEvicted' | 'onSessionFailed'>;
|
|
45
84
|
|
|
46
85
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* @param userId - A unique string identifying the user (e.g. user ID or email).
|
|
50
|
-
* @param options - Optional tuning for connection timeout, retry count, and retry delay.
|
|
51
|
-
* Falls back to sensible defaults if not provided.
|
|
86
|
+
* @param userId - Unique identifier for the user (e.g. user ID or email).
|
|
87
|
+
* @param options - Optional tuning and lifecycle hooks.
|
|
52
88
|
*/
|
|
53
89
|
constructor(userId: string, options: MultiSessionOptions = {}) {
|
|
54
90
|
this.userId = userId;
|
|
@@ -56,36 +92,110 @@ export class MultiSessionClient {
|
|
|
56
92
|
timeout: DEFAULT_TIMEOUT_MS,
|
|
57
93
|
maxRetries: DEFAULT_MAX_RETRIES,
|
|
58
94
|
retryDelay: DEFAULT_RETRY_DELAY_MS,
|
|
59
|
-
...options
|
|
95
|
+
...options,
|
|
60
96
|
};
|
|
61
97
|
}
|
|
62
98
|
|
|
99
|
+
// -----------------------------------------------------------------------
|
|
100
|
+
// Public API
|
|
101
|
+
// -----------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Fetches active sessions and establishes connections to all of them.
|
|
105
|
+
*
|
|
106
|
+
* Call this once after creating the client. On long-running servers you
|
|
107
|
+
* can cache the `MultiSessionClient` instance and call `connect()` on
|
|
108
|
+
* each request — already-connected sessions are skipped internally.
|
|
109
|
+
*/
|
|
110
|
+
async connect(): Promise<void> {
|
|
111
|
+
const sessions = await this.fetchActiveSessions();
|
|
112
|
+
const activeSessionIds = new Set(sessions.map(s => s.sessionId));
|
|
113
|
+
|
|
114
|
+
// Evict clients whose sessions no longer appear in the active list.
|
|
115
|
+
// Without this, a stale MCPClient with a live SDK transport would
|
|
116
|
+
// stay in the clients array after its session was deleted externally
|
|
117
|
+
// (e.g. via the SSE handler's clearSession()), causing OAuth refreshes
|
|
118
|
+
// to fail with a FK violation when they try to patchCredentials()
|
|
119
|
+
// against the now-deleted session.
|
|
120
|
+
for (const client of this.clients) {
|
|
121
|
+
if (!activeSessionIds.has(client.getSessionId())) {
|
|
122
|
+
this.options.onSessionEvicted?.(client.getSessionId());
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
this.clients = this.clients.filter(c => activeSessionIds.has(c.getSessionId()));
|
|
126
|
+
|
|
127
|
+
await this.connectInBatches(sessions);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Drops all cached `MCPClient` instances and reconnects fresh from storage.
|
|
132
|
+
*
|
|
133
|
+
* Call this when downstream MCP servers have expired their transport sessions
|
|
134
|
+
* (e.g. after a remote server restart) and subsequent tool calls return
|
|
135
|
+
* "Session not found. Reconnect without session header." errors.
|
|
136
|
+
*
|
|
137
|
+
* OAuth tokens are preserved in the storage backend — no re-authentication
|
|
138
|
+
* is required. Only the in-memory transport sessions are cleared.
|
|
139
|
+
*/
|
|
140
|
+
async reconnect(): Promise<void> {
|
|
141
|
+
await this.disconnect();
|
|
142
|
+
await this.connect();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Returns all currently connected `MCPClient` instances.
|
|
147
|
+
*
|
|
148
|
+
* Use this to enumerate available tools across all connected servers,
|
|
149
|
+
* or to route a tool call to the right client by `serverId`.
|
|
150
|
+
*/
|
|
151
|
+
getClients(): MCPClient[] {
|
|
152
|
+
return this.clients;
|
|
153
|
+
}
|
|
154
|
+
|
|
63
155
|
/**
|
|
64
|
-
*
|
|
65
|
-
* ones that are ready to connect.
|
|
156
|
+
* Gracefully disconnects all active MCP clients and clears the internal list.
|
|
66
157
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
158
|
+
* For Streamable HTTP sessions, each client sends an HTTP DELETE to its MCP
|
|
159
|
+
* endpoint per the spec before closing locally. All disconnects run in
|
|
160
|
+
* parallel so shutdown is not serialised across many sessions.
|
|
161
|
+
*
|
|
162
|
+
* Call this during server shutdown or when a user logs out to free up
|
|
163
|
+
* underlying transport resources (SSE streams, HTTP connections, etc.).
|
|
164
|
+
*/
|
|
165
|
+
async disconnect(): Promise<void> {
|
|
166
|
+
await Promise.all(this.clients.map((client) => client.disconnect()));
|
|
167
|
+
this.clients = [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// -----------------------------------------------------------------------
|
|
171
|
+
// Internals
|
|
172
|
+
// -----------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolves the list of sessions to connect.
|
|
176
|
+
*
|
|
177
|
+
* Uses the custom `sessionProvider` when provided, otherwise falls back
|
|
178
|
+
* to querying the storage backend via `sessions.list(userId)`.
|
|
179
|
+
*/
|
|
180
|
+
private async fetchActiveSessions(): Promise<Session[]> {
|
|
181
|
+
const sessionList = this.options.sessionProvider
|
|
182
|
+
? await this.options.sessionProvider()
|
|
183
|
+
: await sessions.list(this.userId);
|
|
184
|
+
|
|
185
|
+
return sessionList.filter(s =>
|
|
75
186
|
s.serverId &&
|
|
76
187
|
s.serverUrl &&
|
|
77
188
|
s.callbackUrl &&
|
|
78
189
|
s.status === 'active'
|
|
79
190
|
);
|
|
80
|
-
return valid;
|
|
81
191
|
}
|
|
82
192
|
|
|
83
193
|
/**
|
|
84
|
-
* Connects
|
|
194
|
+
* Connects a list of sessions in controlled batches.
|
|
85
195
|
*
|
|
86
|
-
* Batching prevents overwhelming the event loop or external servers when
|
|
87
|
-
* has many active MCP sessions
|
|
88
|
-
*
|
|
196
|
+
* Batching prevents overwhelming the event loop or external servers when
|
|
197
|
+
* a user has many active MCP sessions. Within each batch, sessions are
|
|
198
|
+
* connected concurrently using `Promise.all`.
|
|
89
199
|
*/
|
|
90
200
|
private async connectInBatches(sessions: Session[]): Promise<void> {
|
|
91
201
|
for (let i = 0; i < sessions.length; i += CONNECTION_BATCH_SIZE) {
|
|
@@ -94,25 +204,30 @@ export class MultiSessionClient {
|
|
|
94
204
|
}
|
|
95
205
|
}
|
|
96
206
|
|
|
97
|
-
private connectionPromises = new Map<string, Promise<void>>();
|
|
98
|
-
|
|
99
207
|
/**
|
|
100
|
-
* Connects a single session, with
|
|
208
|
+
* Connects a single session, with deduplication to prevent race conditions.
|
|
101
209
|
*
|
|
102
|
-
* - If a client for this session already exists and is connected, returns
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
210
|
+
* - If a client for this session already exists and is connected, returns
|
|
211
|
+
* immediately.
|
|
212
|
+
* - If the existing client entry is no longer connected (e.g. explicit
|
|
213
|
+
* disconnect), it is evicted so a fresh transport is created.
|
|
214
|
+
* - If a connection attempt for this session is already in-flight, the
|
|
215
|
+
* existing promise is reused as a per-session mutex.
|
|
216
|
+
* - On completion (success or failure), the promise is cleaned up from
|
|
217
|
+
* the connectionPromises map.
|
|
108
218
|
*/
|
|
109
219
|
private async connectSession(session: Session): Promise<void> {
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
220
|
+
const existing = this.clients.find(c => c.getSessionId() === session.sessionId);
|
|
221
|
+
|
|
222
|
+
if (existing) {
|
|
223
|
+
if (existing.isConnected()) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
this.options.onSessionEvicted?.(existing.getSessionId());
|
|
228
|
+
this.clients = this.clients.filter(c => c !== existing);
|
|
113
229
|
}
|
|
114
230
|
|
|
115
|
-
// Avoid concurrent connection attempts for the same session
|
|
116
231
|
if (this.connectionPromises.has(session.sessionId)) {
|
|
117
232
|
return this.connectionPromises.get(session.sessionId)!;
|
|
118
233
|
}
|
|
@@ -129,22 +244,19 @@ export class MultiSessionClient {
|
|
|
129
244
|
}
|
|
130
245
|
|
|
131
246
|
/**
|
|
132
|
-
*
|
|
247
|
+
* Core connection loop for a single session with retry logic.
|
|
133
248
|
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* 2. Races the connect call against a timeout promise — if the server doesn't respond
|
|
138
|
-
* within `timeoutMs`, the attempt is aborted and counted as a failure.
|
|
139
|
-
* 3. On success, replaces any stale client entry for this session in the `clients` array.
|
|
249
|
+
* 1. Creates a fresh `MCPClient` from the session data.
|
|
250
|
+
* 2. Races `client.connect()` against a timeout.
|
|
251
|
+
* 3. On success, replaces any stale entry and fires `onSessionConnected`.
|
|
140
252
|
* 4. On failure, waits `retryDelay` ms before the next attempt.
|
|
141
253
|
*
|
|
142
|
-
* If all attempts are exhausted, logs an error and returns silently
|
|
143
|
-
*
|
|
254
|
+
* If all attempts are exhausted, logs an error and returns silently so
|
|
255
|
+
* a single bad server doesn't block the rest of the batch.
|
|
144
256
|
*/
|
|
145
257
|
private async establishConnectionWithRetries(session: Session): Promise<void> {
|
|
146
|
-
const maxRetries = this.options.maxRetries
|
|
147
|
-
const retryDelay = this.options.retryDelay
|
|
258
|
+
const maxRetries = this.options.maxRetries;
|
|
259
|
+
const retryDelay = this.options.retryDelay;
|
|
148
260
|
let lastError: unknown;
|
|
149
261
|
|
|
150
262
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
@@ -160,10 +272,13 @@ export class MultiSessionClient {
|
|
|
160
272
|
headers: session.headers,
|
|
161
273
|
});
|
|
162
274
|
|
|
163
|
-
const timeoutMs = this.options.timeout
|
|
275
|
+
const timeoutMs = this.options.timeout;
|
|
164
276
|
let timeoutTimer: ReturnType<typeof setTimeout>;
|
|
165
277
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
166
|
-
timeoutTimer = setTimeout(
|
|
278
|
+
timeoutTimer = setTimeout(
|
|
279
|
+
() => reject(new Error(`Connection timed out after ${timeoutMs}ms`)),
|
|
280
|
+
timeoutMs,
|
|
281
|
+
);
|
|
167
282
|
});
|
|
168
283
|
|
|
169
284
|
try {
|
|
@@ -172,10 +287,11 @@ export class MultiSessionClient {
|
|
|
172
287
|
clearTimeout(timeoutTimer!);
|
|
173
288
|
}
|
|
174
289
|
|
|
175
|
-
// Always replace the disconnected client entry
|
|
176
290
|
this.clients = this.clients.filter(c => c.getSessionId() !== session.sessionId);
|
|
177
291
|
this.clients.push(client);
|
|
178
|
-
|
|
292
|
+
this.options.onSessionConnected?.(session.sessionId, client);
|
|
293
|
+
return;
|
|
294
|
+
|
|
179
295
|
} catch (error) {
|
|
180
296
|
lastError = error;
|
|
181
297
|
if (attempt < maxRetries) {
|
|
@@ -184,41 +300,11 @@ export class MultiSessionClient {
|
|
|
184
300
|
}
|
|
185
301
|
}
|
|
186
302
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
*
|
|
194
|
-
* Call this once after creating the client. On traditional servers, you can
|
|
195
|
-
* cache the `MultiSessionClient` instance after calling `connect()` to avoid
|
|
196
|
-
* re-fetching and re-connecting on every request.
|
|
197
|
-
*/
|
|
198
|
-
async connect(): Promise<void> {
|
|
199
|
-
const sessions = await this.getActiveSessions();
|
|
200
|
-
await this.connectInBatches(sessions);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Returns all currently connected `MCPClient` instances.
|
|
205
|
-
*
|
|
206
|
-
* Use this to enumerate available tools across all connected servers,
|
|
207
|
-
* or to route a tool call to the right client by `serverId`.
|
|
208
|
-
*/
|
|
209
|
-
getClients(): MCPClient[] {
|
|
210
|
-
return this.clients;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Gracefully disconnects all active MCP clients and clears the internal client list.
|
|
215
|
-
*
|
|
216
|
-
* Call this during server shutdown or when a user logs out to free up
|
|
217
|
-
* underlying transport resources (SSE streams, HTTP connections, etc.).
|
|
218
|
-
*/
|
|
219
|
-
disconnect(): void {
|
|
220
|
-
this.clients.forEach((client) => client.disconnect());
|
|
221
|
-
this.clients = [];
|
|
303
|
+
this.options.onSessionFailed?.(session.sessionId, lastError);
|
|
304
|
+
console.error(
|
|
305
|
+
`[MultiSessionClient] Failed to connect to session ${session.sessionId} ` +
|
|
306
|
+
`after ${maxRetries + 1} attempts:`,
|
|
307
|
+
lastError,
|
|
308
|
+
);
|
|
222
309
|
}
|
|
223
310
|
}
|
|
224
|
-
|
|
@@ -242,7 +242,7 @@ export class MCPClient {
|
|
|
242
242
|
* Observation: SDK 1.24.0+ connections may hang indefinitely in some environments.
|
|
243
243
|
* This wrapper enforces a timeout and properly uses AbortController to unblock the request.
|
|
244
244
|
*/
|
|
245
|
-
fetch: (url: RequestInfo | URL, init?: RequestInit) => {
|
|
245
|
+
fetch: async (url: RequestInfo | URL, init?: RequestInit) => {
|
|
246
246
|
const timeout = 30000;
|
|
247
247
|
const controller = new AbortController();
|
|
248
248
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
@@ -251,7 +251,20 @@ export class MCPClient {
|
|
|
251
251
|
(AbortSignal.any ? AbortSignal.any([init.signal, controller.signal]) : controller.signal) :
|
|
252
252
|
controller.signal;
|
|
253
253
|
|
|
254
|
-
|
|
254
|
+
try {
|
|
255
|
+
const response = await fetch(url, { ...init, signal });
|
|
256
|
+
|
|
257
|
+
const hasSessionHeader = init?.headers && new Headers(init.headers as HeadersInit).has('mcp-session-id');
|
|
258
|
+
|
|
259
|
+
if (response.status === 404 && hasSessionHeader) {
|
|
260
|
+
this.client = null;
|
|
261
|
+
throw new Error("MCP_SESSION_EXPIRED: Downstream session was not found on the server.");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return response;
|
|
265
|
+
} finally {
|
|
266
|
+
clearTimeout(timeoutId);
|
|
267
|
+
}
|
|
255
268
|
}
|
|
256
269
|
};
|
|
257
270
|
|
|
@@ -497,6 +510,27 @@ export class MCPClient {
|
|
|
497
510
|
* @throws {Error} When connection fails for other reasons
|
|
498
511
|
*/
|
|
499
512
|
async connect(): Promise<void> {
|
|
513
|
+
// Re-entry guard: if a previous SDK Client is still attached to a transport
|
|
514
|
+
// (e.g. a stale session from a prior connect() call on the same MCPClient
|
|
515
|
+
// instance), close and detach it before proceeding. The MCP SDK client will
|
|
516
|
+
// throw if asked to connect while a transport is already attached, and the
|
|
517
|
+
// stale transport would send an expired mcp-session-id to the remote server
|
|
518
|
+
// causing "Session not found. Reconnect without session header." errors.
|
|
519
|
+
//
|
|
520
|
+
// We also null out this.client so that initialize() creates a fresh Client
|
|
521
|
+
// instance with a clean transport slot, rather than reusing the old one.
|
|
522
|
+
// The oauthProvider is intentionally preserved — OAuth tokens remain valid
|
|
523
|
+
// across reconnects; only the transport session needs to be renegotiated.
|
|
524
|
+
if (this.client?.transport) {
|
|
525
|
+
this.transport = null;
|
|
526
|
+
try {
|
|
527
|
+
await this.client.close();
|
|
528
|
+
} catch {
|
|
529
|
+
// Closing a transport that may have already failed is best-effort.
|
|
530
|
+
}
|
|
531
|
+
this.client = null;
|
|
532
|
+
}
|
|
533
|
+
|
|
500
534
|
await this.initialize();
|
|
501
535
|
|
|
502
536
|
if (!this.client || !this.oauthProvider) {
|
|
@@ -997,7 +1031,7 @@ export class MCPClient {
|
|
|
997
1031
|
}
|
|
998
1032
|
|
|
999
1033
|
await sessions.delete(this.userId, this.sessionId);
|
|
1000
|
-
this.disconnect();
|
|
1034
|
+
await this.disconnect();
|
|
1001
1035
|
}
|
|
1002
1036
|
|
|
1003
1037
|
/**
|
|
@@ -1009,10 +1043,29 @@ export class MCPClient {
|
|
|
1009
1043
|
}
|
|
1010
1044
|
|
|
1011
1045
|
/**
|
|
1012
|
-
* Disconnects from the MCP server and cleans up resources
|
|
1013
|
-
* Does not remove session from Redis
|
|
1046
|
+
* Disconnects from the MCP server and cleans up resources.
|
|
1047
|
+
* Does not remove session from Redis — use clearSession() for that.
|
|
1048
|
+
*
|
|
1049
|
+
* For Streamable HTTP sessions, sends an HTTP DELETE to the MCP endpoint
|
|
1050
|
+
* before closing, as recommended by the MCP Streamable HTTP spec
|
|
1051
|
+
* (section "Session Management", rule 5). This is best-effort — errors
|
|
1052
|
+
* (e.g. server already restarted, 404/405 responses) are silently ignored.
|
|
1014
1053
|
*/
|
|
1015
|
-
disconnect(
|
|
1054
|
+
async disconnect(): Promise<void> {
|
|
1055
|
+
// Per the MCP Streamable HTTP spec (2025-11-25), clients SHOULD send an
|
|
1056
|
+
// HTTP DELETE with the mcp-session-id header when they no longer need a
|
|
1057
|
+
// session. The server MAY respond with 405 if it doesn't support explicit
|
|
1058
|
+
// termination — terminateSession() handles that gracefully.
|
|
1059
|
+
// SSEClientTransport has no session concept, so we guard with instanceof.
|
|
1060
|
+
if (this.transport instanceof StreamableHTTPClientTransport) {
|
|
1061
|
+
try {
|
|
1062
|
+
await this.transport.terminateSession();
|
|
1063
|
+
} catch {
|
|
1064
|
+
// Best-effort: server may be unreachable or may have already expired
|
|
1065
|
+
// the session. Either way, we proceed with local cleanup.
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1016
1069
|
if (this.client) {
|
|
1017
1070
|
this.client.close();
|
|
1018
1071
|
}
|
|
@@ -1026,7 +1079,6 @@ export class MCPClient {
|
|
|
1026
1079
|
type: 'disconnected',
|
|
1027
1080
|
sessionId: this.sessionId,
|
|
1028
1081
|
serverId: this.serverId,
|
|
1029
|
-
reason,
|
|
1030
1082
|
timestamp: Date.now(),
|
|
1031
1083
|
});
|
|
1032
1084
|
|
|
@@ -1036,9 +1088,7 @@ export class MCPClient {
|
|
|
1036
1088
|
message: `Disconnected from ${this.serverId}`,
|
|
1037
1089
|
sessionId: this.sessionId,
|
|
1038
1090
|
serverId: this.serverId,
|
|
1039
|
-
payload: {
|
|
1040
|
-
reason: reason || 'unknown',
|
|
1041
|
-
},
|
|
1091
|
+
payload: {},
|
|
1042
1092
|
timestamp: Date.now(),
|
|
1043
1093
|
id: nanoid(),
|
|
1044
1094
|
});
|
package/src/shared/events.ts
CHANGED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Simple event emitter pattern for MCP connection events
|
|
3
|
-
* Inspired by Cloudflare's agents pattern but adapted for serverless
|
|
4
|
-
*/
|
|
5
|
-
type Disposable = {
|
|
6
|
-
dispose(): void;
|
|
7
|
-
};
|
|
8
|
-
type Event<T> = (listener: (event: T) => void) => Disposable;
|
|
9
|
-
/**
|
|
10
|
-
* Event emitter class for type-safe event handling
|
|
11
|
-
* Similar to Cloudflare's Emitter but simplified for our use case
|
|
12
|
-
*/
|
|
13
|
-
declare class Emitter<T> {
|
|
14
|
-
private listeners;
|
|
15
|
-
/**
|
|
16
|
-
* Subscribe to events
|
|
17
|
-
* @param listener - Callback function to handle events
|
|
18
|
-
* @returns Disposable to unsubscribe
|
|
19
|
-
*/
|
|
20
|
-
get event(): Event<T>;
|
|
21
|
-
/**
|
|
22
|
-
* Fire an event to all listeners
|
|
23
|
-
* @param event - Event data to emit
|
|
24
|
-
*/
|
|
25
|
-
fire(event: T): void;
|
|
26
|
-
/**
|
|
27
|
-
* Clear all listeners
|
|
28
|
-
*/
|
|
29
|
-
dispose(): void;
|
|
30
|
-
/**
|
|
31
|
-
* Get number of active listeners
|
|
32
|
-
*/
|
|
33
|
-
get listenerCount(): number;
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Connection state types matching your existing ConnectionStatus
|
|
37
|
-
* Extended with more granular states for better observability
|
|
38
|
-
*/
|
|
39
|
-
type McpConnectionState = 'DISCONNECTED' | 'CONNECTING' | 'AUTHENTICATING' | 'AUTHENTICATED' | 'DISCOVERING' | 'CONNECTED' | 'READY' | 'VALIDATING' | 'RECONNECTING' | 'INITIALIZING' | 'FAILED';
|
|
40
|
-
/**
|
|
41
|
-
* MCP Connection Event Types
|
|
42
|
-
* Discriminated union for type-safe event handling
|
|
43
|
-
*/
|
|
44
|
-
type McpConnectionEvent = {
|
|
45
|
-
type: 'state_changed';
|
|
46
|
-
sessionId: string;
|
|
47
|
-
serverId: string;
|
|
48
|
-
serverName: string;
|
|
49
|
-
serverUrl: string;
|
|
50
|
-
createdAt?: number;
|
|
51
|
-
state: McpConnectionState;
|
|
52
|
-
previousState: McpConnectionState;
|
|
53
|
-
timestamp: number;
|
|
54
|
-
} | {
|
|
55
|
-
type: 'tools_discovered';
|
|
56
|
-
sessionId: string;
|
|
57
|
-
serverId: string;
|
|
58
|
-
toolCount: number;
|
|
59
|
-
tools: any[];
|
|
60
|
-
timestamp: number;
|
|
61
|
-
} | {
|
|
62
|
-
type: 'auth_required';
|
|
63
|
-
sessionId: string;
|
|
64
|
-
serverId: string;
|
|
65
|
-
authUrl: string;
|
|
66
|
-
timestamp: number;
|
|
67
|
-
} | {
|
|
68
|
-
type: 'error';
|
|
69
|
-
sessionId: string;
|
|
70
|
-
serverId: string;
|
|
71
|
-
error: string;
|
|
72
|
-
errorType: 'connection' | 'auth' | 'validation' | 'unknown';
|
|
73
|
-
timestamp: number;
|
|
74
|
-
} | {
|
|
75
|
-
type: 'disconnected';
|
|
76
|
-
sessionId: string;
|
|
77
|
-
serverId: string;
|
|
78
|
-
reason?: string;
|
|
79
|
-
timestamp: number;
|
|
80
|
-
} | {
|
|
81
|
-
type: 'progress';
|
|
82
|
-
sessionId: string;
|
|
83
|
-
serverId: string;
|
|
84
|
-
message: string;
|
|
85
|
-
timestamp: number;
|
|
86
|
-
};
|
|
87
|
-
/**
|
|
88
|
-
* Event fired when a tool execution returns a UI resource URI
|
|
89
|
-
*/
|
|
90
|
-
interface McpAppsUIEvent {
|
|
91
|
-
type: 'mcp-apps-ui';
|
|
92
|
-
sessionId: string;
|
|
93
|
-
resourceUri: string;
|
|
94
|
-
toolName: string;
|
|
95
|
-
result: unknown;
|
|
96
|
-
timestamp: number;
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Observability event for debugging and monitoring
|
|
100
|
-
*/
|
|
101
|
-
interface McpObservabilityEvent {
|
|
102
|
-
type?: string;
|
|
103
|
-
level?: 'debug' | 'info' | 'warn' | 'error';
|
|
104
|
-
message?: string;
|
|
105
|
-
displayMessage?: string;
|
|
106
|
-
sessionId?: string;
|
|
107
|
-
serverId?: string;
|
|
108
|
-
payload?: Record<string, any>;
|
|
109
|
-
metadata?: Record<string, any>;
|
|
110
|
-
timestamp: number;
|
|
111
|
-
id?: string;
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* DisposableStore for managing multiple disposables
|
|
115
|
-
* Useful for cleanup in React hooks
|
|
116
|
-
*/
|
|
117
|
-
declare class DisposableStore {
|
|
118
|
-
private disposables;
|
|
119
|
-
add(disposable: Disposable): void;
|
|
120
|
-
dispose(): void;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export { type Disposable as D, Emitter as E, type McpConnectionEvent as M, DisposableStore as a, type Event as b, type McpConnectionState as c, type McpObservabilityEvent as d, type McpAppsUIEvent as e };
|