@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/dist/client/react.d.mts +81 -2
- package/dist/client/react.d.ts +81 -2
- package/dist/client/react.js +316 -3
- package/dist/client/react.js.map +1 -1
- package/dist/client/react.mjs +312 -4
- package/dist/client/react.mjs.map +1 -1
- package/dist/client/vue.js +11 -2
- package/dist/client/vue.js.map +1 -1
- package/dist/client/vue.mjs +11 -2
- package/dist/client/vue.mjs.map +1 -1
- package/dist/index.js +55 -11
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +55 -11
- package/dist/index.mjs.map +1 -1
- package/dist/server/index.js +55 -11
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +55 -11
- package/dist/server/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client/react/index.ts +14 -0
- package/src/client/react/oauth-popup.tsx +446 -0
- package/src/client/react/use-mcp.ts +36 -3
- package/src/client/vue/use-mcp.ts +38 -3
- package/src/server/handlers/sse-handler.ts +39 -0
- package/src/server/mcp/oauth-client.ts +32 -14
- package/supabase/migrations/20260421010000_add_session_cleanup_cron.sql +32 -0
package/package.json
CHANGED
|
@@ -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(
|
|
332
|
+
existing.state === 'READY' && isTransientReconnectState(normalizedState)
|
|
302
333
|
? existing.state
|
|
303
|
-
:
|
|
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
|
-
|
|
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(
|
|
283
|
+
existing.state === 'READY' && isTransientReconnectState(normalizedState)
|
|
251
284
|
? existing.state
|
|
252
|
-
:
|
|
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
|
-
|
|
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));
|