@mcp-ts/sdk 2.3.4 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -39
- package/dist/adapters/agui-adapter.d.mts +4 -4
- package/dist/adapters/agui-adapter.d.ts +4 -4
- package/dist/adapters/agui-middleware.d.mts +4 -4
- package/dist/adapters/agui-middleware.d.ts +4 -4
- package/dist/adapters/ai-adapter.d.mts +4 -4
- package/dist/adapters/ai-adapter.d.ts +4 -4
- package/dist/adapters/langchain-adapter.d.mts +4 -4
- package/dist/adapters/langchain-adapter.d.ts +4 -4
- package/dist/adapters/mastra-adapter.d.mts +2 -2
- package/dist/adapters/mastra-adapter.d.ts +2 -2
- package/dist/client/index.d.mts +3 -3
- package/dist/client/index.d.ts +3 -3
- package/dist/client/index.js +2 -2
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +2 -2
- package/dist/client/index.mjs.map +1 -1
- package/dist/client/react.d.mts +11 -10
- package/dist/client/react.d.ts +11 -10
- package/dist/client/react.js +66 -26
- package/dist/client/react.js.map +1 -1
- package/dist/client/react.mjs +67 -27
- package/dist/client/react.mjs.map +1 -1
- package/dist/client/vue.d.mts +8 -7
- package/dist/client/vue.d.ts +8 -7
- package/dist/client/vue.js +27 -17
- package/dist/client/vue.js.map +1 -1
- package/dist/client/vue.mjs +27 -17
- package/dist/client/vue.mjs.map +1 -1
- package/dist/{events-CK3N--3g.d.mts → events-C4m7tK1U.d.mts} +0 -1
- package/dist/{events-CK3N--3g.d.ts → events-C4m7tK1U.d.ts} +0 -1
- package/dist/{index-CmjMd2ac.d.ts → index-Ch7ouNSa.d.ts} +3 -3
- package/dist/{index-Cfjsme-a.d.mts → index-L5XoXgsb.d.mts} +3 -3
- package/dist/index.d.mts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.js +799 -326
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +793 -324
- package/dist/index.mjs.map +1 -1
- package/dist/{multi-session-client-C_kPHpD5.d.ts → multi-session-client-Bwm-efqg.d.ts} +43 -38
- package/dist/{multi-session-client-C7hGqzgM.d.mts → multi-session-client-k-9RvWvi.d.mts} +43 -38
- package/dist/server/index.d.mts +48 -20
- package/dist/server/index.d.ts +48 -20
- package/dist/server/index.js +791 -320
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +787 -319
- package/dist/server/index.mjs.map +1 -1
- package/dist/shared/index.d.mts +10 -9
- package/dist/shared/index.d.ts +10 -9
- package/dist/shared/index.js +7 -5
- package/dist/shared/index.js.map +1 -1
- package/dist/shared/index.mjs +5 -4
- package/dist/shared/index.mjs.map +1 -1
- package/dist/{tool-router-BMzhoNYt.d.ts → tool-router-CZMrOG8J.d.ts} +1 -1
- package/dist/{tool-router-BhHsvBfP.d.mts → tool-router-CuApsDiV.d.mts} +1 -1
- package/dist/{types-CxFaaZrM.d.mts → types-DCk_IF4L.d.mts} +5 -3
- package/dist/{types-CxFaaZrM.d.ts → types-DCk_IF4L.d.ts} +5 -3
- package/migrations/neon/20260513010000_install_mcp_sessions.sql +40 -3
- package/migrations/neon/20260513020000_add_session_cleanup_cron.sql +4 -4
- package/migrations/supabase/20260330195700_install_mcp_sessions.sql +67 -3
- package/migrations/supabase/20260421010000_add_session_cleanup_cron.sql +6 -6
- package/migrations/v2.3.4/neon/20260513010000_install_mcp_sessions.sql +69 -0
- package/migrations/v2.3.4/neon/20260513020000_add_session_cleanup_cron.sql +35 -0
- package/migrations/v2.3.4/supabase/20260330195700_install_mcp_sessions.sql +82 -0
- package/migrations/v2.3.4/supabase/20260421010000_add_session_cleanup_cron.sql +32 -0
- package/package.json +13 -3
- package/src/client/core/sse-client.ts +2 -2
- package/src/client/react/index.ts +1 -1
- package/src/client/react/oauth-popup.tsx +34 -13
- package/src/client/react/use-mcp.ts +19 -45
- package/src/client/utils/session-state.ts +43 -0
- package/src/client/vue/use-mcp.ts +18 -47
- package/src/server/handlers/sse-handler.ts +16 -9
- package/src/server/mcp/multi-session-client.ts +48 -11
- package/src/server/mcp/oauth-client.ts +133 -111
- package/src/server/mcp/storage-oauth-provider.ts +79 -50
- package/src/server/storage/file-backend.ts +74 -18
- package/src/server/storage/index.ts +2 -4
- package/src/server/storage/memory-backend.ts +49 -13
- package/src/server/storage/neon-backend.ts +201 -68
- package/src/server/storage/redis-backend.ts +81 -23
- package/src/server/storage/session-lifecycle.ts +78 -0
- package/src/server/storage/sqlite-backend.ts +89 -15
- package/src/server/storage/supabase-backend.ts +188 -63
- package/src/server/storage/types.ts +49 -16
- package/src/shared/constants.ts +5 -6
- package/src/shared/events.ts +0 -1
- package/src/shared/types.ts +5 -3
- package/src/shared/utils.ts +26 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
|
2
|
+
import { parseOAuthState } from '../../shared/utils.js';
|
|
2
3
|
|
|
3
4
|
export interface OAuthPopupConnectionLike {
|
|
4
5
|
sessionId: string;
|
|
@@ -11,7 +12,7 @@ export interface OAuthPopupConnectionLike {
|
|
|
11
12
|
*
|
|
12
13
|
* These utilities sit on top of the core MCP auth primitives:
|
|
13
14
|
* - `useMcp({ onRedirect })` to decide how auth navigation happens
|
|
14
|
-
* - `finishAuth(
|
|
15
|
+
* - `finishAuth(state, code)` to complete code exchange
|
|
15
16
|
*
|
|
16
17
|
* Consumers are free to:
|
|
17
18
|
* - use these helpers as-is for a turnkey popup flow
|
|
@@ -60,6 +61,7 @@ function postPopupResult(
|
|
|
60
61
|
popupWindow: WindowProxy | null,
|
|
61
62
|
result: {
|
|
62
63
|
sessionId?: string;
|
|
64
|
+
state?: string;
|
|
63
65
|
success: boolean;
|
|
64
66
|
error?: string;
|
|
65
67
|
}
|
|
@@ -150,9 +152,9 @@ export function createOAuthPopupRedirectHandler(
|
|
|
150
152
|
*/
|
|
151
153
|
export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
|
|
152
154
|
connections: TConnection[],
|
|
153
|
-
finishAuth: (
|
|
155
|
+
finishAuth: (state: string, code: string) => Promise<unknown>
|
|
154
156
|
): void {
|
|
155
|
-
const pendingPopupsRef = useRef<Map<string, WindowProxy>>(new Map());
|
|
157
|
+
const pendingPopupsRef = useRef<Map<string, { popupWindow: WindowProxy | null; state: string }>>(new Map());
|
|
156
158
|
const processingCodesRef = useRef<Set<string>>(new Set());
|
|
157
159
|
|
|
158
160
|
useEffect(() => {
|
|
@@ -169,10 +171,16 @@ export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
|
|
|
169
171
|
const popupWindow = event.source && 'postMessage' in event.source
|
|
170
172
|
? event.source as WindowProxy
|
|
171
173
|
: null;
|
|
172
|
-
const
|
|
174
|
+
const rawState =
|
|
175
|
+
typeof event.data.state === 'string'
|
|
176
|
+
? event.data.state
|
|
177
|
+
: typeof event.data.sessionId === 'string'
|
|
178
|
+
? event.data.sessionId
|
|
179
|
+
: '';
|
|
180
|
+
const targetSessionId = rawState ? parseOAuthState(rawState)?.sessionId || rawState : '';
|
|
173
181
|
|
|
174
182
|
if (popupWindow && targetSessionId) {
|
|
175
|
-
pendingPopupsRef.current.set(targetSessionId, popupWindow);
|
|
183
|
+
pendingPopupsRef.current.set(targetSessionId, { popupWindow, state: rawState });
|
|
176
184
|
}
|
|
177
185
|
|
|
178
186
|
if (!targetSessionId) {
|
|
@@ -190,6 +198,7 @@ export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
|
|
|
190
198
|
if (popupWindow) {
|
|
191
199
|
postPopupResult(popupWindow, {
|
|
192
200
|
sessionId: targetSessionId,
|
|
201
|
+
state: rawState,
|
|
193
202
|
success: false,
|
|
194
203
|
error: 'OAuth session not found in the current client state',
|
|
195
204
|
});
|
|
@@ -204,13 +213,14 @@ export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
|
|
|
204
213
|
processingCodesRef.current.add(codeKey);
|
|
205
214
|
|
|
206
215
|
try {
|
|
207
|
-
await finishAuth(
|
|
216
|
+
await finishAuth(rawState, code);
|
|
208
217
|
} catch (error) {
|
|
209
218
|
processingCodesRef.current.delete(codeKey);
|
|
210
219
|
pendingPopupsRef.current.delete(targetSession.sessionId);
|
|
211
220
|
if (popupWindow) {
|
|
212
221
|
postPopupResult(popupWindow, {
|
|
213
|
-
sessionId:
|
|
222
|
+
sessionId: rawState,
|
|
223
|
+
state: rawState,
|
|
214
224
|
success: false,
|
|
215
225
|
error: error instanceof Error ? error.message : 'Failed to finish auth',
|
|
216
226
|
});
|
|
@@ -237,11 +247,14 @@ export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
|
|
|
237
247
|
|
|
238
248
|
useEffect(() => {
|
|
239
249
|
for (const connection of connections) {
|
|
240
|
-
const
|
|
250
|
+
const pendingPopup = pendingPopupsRef.current.get(connection.sessionId);
|
|
251
|
+
const popupWindow = pendingPopup?.popupWindow || null;
|
|
252
|
+
const resultState = pendingPopup?.state || connection.sessionId;
|
|
241
253
|
|
|
242
254
|
if (connection.state === 'AUTHENTICATED' || connection.state === 'READY' || connection.state === 'CONNECTED') {
|
|
243
255
|
postPopupResult(popupWindow, {
|
|
244
|
-
sessionId:
|
|
256
|
+
sessionId: resultState,
|
|
257
|
+
state: resultState,
|
|
245
258
|
success: true,
|
|
246
259
|
});
|
|
247
260
|
for (const codeKey of processingCodesRef.current) {
|
|
@@ -255,7 +268,8 @@ export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
|
|
|
255
268
|
|
|
256
269
|
if (connection.state === 'FAILED') {
|
|
257
270
|
postPopupResult(popupWindow, {
|
|
258
|
-
sessionId:
|
|
271
|
+
sessionId: resultState,
|
|
272
|
+
state: resultState,
|
|
259
273
|
success: false,
|
|
260
274
|
error: connection.error || 'Failed to complete authorization',
|
|
261
275
|
});
|
|
@@ -277,7 +291,7 @@ export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
|
|
|
277
291
|
* opener window, waits for success/failure, and closes the popup on success.
|
|
278
292
|
*
|
|
279
293
|
* It is intentionally optional: apps can replace it with their own callback
|
|
280
|
-
* page UI or skip popup auth entirely and call `finishAuth(
|
|
294
|
+
* page UI or skip popup auth entirely and call `finishAuth(state, code)`
|
|
281
295
|
* from any callback route they control.
|
|
282
296
|
*/
|
|
283
297
|
export function McpOAuthCallbackContent({
|
|
@@ -328,7 +342,14 @@ export function McpOAuthCallbackContent({
|
|
|
328
342
|
return;
|
|
329
343
|
}
|
|
330
344
|
|
|
331
|
-
|
|
345
|
+
const resultState =
|
|
346
|
+
typeof event.data.state === 'string'
|
|
347
|
+
? event.data.state
|
|
348
|
+
: typeof event.data.sessionId === 'string'
|
|
349
|
+
? event.data.sessionId
|
|
350
|
+
: '';
|
|
351
|
+
|
|
352
|
+
if (resultState !== sessionId) {
|
|
332
353
|
return;
|
|
333
354
|
}
|
|
334
355
|
|
|
@@ -352,7 +373,7 @@ export function McpOAuthCallbackContent({
|
|
|
352
373
|
window.addEventListener('message', handleResult);
|
|
353
374
|
channel?.addEventListener('message', handleResult);
|
|
354
375
|
|
|
355
|
-
const payload = { type: AUTH_CODE_MESSAGE, code, sessionId };
|
|
376
|
+
const payload = { type: AUTH_CODE_MESSAGE, code, state: sessionId, sessionId };
|
|
356
377
|
|
|
357
378
|
if (window.opener) {
|
|
358
379
|
try {
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
|
8
8
|
import { SSEClient, type SSEClientOptions } from '../core/sse-client';
|
|
9
|
+
import {
|
|
10
|
+
getInitialConnectionState,
|
|
11
|
+
getVisibleConnectionState,
|
|
12
|
+
isTransientReconnectState,
|
|
13
|
+
} from '../utils/session-state';
|
|
9
14
|
import type { McpConnectionEvent, McpConnectionState } from '../../shared/events';
|
|
10
15
|
import type {
|
|
11
16
|
ToolInfo,
|
|
@@ -84,6 +89,7 @@ export interface McpConnection {
|
|
|
84
89
|
authUrl?: string;
|
|
85
90
|
error?: string;
|
|
86
91
|
createdAt?: Date;
|
|
92
|
+
updatedAt?: Date;
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
export interface McpClient {
|
|
@@ -167,7 +173,7 @@ export interface McpClient {
|
|
|
167
173
|
/**
|
|
168
174
|
* Complete OAuth authorization
|
|
169
175
|
*/
|
|
170
|
-
finishAuth: (
|
|
176
|
+
finishAuth: (state: string, code: string) => Promise<FinishAuthResult>;
|
|
171
177
|
|
|
172
178
|
/**
|
|
173
179
|
* Explicitly resume OAuth flow for an existing session
|
|
@@ -293,42 +299,6 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
293
299
|
const updateConnectionsFromEvent = useCallback((event: McpConnectionEvent) => {
|
|
294
300
|
if (!isMountedRef.current) return;
|
|
295
301
|
|
|
296
|
-
const isTransientReconnectState = (state: McpConnectionState): boolean =>
|
|
297
|
-
state === 'INITIALIZING' ||
|
|
298
|
-
state === 'VALIDATING' ||
|
|
299
|
-
state === 'RECONNECTING' ||
|
|
300
|
-
state === 'CONNECTING' ||
|
|
301
|
-
state === 'CONNECTED' ||
|
|
302
|
-
state === 'DISCOVERING';
|
|
303
|
-
|
|
304
|
-
const getVisibleState = (
|
|
305
|
-
incomingState: McpConnectionState,
|
|
306
|
-
existingState?: McpConnectionState,
|
|
307
|
-
previousState?: McpConnectionState
|
|
308
|
-
): McpConnectionState => {
|
|
309
|
-
// `INITIALIZING` has two meanings in practice:
|
|
310
|
-
// 1. genuine cold start / reconnect work
|
|
311
|
-
// 2. an internal setup step that happens mid-OAuth completion
|
|
312
|
-
//
|
|
313
|
-
// For case (2), showing raw `INITIALIZING` creates a confusing user-facing
|
|
314
|
-
// sequence like AUTHENTICATING -> INITIALIZING -> AUTHENTICATED.
|
|
315
|
-
// We keep the raw event stream intact for observability, but collapse the
|
|
316
|
-
// visible state back into the current auth phase in the UI.
|
|
317
|
-
if (
|
|
318
|
-
incomingState === 'INITIALIZING' &&
|
|
319
|
-
(existingState === 'AUTHENTICATING' ||
|
|
320
|
-
existingState === 'AUTHENTICATED' ||
|
|
321
|
-
previousState === 'AUTHENTICATING' ||
|
|
322
|
-
previousState === 'AUTHENTICATED')
|
|
323
|
-
) {
|
|
324
|
-
return existingState === 'AUTHENTICATED' || previousState === 'AUTHENTICATED'
|
|
325
|
-
? 'AUTHENTICATED'
|
|
326
|
-
: 'AUTHENTICATING';
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
return incomingState;
|
|
330
|
-
};
|
|
331
|
-
|
|
332
302
|
setConnections((prev: McpConnection[]) => {
|
|
333
303
|
switch (event.type) {
|
|
334
304
|
case 'state_changed': {
|
|
@@ -336,7 +306,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
336
306
|
if (existing) {
|
|
337
307
|
// Normalize the incoming backend state into the smoother user-facing
|
|
338
308
|
// state we want to render for this existing connection.
|
|
339
|
-
const normalizedState =
|
|
309
|
+
const normalizedState = getVisibleConnectionState(event.state, existing.state, event.previousState);
|
|
340
310
|
// In stateless per-request transport, tool calls can emit transient reconnect states.
|
|
341
311
|
// Keep READY sticky to avoid UI flicker from READY -> CONNECTING -> CONNECTED.
|
|
342
312
|
const nextState =
|
|
@@ -344,12 +314,14 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
344
314
|
? existing.state
|
|
345
315
|
: normalizedState;
|
|
346
316
|
|
|
317
|
+
const updatedAt = new Date();
|
|
347
318
|
return prev.map((c: McpConnection) =>
|
|
348
319
|
c.sessionId === event.sessionId ? {
|
|
349
320
|
...c,
|
|
350
321
|
state: nextState,
|
|
351
322
|
// update createdAt if present in event, otherwise keep existing
|
|
352
|
-
createdAt: event.createdAt ? new Date(event.createdAt) : c.createdAt
|
|
323
|
+
createdAt: event.createdAt ? new Date(event.createdAt) : c.createdAt,
|
|
324
|
+
updatedAt,
|
|
353
325
|
} : c
|
|
354
326
|
);
|
|
355
327
|
} else {
|
|
@@ -367,8 +339,9 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
367
339
|
serverUrl: event.serverUrl,
|
|
368
340
|
// New connections do not have prior local state, so we normalize
|
|
369
341
|
// only against the server-reported previous state.
|
|
370
|
-
state:
|
|
342
|
+
state: getVisibleConnectionState(event.state, undefined, event.previousState),
|
|
371
343
|
createdAt: event.createdAt ? new Date(event.createdAt) : undefined,
|
|
344
|
+
updatedAt: new Date(),
|
|
372
345
|
tools: [],
|
|
373
346
|
},
|
|
374
347
|
];
|
|
@@ -382,7 +355,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
382
355
|
}
|
|
383
356
|
|
|
384
357
|
return prev.map((c: McpConnection) =>
|
|
385
|
-
c.sessionId === event.sessionId ? { ...c, tools: event.tools, state: 'READY' } : c
|
|
358
|
+
c.sessionId === event.sessionId ? { ...c, tools: event.tools, state: 'READY', updatedAt: new Date() } : c
|
|
386
359
|
);
|
|
387
360
|
}
|
|
388
361
|
|
|
@@ -453,8 +426,9 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
453
426
|
serverName: s.serverName ?? 'Unknown Server',
|
|
454
427
|
serverUrl: s.serverUrl,
|
|
455
428
|
transport: s.transport,
|
|
456
|
-
state: (s.
|
|
429
|
+
state: getInitialConnectionState(s.status),
|
|
457
430
|
createdAt: new Date(s.createdAt),
|
|
431
|
+
updatedAt: new Date(s.updatedAt ?? s.createdAt),
|
|
458
432
|
tools: [],
|
|
459
433
|
}))
|
|
460
434
|
);
|
|
@@ -466,7 +440,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
466
440
|
if (clientRef.current) {
|
|
467
441
|
try {
|
|
468
442
|
// Pending auth sessions should not auto-trigger popup/redirect on reload.
|
|
469
|
-
if (session.
|
|
443
|
+
if (session.status !== 'active') {
|
|
470
444
|
return;
|
|
471
445
|
}
|
|
472
446
|
suppressAuthRedirectSessionsRef.current.add(session.sessionId);
|
|
@@ -585,12 +559,12 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
585
559
|
/**
|
|
586
560
|
* Complete OAuth authorization
|
|
587
561
|
*/
|
|
588
|
-
const finishAuth = useCallback(async (
|
|
562
|
+
const finishAuth = useCallback(async (state: string, code: string): Promise<FinishAuthResult> => {
|
|
589
563
|
if (!clientRef.current) {
|
|
590
564
|
throw new Error('SSE client not initialized');
|
|
591
565
|
}
|
|
592
566
|
|
|
593
|
-
return await clientRef.current.finishAuth(
|
|
567
|
+
return await clientRef.current.finishAuth(state, code);
|
|
594
568
|
}, []);
|
|
595
569
|
|
|
596
570
|
/**
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { McpConnectionState } from '../../shared/events';
|
|
2
|
+
import type { SessionInfo } from '../../shared/types';
|
|
3
|
+
|
|
4
|
+
export function getInitialConnectionState(status: SessionInfo['status']): McpConnectionState {
|
|
5
|
+
return status === 'active' ? 'VALIDATING' : 'AUTHENTICATING';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function isTransientReconnectState(state: McpConnectionState): boolean {
|
|
9
|
+
return state === 'INITIALIZING' ||
|
|
10
|
+
state === 'VALIDATING' ||
|
|
11
|
+
state === 'RECONNECTING' ||
|
|
12
|
+
state === 'CONNECTING' ||
|
|
13
|
+
state === 'CONNECTED' ||
|
|
14
|
+
state === 'DISCOVERING';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getVisibleConnectionState(
|
|
18
|
+
incomingState: McpConnectionState,
|
|
19
|
+
existingState?: McpConnectionState,
|
|
20
|
+
previousState?: McpConnectionState
|
|
21
|
+
): McpConnectionState {
|
|
22
|
+
// `INITIALIZING` has two meanings in practice:
|
|
23
|
+
// 1. genuine cold start / reconnect work
|
|
24
|
+
// 2. an internal setup step that happens mid-OAuth completion
|
|
25
|
+
//
|
|
26
|
+
// For case (2), showing raw `INITIALIZING` creates a confusing user-facing
|
|
27
|
+
// sequence like AUTHENTICATING -> INITIALIZING -> AUTHENTICATED.
|
|
28
|
+
if (
|
|
29
|
+
incomingState === 'INITIALIZING' &&
|
|
30
|
+
(
|
|
31
|
+
existingState === 'AUTHENTICATING' ||
|
|
32
|
+
existingState === 'AUTHENTICATED' ||
|
|
33
|
+
previousState === 'AUTHENTICATING' ||
|
|
34
|
+
previousState === 'AUTHENTICATED'
|
|
35
|
+
)
|
|
36
|
+
) {
|
|
37
|
+
return existingState === 'AUTHENTICATED' || previousState === 'AUTHENTICATED'
|
|
38
|
+
? 'AUTHENTICATED'
|
|
39
|
+
: 'AUTHENTICATING';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return incomingState;
|
|
43
|
+
}
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
import { ref, onMounted, onUnmounted, watch, computed, shallowRef } from 'vue';
|
|
8
8
|
import { SSEClient, type SSEClientOptions } from '../core/sse-client';
|
|
9
|
+
import {
|
|
10
|
+
getInitialConnectionState,
|
|
11
|
+
getVisibleConnectionState,
|
|
12
|
+
isTransientReconnectState,
|
|
13
|
+
} from '../utils/session-state';
|
|
9
14
|
import type { McpConnectionEvent, McpConnectionState } from '../../shared/events';
|
|
10
15
|
import type {
|
|
11
16
|
ToolInfo,
|
|
@@ -83,6 +88,7 @@ export interface McpConnection {
|
|
|
83
88
|
authUrl?: string;
|
|
84
89
|
error?: string;
|
|
85
90
|
createdAt?: Date;
|
|
91
|
+
updatedAt?: Date;
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
export interface McpClient {
|
|
@@ -166,7 +172,7 @@ export interface McpClient {
|
|
|
166
172
|
/**
|
|
167
173
|
* Complete OAuth authorization
|
|
168
174
|
*/
|
|
169
|
-
finishAuth: (
|
|
175
|
+
finishAuth: (state: string, code: string) => Promise<FinishAuthResult>;
|
|
170
176
|
|
|
171
177
|
/**
|
|
172
178
|
* Explicitly resume OAuth flow for an existing session
|
|
@@ -243,51 +249,13 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
243
249
|
const updateConnectionsFromEvent = (event: McpConnectionEvent) => {
|
|
244
250
|
if (!isMountedRef.value) return;
|
|
245
251
|
|
|
246
|
-
const isTransientReconnectState = (state: McpConnectionState): boolean =>
|
|
247
|
-
state === 'INITIALIZING' ||
|
|
248
|
-
state === 'VALIDATING' ||
|
|
249
|
-
state === 'RECONNECTING' ||
|
|
250
|
-
state === 'CONNECTING' ||
|
|
251
|
-
state === 'CONNECTED' ||
|
|
252
|
-
state === 'DISCOVERING';
|
|
253
|
-
|
|
254
|
-
const getVisibleState = (
|
|
255
|
-
incomingState: McpConnectionState,
|
|
256
|
-
existingState?: McpConnectionState,
|
|
257
|
-
previousState?: McpConnectionState
|
|
258
|
-
): McpConnectionState => {
|
|
259
|
-
// `INITIALIZING` has two meanings in practice:
|
|
260
|
-
// 1. genuine cold start / reconnect work
|
|
261
|
-
// 2. an internal setup step that happens mid-OAuth completion
|
|
262
|
-
//
|
|
263
|
-
// For case (2), showing raw `INITIALIZING` creates a confusing user-facing
|
|
264
|
-
// sequence like AUTHENTICATING -> INITIALIZING -> AUTHENTICATED.
|
|
265
|
-
// We keep the raw event stream intact for observability, but collapse the
|
|
266
|
-
// visible state back into the current auth phase in the UI.
|
|
267
|
-
if (
|
|
268
|
-
incomingState === 'INITIALIZING' &&
|
|
269
|
-
(
|
|
270
|
-
existingState === 'AUTHENTICATING' ||
|
|
271
|
-
existingState === 'AUTHENTICATED' ||
|
|
272
|
-
previousState === 'AUTHENTICATING' ||
|
|
273
|
-
previousState === 'AUTHENTICATED'
|
|
274
|
-
)
|
|
275
|
-
) {
|
|
276
|
-
return existingState === 'AUTHENTICATED' || previousState === 'AUTHENTICATED'
|
|
277
|
-
? 'AUTHENTICATED'
|
|
278
|
-
: 'AUTHENTICATING';
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
return incomingState;
|
|
282
|
-
};
|
|
283
|
-
|
|
284
252
|
switch (event.type) {
|
|
285
253
|
case 'state_changed': {
|
|
286
254
|
const existing = connections.value.find((c) => c.sessionId === event.sessionId);
|
|
287
255
|
if (existing) {
|
|
288
256
|
// Normalize the incoming backend state into the smoother user-facing
|
|
289
257
|
// state we want to render for this existing connection.
|
|
290
|
-
const normalizedState =
|
|
258
|
+
const normalizedState = getVisibleConnectionState(event.state, existing.state, event.previousState);
|
|
291
259
|
// In stateless per-request transport, tool calls can emit transient reconnect states.
|
|
292
260
|
// Keep READY sticky to avoid UI flicker from READY -> CONNECTING -> CONNECTED.
|
|
293
261
|
const nextState =
|
|
@@ -300,7 +268,8 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
300
268
|
...existing,
|
|
301
269
|
state: nextState,
|
|
302
270
|
// update createdAt if present in event, otherwise keep existing
|
|
303
|
-
createdAt: event.createdAt ? new Date(event.createdAt) : existing.createdAt
|
|
271
|
+
createdAt: event.createdAt ? new Date(event.createdAt) : existing.createdAt,
|
|
272
|
+
updatedAt: new Date(),
|
|
304
273
|
};
|
|
305
274
|
} else {
|
|
306
275
|
// Fix: Don't add back disconnected sessions that were just removed
|
|
@@ -314,8 +283,9 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
314
283
|
serverName: event.serverName,
|
|
315
284
|
// New connections do not have prior local state, so we normalize
|
|
316
285
|
// only against the server-reported previous state.
|
|
317
|
-
state:
|
|
286
|
+
state: getVisibleConnectionState(event.state, undefined, event.previousState),
|
|
318
287
|
createdAt: event.createdAt ? new Date(event.createdAt) : undefined,
|
|
288
|
+
updatedAt: new Date(),
|
|
319
289
|
tools: [],
|
|
320
290
|
}];
|
|
321
291
|
}
|
|
@@ -325,7 +295,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
325
295
|
case 'tools_discovered': {
|
|
326
296
|
const index = connections.value.findIndex((c) => c.sessionId === event.sessionId);
|
|
327
297
|
if (index !== -1) {
|
|
328
|
-
connections.value[index] = { ...connections.value[index], tools: event.tools, state: 'READY' };
|
|
298
|
+
connections.value[index] = { ...connections.value[index], tools: event.tools, state: 'READY', updatedAt: new Date() };
|
|
329
299
|
}
|
|
330
300
|
break;
|
|
331
301
|
}
|
|
@@ -397,8 +367,9 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
397
367
|
serverName: s.serverName ?? 'Unknown Server',
|
|
398
368
|
serverUrl: s.serverUrl,
|
|
399
369
|
transport: s.transport,
|
|
400
|
-
state: (s.
|
|
370
|
+
state: getInitialConnectionState(s.status),
|
|
401
371
|
createdAt: new Date(s.createdAt),
|
|
372
|
+
updatedAt: new Date(s.updatedAt ?? s.createdAt),
|
|
402
373
|
tools: [],
|
|
403
374
|
}));
|
|
404
375
|
}
|
|
@@ -409,7 +380,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
409
380
|
if (clientRef.value) {
|
|
410
381
|
try {
|
|
411
382
|
// Pending auth sessions should not auto-trigger popup/redirect on reload.
|
|
412
|
-
if (session.
|
|
383
|
+
if (session.status !== 'active') {
|
|
413
384
|
return;
|
|
414
385
|
}
|
|
415
386
|
suppressAuthRedirectSessions.value.add(session.sessionId);
|
|
@@ -573,12 +544,12 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
573
544
|
/**
|
|
574
545
|
* Complete OAuth authorization
|
|
575
546
|
*/
|
|
576
|
-
const finishAuth = async (
|
|
547
|
+
const finishAuth = async (state: string, code: string): Promise<FinishAuthResult> => {
|
|
577
548
|
if (!clientRef.value) {
|
|
578
549
|
throw new Error('SSE client not initialized');
|
|
579
550
|
}
|
|
580
551
|
|
|
581
|
-
return await clientRef.value.finishAuth(
|
|
552
|
+
return await clientRef.value.finishAuth(state, code);
|
|
582
553
|
};
|
|
583
554
|
|
|
584
555
|
/**
|
|
@@ -36,6 +36,7 @@ import type {
|
|
|
36
36
|
import { RpcErrorCodes } from '../../shared/errors.js';
|
|
37
37
|
import { UnauthorizedError } from '../../shared/errors.js';
|
|
38
38
|
import { isConnectionEvent, isRpcResponseEvent } from '../../shared/event-routing.js';
|
|
39
|
+
import { parseOAuthState } from '../../shared/utils.js';
|
|
39
40
|
import { MCPClient } from '../mcp/oauth-client.js';
|
|
40
41
|
import { sessions } from '../storage/index.js';
|
|
41
42
|
|
|
@@ -238,7 +239,8 @@ export class SSEConnectionManager {
|
|
|
238
239
|
serverUrl: s.serverUrl,
|
|
239
240
|
transport: s.transportType,
|
|
240
241
|
createdAt: s.createdAt,
|
|
241
|
-
|
|
242
|
+
updatedAt: s.updatedAt ?? s.createdAt,
|
|
243
|
+
status: s.status ?? 'pending',
|
|
242
244
|
})),
|
|
243
245
|
};
|
|
244
246
|
}
|
|
@@ -265,7 +267,7 @@ export class SSEConnectionManager {
|
|
|
265
267
|
if (duplicate) {
|
|
266
268
|
// If the existing session is still pending OAuth, treat connect as "resume auth"
|
|
267
269
|
// instead of failing with duplicate connection error.
|
|
268
|
-
if (duplicate.
|
|
270
|
+
if (duplicate.status === 'pending') {
|
|
269
271
|
await this.getSession({ sessionId: duplicate.sessionId });
|
|
270
272
|
return {
|
|
271
273
|
sessionId: duplicate.sessionId,
|
|
@@ -354,8 +356,8 @@ export class SSEConnectionManager {
|
|
|
354
356
|
const client = this.clients.get(sessionId);
|
|
355
357
|
|
|
356
358
|
if (client) {
|
|
359
|
+
// clearSession() handles DELETE + local cleanup internally.
|
|
357
360
|
await client.clearSession();
|
|
358
|
-
client.disconnect();
|
|
359
361
|
this.clients.delete(sessionId);
|
|
360
362
|
} else {
|
|
361
363
|
// Handle orphaned sessions (e.g., OAuth flow failed before client was stored)
|
|
@@ -504,7 +506,10 @@ export class SSEConnectionManager {
|
|
|
504
506
|
* Complete OAuth authorization flow
|
|
505
507
|
*/
|
|
506
508
|
private async finishAuth(params: FinishAuthParams): Promise<FinishAuthResult> {
|
|
507
|
-
const {
|
|
509
|
+
const { code } = params;
|
|
510
|
+
const oauthState = params.state;
|
|
511
|
+
const parsedState = parseOAuthState(oauthState);
|
|
512
|
+
const sessionId = parsedState?.sessionId || oauthState;
|
|
508
513
|
|
|
509
514
|
const session = await sessions.get(this.userId, sessionId);
|
|
510
515
|
if (!session) {
|
|
@@ -533,7 +538,7 @@ export class SSEConnectionManager {
|
|
|
533
538
|
|
|
534
539
|
client.onConnectionEvent((event) => this.emitConnectionEvent(event));
|
|
535
540
|
|
|
536
|
-
await client.finishAuth(code);
|
|
541
|
+
await client.finishAuth(code, oauthState);
|
|
537
542
|
this.clients.set(sessionId, client);
|
|
538
543
|
|
|
539
544
|
const tools = await client.listTools();
|
|
@@ -601,16 +606,18 @@ export class SSEConnectionManager {
|
|
|
601
606
|
/**
|
|
602
607
|
* Cleanup and close all connections
|
|
603
608
|
*/
|
|
604
|
-
dispose(): void {
|
|
609
|
+
async dispose(): Promise<void> {
|
|
605
610
|
this.isActive = false;
|
|
606
611
|
|
|
607
612
|
if (this.heartbeatTimer) {
|
|
608
613
|
clearInterval(this.heartbeatTimer);
|
|
609
614
|
}
|
|
610
615
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
616
|
+
// Send HTTP DELETE to each Streamable HTTP server before closing, per spec.
|
|
617
|
+
// Run in parallel so shutdown is not serialised across many sessions.
|
|
618
|
+
await Promise.all(
|
|
619
|
+
Array.from(this.clients.values()).map((client) => client.disconnect())
|
|
620
|
+
);
|
|
614
621
|
|
|
615
622
|
this.clients.clear();
|
|
616
623
|
}
|
|
@@ -66,12 +66,8 @@ export class MultiSessionClient {
|
|
|
66
66
|
*
|
|
67
67
|
* A session is considered connectable when:
|
|
68
68
|
* - It has a `serverId`, `serverUrl`, and `callbackUrl` (i.e. it was fully initialized)
|
|
69
|
-
* - Its
|
|
70
|
-
* either mid-OAuth flow, auth-pending, or previously failed. We skip those here
|
|
69
|
+
* - Its status is `active`. Pending sessions are skipped here
|
|
71
70
|
* and let the OAuth flow complete separately before we try to reconnect them.
|
|
72
|
-
*
|
|
73
|
-
* Note: Sessions where `active` is `undefined` (legacy records) are included
|
|
74
|
-
* for backwards compatibility.
|
|
75
71
|
*/
|
|
76
72
|
private async getActiveSessions(): Promise<Session[]> {
|
|
77
73
|
const sessionList = await sessions.list(this.userId);
|
|
@@ -79,7 +75,7 @@ export class MultiSessionClient {
|
|
|
79
75
|
s.serverId &&
|
|
80
76
|
s.serverUrl &&
|
|
81
77
|
s.callbackUrl &&
|
|
82
|
-
s.
|
|
78
|
+
s.status === 'active'
|
|
83
79
|
);
|
|
84
80
|
return valid;
|
|
85
81
|
}
|
|
@@ -104,6 +100,9 @@ export class MultiSessionClient {
|
|
|
104
100
|
* Connects a single session, with built-in deduplication to prevent race conditions.
|
|
105
101
|
*
|
|
106
102
|
* - If a client for this session already exists and is connected, returns immediately.
|
|
103
|
+
* - If the existing client entry is no longer connected (e.g. it was explicitly
|
|
104
|
+
* disconnected), it is evicted so that `establishConnectionWithRetries` creates a
|
|
105
|
+
* fresh transport — preventing "Client already connected" errors from the SDK.
|
|
107
106
|
* - If a connection attempt for this session is already in-flight (e.g. from a
|
|
108
107
|
* concurrent call), it joins the existing promise instead of starting a new one.
|
|
109
108
|
* This is the key concurrency lock — the `connectionPromises` map acts as a
|
|
@@ -111,9 +110,19 @@ export class MultiSessionClient {
|
|
|
111
110
|
* - On completion (success or failure), the promise is cleaned up from the map.
|
|
112
111
|
*/
|
|
113
112
|
private async connectSession(session: Session): Promise<void> {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
const existing = this.clients.find(c => c.getSessionId() === session.sessionId);
|
|
114
|
+
|
|
115
|
+
if (existing) {
|
|
116
|
+
if (existing.isConnected()) {
|
|
117
|
+
// Genuinely connected — nothing to do.
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Client entry exists but is no longer connected (explicit disconnect or
|
|
122
|
+
// a prior failed reconnect attempt). Remove it so the fresh connect below
|
|
123
|
+
// starts with a clean slate and the underlying SDK Client doesn't complain
|
|
124
|
+
// about an already-attached transport.
|
|
125
|
+
this.clients = this.clients.filter(c => c !== existing);
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
// Avoid concurrent connection attempts for the same session
|
|
@@ -201,9 +210,33 @@ export class MultiSessionClient {
|
|
|
201
210
|
*/
|
|
202
211
|
async connect(): Promise<void> {
|
|
203
212
|
const sessions = await this.getActiveSessions();
|
|
213
|
+
const activeSessionIds = new Set(sessions.map(s => s.sessionId));
|
|
214
|
+
|
|
215
|
+
// Prune stale clients whose sessions no longer exist in storage.
|
|
216
|
+
// Otherwise a lingering MCPClient with a still-alive SDK Client
|
|
217
|
+
// transport triggers OAuth refreshes on listTools(), and the
|
|
218
|
+
// StorageOAuthClientProvider.state() -> patchCredentials() call
|
|
219
|
+
// fails with a FK violation because the session was deleted.
|
|
220
|
+
this.clients = this.clients.filter(c => activeSessionIds.has(c.getSessionId()));
|
|
221
|
+
|
|
204
222
|
await this.connectInBatches(sessions);
|
|
205
223
|
}
|
|
206
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Drops all cached `MCPClient` instances and reconnects fresh from storage.
|
|
227
|
+
*
|
|
228
|
+
* Call this when downstream MCP servers have expired their transport sessions
|
|
229
|
+
* (e.g. after a remote server restart) and subsequent tool calls return
|
|
230
|
+
* "Session not found. Reconnect without session header." errors.
|
|
231
|
+
*
|
|
232
|
+
* OAuth tokens are preserved in the storage backend — no re-authentication
|
|
233
|
+
* is required. Only the in-memory transport sessions are cleared.
|
|
234
|
+
*/
|
|
235
|
+
async reconnect(): Promise<void> {
|
|
236
|
+
await this.disconnect();
|
|
237
|
+
await this.connect();
|
|
238
|
+
}
|
|
239
|
+
|
|
207
240
|
/**
|
|
208
241
|
* Returns all currently connected `MCPClient` instances.
|
|
209
242
|
*
|
|
@@ -217,11 +250,15 @@ export class MultiSessionClient {
|
|
|
217
250
|
/**
|
|
218
251
|
* Gracefully disconnects all active MCP clients and clears the internal client list.
|
|
219
252
|
*
|
|
253
|
+
* For Streamable HTTP sessions, each client sends an HTTP DELETE to its MCP
|
|
254
|
+
* endpoint per the spec before closing locally. All disconnects run in
|
|
255
|
+
* parallel so shutdown is not serialised across many sessions.
|
|
256
|
+
*
|
|
220
257
|
* Call this during server shutdown or when a user logs out to free up
|
|
221
258
|
* underlying transport resources (SSE streams, HTTP connections, etc.).
|
|
222
259
|
*/
|
|
223
|
-
disconnect(): void {
|
|
224
|
-
this.clients.
|
|
260
|
+
async disconnect(): Promise<void> {
|
|
261
|
+
await Promise.all(this.clients.map((client) => client.disconnect()));
|
|
225
262
|
this.clients = [];
|
|
226
263
|
}
|
|
227
264
|
}
|