@mcp-ts/sdk 1.5.0 → 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcp-ts/sdk",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -6,6 +6,20 @@
6
6
  // Core MCP Hook
7
7
  export { useMcp, type UseMcpOptions, type McpClient, type McpConnection } from './use-mcp.js';
8
8
 
9
+ // Optional OAuth popup conveniences. These are not required for auth:
10
+ // consumers can still provide their own onRedirect handler, callback page UI,
11
+ // or complete `finishAuth(sessionId, code)` from a normal redirect flow.
12
+ export {
13
+ useMcpOAuthPopup,
14
+ openCenteredPopup,
15
+ createOAuthPopupRedirectHandler,
16
+ McpOAuthCallbackContent,
17
+ McpOAuthCallbackFallback,
18
+ type OAuthPopupConnectionLike,
19
+ type OAuthPopupRedirectOptions,
20
+ type McpOAuthCallbackContentProps,
21
+ } from './oauth-popup.js';
22
+
9
23
  // App Host (internal use)
10
24
  export { useAppHost } from './use-app-host.js';
11
25
 
@@ -0,0 +1,446 @@
1
+ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
2
+
3
+ export interface OAuthPopupConnectionLike {
4
+ sessionId: string;
5
+ state: string;
6
+ error?: string;
7
+ }
8
+
9
+ /**
10
+ * Optional helpers for popup-based OAuth UX.
11
+ *
12
+ * These utilities sit on top of the core MCP auth primitives:
13
+ * - `useMcp({ onRedirect })` to decide how auth navigation happens
14
+ * - `finishAuth(sessionId, code)` to complete code exchange
15
+ *
16
+ * Consumers are free to:
17
+ * - use these helpers as-is for a turnkey popup flow
18
+ * - build their own popup UI/message bridge
19
+ * - skip popups entirely and handle auth in a normal callback page
20
+ */
21
+ export interface OAuthPopupRedirectOptions {
22
+ width?: number;
23
+ height?: number;
24
+ windowName?: string;
25
+ features?: string[];
26
+ onBlocked?: (url: string) => void;
27
+ }
28
+
29
+ export interface McpOAuthCallbackContentProps {
30
+ code?: string | null;
31
+ sessionId?: string | null;
32
+ title?: ReactNode;
33
+ initialStatus?: string;
34
+ loadingFallback?: ReactNode;
35
+ rootStyle?: CSSProperties;
36
+ cardStyle?: CSSProperties;
37
+ titleStyle?: CSSProperties;
38
+ messageStyle?: CSSProperties;
39
+ renderContainer?: (content: ReactNode) => ReactNode;
40
+ debugPhase?: 'loading' | 'success' | 'error';
41
+ }
42
+
43
+ const AUTH_CODE_MESSAGE = 'MCP_AUTH_CODE';
44
+ const AUTH_RESULT_MESSAGE = 'MCP_AUTH_RESULT';
45
+
46
+ function postPopupResult(
47
+ popupWindow: WindowProxy | null,
48
+ result: {
49
+ sessionId?: string;
50
+ success: boolean;
51
+ error?: string;
52
+ }
53
+ ): void {
54
+ popupWindow?.postMessage(
55
+ {
56
+ type: AUTH_RESULT_MESSAGE,
57
+ ...result,
58
+ },
59
+ window.location.origin
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Opens a centered popup window for OAuth.
65
+ *
66
+ * Convenience only: callers can replace this entirely with their own popup,
67
+ * modal, redirect, or router-based navigation strategy.
68
+ */
69
+ export function openCenteredPopup(url: string, options: OAuthPopupRedirectOptions = {}): Window | null {
70
+ const {
71
+ width = 600,
72
+ height = 700,
73
+ windowName = 'mcp-auth-popup',
74
+ features = [],
75
+ onBlocked,
76
+ } = options;
77
+
78
+ const left = window.screenX + (window.outerWidth - width) / 2;
79
+ const top = window.screenY + (window.outerHeight - height) / 2;
80
+ const featureList = [
81
+ `width=${width}`,
82
+ `height=${height}`,
83
+ `left=${left}`,
84
+ `top=${top}`,
85
+ 'resizable=yes',
86
+ 'scrollbars=yes',
87
+ 'status=yes',
88
+ ...features,
89
+ ].join(',');
90
+
91
+ const popup = window.open(url, windowName, featureList);
92
+ if (!popup) {
93
+ onBlocked?.(url);
94
+ }
95
+
96
+ return popup;
97
+ }
98
+
99
+ /**
100
+ * Creates an `onRedirect` handler suitable for `useMcp({ onRedirect })`.
101
+ *
102
+ * This is the simplest popup entry point, but it is intentionally optional.
103
+ * Applications can provide any redirect handler they want, including full-page
104
+ * navigation or a completely custom popup implementation.
105
+ */
106
+ export function createOAuthPopupRedirectHandler(
107
+ options: OAuthPopupRedirectOptions = {}
108
+ ): (url: string) => void {
109
+ return (url: string) => {
110
+ openCenteredPopup(url, {
111
+ ...options,
112
+ onBlocked: options.onBlocked ?? ((blockedUrl) => {
113
+ window.alert('Popup blocked! Allow popups for this site to complete authentication.');
114
+ window.location.href = blockedUrl;
115
+ }),
116
+ });
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Handles opener-side popup coordination for OAuth code exchange.
122
+ *
123
+ * Use this when you want popup auth but do not want to reimplement the
124
+ * postMessage wiring between the main app window and the popup callback page.
125
+ *
126
+ * If you are not using a popup flow, you do not need this hook.
127
+ */
128
+ export function useMcpOAuthPopup<TConnection extends OAuthPopupConnectionLike>(
129
+ connections: TConnection[],
130
+ finishAuth: (sessionId: string, code: string) => Promise<unknown>
131
+ ): void {
132
+ const pendingPopupsRef = useRef<Map<string, WindowProxy>>(new Map());
133
+
134
+ useEffect(() => {
135
+ const handleMessage = async (event: MessageEvent) => {
136
+ if (event.origin !== window.location.origin) {
137
+ return;
138
+ }
139
+
140
+ if (event.data?.type !== AUTH_CODE_MESSAGE || !event.data.code) {
141
+ return;
142
+ }
143
+
144
+ const popupWindow = event.source && 'postMessage' in event.source
145
+ ? event.source as WindowProxy
146
+ : null;
147
+ const targetSessionId = typeof event.data.sessionId === 'string' ? event.data.sessionId : '';
148
+
149
+ if (!targetSessionId) {
150
+ postPopupResult(popupWindow, {
151
+ success: false,
152
+ error: 'Missing OAuth session identifier',
153
+ });
154
+ return;
155
+ }
156
+
157
+ const targetSession = connections.find((connection) => connection.sessionId === targetSessionId);
158
+ if (!targetSession) {
159
+ postPopupResult(popupWindow, {
160
+ sessionId: targetSessionId,
161
+ success: false,
162
+ error: 'OAuth session not found in the current client state',
163
+ });
164
+ return;
165
+ }
166
+
167
+ if (popupWindow) {
168
+ pendingPopupsRef.current.set(targetSession.sessionId, popupWindow);
169
+ }
170
+
171
+ try {
172
+ await finishAuth(targetSession.sessionId, event.data.code);
173
+ } catch (error) {
174
+ pendingPopupsRef.current.delete(targetSession.sessionId);
175
+ postPopupResult(popupWindow, {
176
+ sessionId: targetSession.sessionId,
177
+ success: false,
178
+ error: error instanceof Error ? error.message : 'Failed to finish auth',
179
+ });
180
+ }
181
+ };
182
+
183
+ window.addEventListener('message', handleMessage);
184
+ return () => window.removeEventListener('message', handleMessage);
185
+ }, [connections, finishAuth]);
186
+
187
+ useEffect(() => {
188
+ for (const connection of connections) {
189
+ const popupWindow = pendingPopupsRef.current.get(connection.sessionId);
190
+ if (!popupWindow) {
191
+ continue;
192
+ }
193
+
194
+ if (connection.state === 'AUTHENTICATED') {
195
+ postPopupResult(popupWindow, {
196
+ sessionId: connection.sessionId,
197
+ success: true,
198
+ });
199
+ pendingPopupsRef.current.delete(connection.sessionId);
200
+ continue;
201
+ }
202
+
203
+ if (connection.state === 'FAILED') {
204
+ postPopupResult(popupWindow, {
205
+ sessionId: connection.sessionId,
206
+ success: false,
207
+ error: connection.error || 'Failed to complete authorization',
208
+ });
209
+ pendingPopupsRef.current.delete(connection.sessionId);
210
+ }
211
+ }
212
+ }, [connections]);
213
+ }
214
+
215
+ /**
216
+ * Default popup callback UI for popup-based OAuth flows.
217
+ *
218
+ * This component reads the OAuth `code` and `state/sessionId`, notifies the
219
+ * opener window, waits for success/failure, and closes the popup on success.
220
+ *
221
+ * It is intentionally optional: apps can replace it with their own callback
222
+ * page UI or skip popup auth entirely and call `finishAuth(sessionId, code)`
223
+ * from any callback route they control.
224
+ */
225
+ export function McpOAuthCallbackContent({
226
+ code,
227
+ sessionId,
228
+ title = 'Verifying Authorization',
229
+ initialStatus = 'Completing your authorization...',
230
+ loadingFallback = 'Loading...',
231
+ rootStyle,
232
+ cardStyle,
233
+ titleStyle,
234
+ messageStyle,
235
+ renderContainer,
236
+ debugPhase,
237
+ }: McpOAuthCallbackContentProps): JSX.Element {
238
+ const [phase, setPhase] = useState<'loading' | 'success' | 'error'>(debugPhase || 'loading');
239
+ const [errorMessage, setErrorMessage] = useState('');
240
+
241
+ const openerMissing = typeof window !== 'undefined' ? !window.opener : false;
242
+ const missingCode = !code;
243
+ const missingSessionId = !sessionId;
244
+ const blockingError = openerMissing
245
+ ? 'Error: No opener window found. This window should be opened from the app.'
246
+ : missingCode
247
+ ? 'Error: No authorization code received.'
248
+ : missingSessionId
249
+ ? 'Error: No OAuth state received.'
250
+ : null;
251
+
252
+ useEffect(() => {
253
+ if (debugPhase) {
254
+ setPhase(debugPhase);
255
+ if (debugPhase === 'error') setErrorMessage('Test error message representing a real failure.');
256
+ return;
257
+ }
258
+
259
+ if (blockingError) {
260
+ setPhase('error');
261
+ setErrorMessage(blockingError);
262
+ return;
263
+ }
264
+
265
+ let closed = false;
266
+
267
+ const handleResult = (event: MessageEvent) => {
268
+ if (event.origin !== window.location.origin) {
269
+ return;
270
+ }
271
+
272
+ if (event.data?.type !== AUTH_RESULT_MESSAGE) {
273
+ return;
274
+ }
275
+
276
+ if (event.data.sessionId !== sessionId) {
277
+ return;
278
+ }
279
+
280
+ if (event.data.success) {
281
+ setPhase('success');
282
+ window.removeEventListener('message', handleResult);
283
+ closed = true;
284
+ window.setTimeout(() => window.close(), 1200);
285
+ return;
286
+ }
287
+
288
+ const message =
289
+ typeof event.data.error === 'string' && event.data.error.length > 0
290
+ ? event.data.error
291
+ : 'Failed to complete authorization.';
292
+ setPhase('error');
293
+ setErrorMessage(message);
294
+ };
295
+
296
+ window.addEventListener('message', handleResult);
297
+
298
+ try {
299
+ window.opener.postMessage(
300
+ { type: AUTH_CODE_MESSAGE, code, sessionId },
301
+ window.location.origin
302
+ );
303
+ } catch (error) {
304
+ console.error('Failed to communicate with opener:', error);
305
+ window.setTimeout(() => {
306
+ setPhase('error');
307
+ setErrorMessage('Error: Could not communicate with main window.');
308
+ }, 0);
309
+ }
310
+
311
+ return () => {
312
+ if (!closed) {
313
+ window.removeEventListener('message', handleResult);
314
+ }
315
+ };
316
+ }, [blockingError, code, sessionId, debugPhase]);
317
+
318
+ const loadingBubbles = (
319
+ <div style={{ display: 'flex', gap: '8px', justifyContent: 'center', height: '12px', alignItems: 'center' }}>
320
+ {[0, 150, 300].map((delay) => (
321
+ <span
322
+ key={delay}
323
+ style={{
324
+ width: '8px',
325
+ height: '8px',
326
+ borderRadius: '50%',
327
+ backgroundColor: 'currentColor',
328
+ opacity: 0.3,
329
+ animation: `mcp-pulse 1.2s ease-in-out infinite`,
330
+ animationDelay: `${delay}ms`,
331
+ }}
332
+ />
333
+ ))}
334
+ </div>
335
+ );
336
+
337
+ const content = (
338
+ <div
339
+ style={{
340
+ display: 'flex',
341
+ justifyContent: 'center',
342
+ alignItems: 'center',
343
+ minHeight: '100vh',
344
+ fontFamily: 'system-ui, -apple-system, sans-serif',
345
+ flexDirection: 'column',
346
+ backgroundColor: '#fafafa',
347
+ color: '#18181b',
348
+ boxSizing: 'border-box',
349
+ padding: '1.5rem',
350
+ ...rootStyle,
351
+ }}
352
+ >
353
+ <style>
354
+ {`
355
+ @keyframes mcp-pulse { 0%, 100% { transform: scale(0.8); opacity: 0.4; } 50% { transform: scale(1.2); opacity: 1; } }
356
+ @keyframes mcp-fade-up { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
357
+ `}
358
+ </style>
359
+ <div
360
+ style={{
361
+ backgroundColor: '#fff',
362
+ borderRadius: '20px',
363
+ boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.05)',
364
+ width: '100%',
365
+ maxWidth: '400px',
366
+ overflow: 'hidden',
367
+ border: '1px solid #f4f4f5',
368
+ display: 'flex',
369
+ flexDirection: 'column',
370
+ ...cardStyle,
371
+ }}
372
+ >
373
+ <div style={{ padding: '3rem 2rem', textAlign: 'center', animation: 'mcp-fade-up 0.4s ease-out' }}>
374
+ {phase === 'loading' && (
375
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', alignItems: 'center' }}>
376
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '48px', width: '48px', background: '#f8fafc', borderRadius: '12px', border: '1px solid #f1f5f9', color: '#64748b' }}>
377
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
378
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
379
+ </svg>
380
+ </div>
381
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
382
+ <h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 600, ...titleStyle }}>{title}</h2>
383
+ <p style={{ margin: 0, fontSize: '0.9rem', color: '#71717a', lineHeight: 1.5, ...messageStyle }}>
384
+ {initialStatus}
385
+ </p>
386
+ </div>
387
+ {loadingBubbles}
388
+ </div>
389
+ )}
390
+
391
+ {phase === 'success' && (
392
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'center' }}>
393
+ <svg style={{ color: '#10b981' }} width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
394
+ <circle cx="12" cy="12" r="10" />
395
+ <path d="M8 12l3 3 5-5" />
396
+ </svg>
397
+ <h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600, ...titleStyle }}>Connected</h2>
398
+ <p style={{ margin: 0, fontSize: '0.9rem', color: '#71717a', lineHeight: 1.5, ...messageStyle }}>
399
+ Authorization complete. This window will close automatically.
400
+ </p>
401
+ </div>
402
+ )}
403
+
404
+ {phase === 'error' && (
405
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'center' }}>
406
+ <svg style={{ color: '#ef4444' }} width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
407
+ <circle cx="12" cy="12" r="10" />
408
+ <path d="M15 9l-6 6M9 9l6 6" />
409
+ </svg>
410
+ <h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600, ...titleStyle }}>Connection Failed</h2>
411
+ <p style={{ margin: 0, fontSize: '0.9rem', color: '#ef4444', fontWeight: 500, ...messageStyle }}>
412
+ {errorMessage}
413
+ </p>
414
+ <button
415
+ onClick={() => window.close()}
416
+ style={{
417
+ marginTop: '0.5rem', padding: '0.625rem 1.25rem', border: 'none', borderRadius: '8px',
418
+ backgroundColor: '#fef2f2', color: '#ef4444', cursor: 'pointer', fontWeight: 600, fontSize: '0.875rem'
419
+ }}
420
+ >
421
+ Close Window
422
+ </button>
423
+ </div>
424
+ )}
425
+ </div>
426
+ </div>
427
+ </div>
428
+ );
429
+
430
+ if (renderContainer) {
431
+ return <>{renderContainer(content)}</>;
432
+ }
433
+
434
+ return content;
435
+ }
436
+
437
+ /**
438
+ * Tiny fallback component for Suspense-wrapped callback pages.
439
+ */
440
+ export function McpOAuthCallbackFallback({
441
+ children = 'Loading...',
442
+ }: {
443
+ children?: ReactNode;
444
+ }): JSX.Element {
445
+ return <>{children || 'Loading...'}</>;
446
+ }
@@ -290,17 +290,48 @@ export function useMcp(options: UseMcpOptions): McpClient {
290
290
  state === 'CONNECTED' ||
291
291
  state === 'DISCOVERING';
292
292
 
293
+ const getVisibleState = (
294
+ incomingState: McpConnectionState,
295
+ existingState?: McpConnectionState,
296
+ previousState?: McpConnectionState
297
+ ): McpConnectionState => {
298
+ // `INITIALIZING` has two meanings in practice:
299
+ // 1. genuine cold start / reconnect work
300
+ // 2. an internal setup step that happens mid-OAuth completion
301
+ //
302
+ // For case (2), showing raw `INITIALIZING` creates a confusing user-facing
303
+ // sequence like AUTHENTICATING -> INITIALIZING -> AUTHENTICATED.
304
+ // We keep the raw event stream intact for observability, but collapse the
305
+ // visible state back into the current auth phase in the UI.
306
+ if (
307
+ incomingState === 'INITIALIZING' &&
308
+ (existingState === 'AUTHENTICATING' ||
309
+ existingState === 'AUTHENTICATED' ||
310
+ previousState === 'AUTHENTICATING' ||
311
+ previousState === 'AUTHENTICATED')
312
+ ) {
313
+ return existingState === 'AUTHENTICATED' || previousState === 'AUTHENTICATED'
314
+ ? 'AUTHENTICATED'
315
+ : 'AUTHENTICATING';
316
+ }
317
+
318
+ return incomingState;
319
+ };
320
+
293
321
  setConnections((prev: McpConnection[]) => {
294
322
  switch (event.type) {
295
323
  case 'state_changed': {
296
324
  const existing = prev.find((c: McpConnection) => c.sessionId === event.sessionId);
297
325
  if (existing) {
326
+ // Normalize the incoming backend state into the smoother user-facing
327
+ // state we want to render for this existing connection.
328
+ const normalizedState = getVisibleState(event.state, existing.state, event.previousState);
298
329
  // In stateless per-request transport, tool calls can emit transient reconnect states.
299
330
  // Keep READY sticky to avoid UI flicker from READY -> CONNECTING -> CONNECTED.
300
331
  const nextState =
301
- existing.state === 'READY' && isTransientReconnectState(event.state)
332
+ existing.state === 'READY' && isTransientReconnectState(normalizedState)
302
333
  ? existing.state
303
- : event.state;
334
+ : normalizedState;
304
335
 
305
336
  return prev.map((c: McpConnection) =>
306
337
  c.sessionId === event.sessionId ? {
@@ -323,7 +354,9 @@ export function useMcp(options: UseMcpOptions): McpClient {
323
354
  serverId: event.serverId,
324
355
  serverName: event.serverName,
325
356
  serverUrl: event.serverUrl,
326
- state: event.state,
357
+ // New connections do not have prior local state, so we normalize
358
+ // only against the server-reported previous state.
359
+ state: getVisibleState(event.state, undefined, event.previousState),
327
360
  createdAt: event.createdAt ? new Date(event.createdAt) : undefined,
328
361
  tools: [],
329
362
  },
@@ -240,16 +240,49 @@ export function useMcp(options: UseMcpOptions): McpClient {
240
240
  state === 'CONNECTED' ||
241
241
  state === 'DISCOVERING';
242
242
 
243
+ const getVisibleState = (
244
+ incomingState: McpConnectionState,
245
+ existingState?: McpConnectionState,
246
+ previousState?: McpConnectionState
247
+ ): McpConnectionState => {
248
+ // `INITIALIZING` has two meanings in practice:
249
+ // 1. genuine cold start / reconnect work
250
+ // 2. an internal setup step that happens mid-OAuth completion
251
+ //
252
+ // For case (2), showing raw `INITIALIZING` creates a confusing user-facing
253
+ // sequence like AUTHENTICATING -> INITIALIZING -> AUTHENTICATED.
254
+ // We keep the raw event stream intact for observability, but collapse the
255
+ // visible state back into the current auth phase in the UI.
256
+ if (
257
+ incomingState === 'INITIALIZING' &&
258
+ (
259
+ existingState === 'AUTHENTICATING' ||
260
+ existingState === 'AUTHENTICATED' ||
261
+ previousState === 'AUTHENTICATING' ||
262
+ previousState === 'AUTHENTICATED'
263
+ )
264
+ ) {
265
+ return existingState === 'AUTHENTICATED' || previousState === 'AUTHENTICATED'
266
+ ? 'AUTHENTICATED'
267
+ : 'AUTHENTICATING';
268
+ }
269
+
270
+ return incomingState;
271
+ };
272
+
243
273
  switch (event.type) {
244
274
  case 'state_changed': {
245
275
  const existing = connections.value.find((c) => c.sessionId === event.sessionId);
246
276
  if (existing) {
277
+ // Normalize the incoming backend state into the smoother user-facing
278
+ // state we want to render for this existing connection.
279
+ const normalizedState = getVisibleState(event.state, existing.state, event.previousState);
247
280
  // In stateless per-request transport, tool calls can emit transient reconnect states.
248
281
  // Keep READY sticky to avoid UI flicker from READY -> CONNECTING -> CONNECTED.
249
282
  const nextState =
250
- existing.state === 'READY' && isTransientReconnectState(event.state)
283
+ existing.state === 'READY' && isTransientReconnectState(normalizedState)
251
284
  ? existing.state
252
- : event.state;
285
+ : normalizedState;
253
286
 
254
287
  const index = connections.value.indexOf(existing);
255
288
  connections.value[index] = {
@@ -268,7 +301,9 @@ export function useMcp(options: UseMcpOptions): McpClient {
268
301
  sessionId: event.sessionId,
269
302
  serverId: event.serverId,
270
303
  serverName: event.serverName,
271
- state: event.state,
304
+ // New connections do not have prior local state, so we normalize
305
+ // only against the server-reported previous state.
306
+ state: getVisibleState(event.state, undefined, event.previousState),
272
307
  createdAt: event.createdAt ? new Date(event.createdAt) : undefined,
273
308
  tools: [],
274
309
  }];
@@ -363,9 +363,24 @@ export class SSEConnectionManager {
363
363
  return existing;
364
364
  }
365
365
 
366
+ const session = await storage.getSession(this.identity, sessionId);
367
+ if (!session) {
368
+ throw new Error('Session not found');
369
+ }
370
+
366
371
  const client = new MCPClient({
367
372
  identity: this.identity,
368
373
  sessionId,
374
+ // These fields are optional in MCPClient, but when rehydrating a known
375
+ // stored session on the server we pass them explicitly to preserve the
376
+ // original transport/connection metadata instead of relying on lazy
377
+ // reloading during initialize().
378
+ serverId: session.serverId,
379
+ serverName: session.serverName,
380
+ serverUrl: session.serverUrl,
381
+ callbackUrl: session.callbackUrl,
382
+ transportType: session.transportType,
383
+ headers: session.headers,
369
384
  });
370
385
 
371
386
  // Subscribe to events before connecting
@@ -437,6 +452,16 @@ export class SSEConnectionManager {
437
452
  const client = new MCPClient({
438
453
  identity: this.identity,
439
454
  sessionId,
455
+ // These fields are optional in MCPClient, but when rehydrating a known
456
+ // stored session on the server we pass them explicitly to preserve the
457
+ // original transport/connection metadata instead of relying on lazy
458
+ // reloading during initialize().
459
+ serverId: session.serverId,
460
+ serverName: session.serverName,
461
+ serverUrl: session.serverUrl,
462
+ callbackUrl: session.callbackUrl,
463
+ transportType: session.transportType,
464
+ headers: session.headers,
440
465
  ...clientMetadata,
441
466
  });
442
467
 
@@ -478,6 +503,20 @@ export class SSEConnectionManager {
478
503
  const client = new MCPClient({
479
504
  identity: this.identity,
480
505
  sessionId,
506
+ // These fields are optional in MCPClient, but when rehydrating a known
507
+ // stored session on the server we pass them explicitly to preserve the
508
+ // original connection metadata instead of relying on lazy
509
+ // reloading during initialize().
510
+ serverId: session.serverId,
511
+ serverName: session.serverName,
512
+ serverUrl: session.serverUrl,
513
+ callbackUrl: session.callbackUrl,
514
+ // NOTE: transportType is intentionally omitted here.
515
+ // The session's stored transportType is a placeholder ('streamable_http')
516
+ // set before transport negotiation. Omitting it lets MCPClient auto-negotiate
517
+ // (try streamable_http → SSE fallback), which is critical for servers like
518
+ // Neon that only support SSE transport.
519
+ headers: session.headers,
481
520
  });
482
521
 
483
522
  client.onConnectionEvent((event) => this.emitConnectionEvent(event));