@mcp-ts/sdk 1.5.0 → 1.5.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/dist/adapters/agui-adapter.d.mts +1 -1
- package/dist/adapters/agui-adapter.d.ts +1 -1
- package/dist/adapters/agui-adapter.js +43 -9
- package/dist/adapters/agui-adapter.js.map +1 -1
- package/dist/adapters/agui-adapter.mjs +43 -9
- package/dist/adapters/agui-adapter.mjs.map +1 -1
- package/dist/adapters/agui-middleware.d.mts +1 -1
- package/dist/adapters/agui-middleware.d.ts +1 -1
- package/dist/adapters/agui-middleware.js.map +1 -1
- package/dist/adapters/agui-middleware.mjs.map +1 -1
- package/dist/adapters/ai-adapter.d.mts +1 -1
- package/dist/adapters/ai-adapter.d.ts +1 -1
- package/dist/adapters/ai-adapter.js +42 -8
- package/dist/adapters/ai-adapter.js.map +1 -1
- package/dist/adapters/ai-adapter.mjs +42 -8
- package/dist/adapters/ai-adapter.mjs.map +1 -1
- package/dist/adapters/langchain-adapter.d.mts +1 -1
- package/dist/adapters/langchain-adapter.d.ts +1 -1
- package/dist/adapters/langchain-adapter.js +42 -8
- package/dist/adapters/langchain-adapter.js.map +1 -1
- package/dist/adapters/langchain-adapter.mjs +42 -8
- package/dist/adapters/langchain-adapter.mjs.map +1 -1
- package/dist/client/react.d.mts +91 -2
- package/dist/client/react.d.ts +91 -2
- package/dist/client/react.js +339 -3
- package/dist/client/react.js.map +1 -1
- package/dist/client/react.mjs +335 -4
- package/dist/client/react.mjs.map +1 -1
- package/dist/client/vue.d.mts +10 -0
- package/dist/client/vue.d.ts +10 -0
- package/dist/client/vue.js +28 -2
- package/dist/client/vue.js.map +1 -1
- package/dist/client/vue.mjs +28 -2
- package/dist/client/vue.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +170 -37
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +170 -37
- 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/dist/shared/index.d.mts +2 -2
- package/dist/shared/index.d.ts +2 -2
- package/dist/shared/index.js +115 -26
- package/dist/shared/index.js.map +1 -1
- package/dist/shared/index.mjs +115 -26
- package/dist/shared/index.mjs.map +1 -1
- package/dist/{tool-router-XnWVxPzv.d.mts → tool-router-DK0RJblO.d.mts} +3 -0
- package/dist/{tool-router-Bo8qZbsD.d.ts → tool-router-DsKhRmJm.d.ts} +3 -0
- package/package.json +1 -1
- package/src/adapters/agui-adapter.ts +7 -7
- package/src/adapters/ai-adapter.ts +5 -5
- package/src/adapters/langchain-adapter.ts +5 -5
- package/src/client/react/index.ts +14 -0
- package/src/client/react/oauth-popup.tsx +446 -0
- package/src/client/react/use-mcp.ts +84 -3
- package/src/client/vue/use-mcp.ts +80 -3
- package/src/server/handlers/sse-handler.ts +39 -0
- package/src/server/mcp/oauth-client.ts +32 -14
- package/src/shared/meta-tools.ts +62 -13
- package/src/shared/tool-index.ts +85 -12
- package/src/shared/tool-router.ts +8 -7
- package/supabase/migrations/20260421010000_add_session_cleanup_cron.sql +32 -0
|
@@ -137,11 +137,11 @@ export class AIAdapter {
|
|
|
137
137
|
// @ts-ignore: ToolSet type inference can be tricky with dynamic imports
|
|
138
138
|
return Object.fromEntries(
|
|
139
139
|
filteredTools.map((tool) => {
|
|
140
|
-
const routedTool = tool as typeof tool & { sessionId?: string; serverName?: string };
|
|
141
|
-
const namespace = routedTool.
|
|
140
|
+
const routedTool = tool as typeof tool & { sessionId?: string; serverId?: string; serverName?: string };
|
|
141
|
+
const namespace = routedTool.serverId ?? routedTool.sessionId;
|
|
142
142
|
const toolKey = isMetaTool(tool.name)
|
|
143
143
|
? tool.name
|
|
144
|
-
: this.getRouterToolKey(tool.name, routedTool.sessionId, routedTool.
|
|
144
|
+
: this.getRouterToolKey(tool.name, routedTool.sessionId, routedTool.serverId);
|
|
145
145
|
|
|
146
146
|
return [
|
|
147
147
|
toolKey,
|
|
@@ -172,8 +172,8 @@ export class AIAdapter {
|
|
|
172
172
|
);
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
private getRouterToolKey(toolName: string, sessionId?: string,
|
|
176
|
-
const namespace = sessionId ??
|
|
175
|
+
private getRouterToolKey(toolName: string, sessionId?: string, serverId?: string): string {
|
|
176
|
+
const namespace = sessionId ?? serverId ?? 'mcp';
|
|
177
177
|
const normalized = namespace
|
|
178
178
|
.toLowerCase()
|
|
179
179
|
.replace(/[^a-z0-9]+/g, '_')
|
|
@@ -144,14 +144,14 @@ export class LangChainAdapter {
|
|
|
144
144
|
const filteredTools = await router.getFilteredTools();
|
|
145
145
|
|
|
146
146
|
return filteredTools.map((tool) => {
|
|
147
|
-
const routedTool = tool as typeof tool & { sessionId?: string; serverName?: string };
|
|
148
|
-
const namespace = routedTool.
|
|
147
|
+
const routedTool = tool as typeof tool & { sessionId?: string; serverId?: string; serverName?: string };
|
|
148
|
+
const namespace = routedTool.serverId ?? routedTool.sessionId;
|
|
149
149
|
const schema = this.jsonSchemaToZod(tool.inputSchema);
|
|
150
150
|
|
|
151
151
|
return new this.DynamicStructuredTool!({
|
|
152
152
|
name: isMetaTool(tool.name)
|
|
153
153
|
? tool.name
|
|
154
|
-
: this.getRouterToolKey(tool.name, routedTool.sessionId, routedTool.
|
|
154
|
+
: this.getRouterToolKey(tool.name, routedTool.sessionId, routedTool.serverId),
|
|
155
155
|
description: tool.description || `Tool ${tool.name}`,
|
|
156
156
|
schema: schema,
|
|
157
157
|
func: async (args: any) => {
|
|
@@ -183,8 +183,8 @@ export class LangChainAdapter {
|
|
|
183
183
|
});
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
private getRouterToolKey(toolName: string, sessionId?: string,
|
|
187
|
-
const namespace = sessionId ??
|
|
186
|
+
private getRouterToolKey(toolName: string, sessionId?: string, serverId?: string): string {
|
|
187
|
+
const namespace = sessionId ?? serverId ?? 'mcp';
|
|
188
188
|
const normalized = namespace
|
|
189
189
|
.toLowerCase()
|
|
190
190
|
.replace(/[^a-z0-9]+/g, '_')
|
|
@@ -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
|
+
}
|
|
@@ -118,6 +118,17 @@ export interface McpClient {
|
|
|
118
118
|
*/
|
|
119
119
|
disconnect: (sessionId: string) => Promise<void>;
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Reconnect to an MCP server (disconnects existing session first)
|
|
123
|
+
*/
|
|
124
|
+
reconnect: (params: {
|
|
125
|
+
serverId: string;
|
|
126
|
+
serverName: string;
|
|
127
|
+
serverUrl: string;
|
|
128
|
+
callbackUrl: string;
|
|
129
|
+
transportType?: 'sse' | 'streamable_http';
|
|
130
|
+
}) => Promise<string>;
|
|
131
|
+
|
|
121
132
|
/**
|
|
122
133
|
* Get connection by session ID
|
|
123
134
|
*/
|
|
@@ -290,17 +301,48 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
290
301
|
state === 'CONNECTED' ||
|
|
291
302
|
state === 'DISCOVERING';
|
|
292
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
|
+
|
|
293
332
|
setConnections((prev: McpConnection[]) => {
|
|
294
333
|
switch (event.type) {
|
|
295
334
|
case 'state_changed': {
|
|
296
335
|
const existing = prev.find((c: McpConnection) => c.sessionId === event.sessionId);
|
|
297
336
|
if (existing) {
|
|
337
|
+
// Normalize the incoming backend state into the smoother user-facing
|
|
338
|
+
// state we want to render for this existing connection.
|
|
339
|
+
const normalizedState = getVisibleState(event.state, existing.state, event.previousState);
|
|
298
340
|
// In stateless per-request transport, tool calls can emit transient reconnect states.
|
|
299
341
|
// Keep READY sticky to avoid UI flicker from READY -> CONNECTING -> CONNECTED.
|
|
300
342
|
const nextState =
|
|
301
|
-
existing.state === 'READY' && isTransientReconnectState(
|
|
343
|
+
existing.state === 'READY' && isTransientReconnectState(normalizedState)
|
|
302
344
|
? existing.state
|
|
303
|
-
:
|
|
345
|
+
: normalizedState;
|
|
304
346
|
|
|
305
347
|
return prev.map((c: McpConnection) =>
|
|
306
348
|
c.sessionId === event.sessionId ? {
|
|
@@ -323,7 +365,9 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
323
365
|
serverId: event.serverId,
|
|
324
366
|
serverName: event.serverName,
|
|
325
367
|
serverUrl: event.serverUrl,
|
|
326
|
-
|
|
368
|
+
// New connections do not have prior local state, so we normalize
|
|
369
|
+
// only against the server-reported previous state.
|
|
370
|
+
state: getVisibleState(event.state, undefined, event.previousState),
|
|
327
371
|
createdAt: event.createdAt ? new Date(event.createdAt) : undefined,
|
|
328
372
|
tools: [],
|
|
329
373
|
},
|
|
@@ -466,6 +510,41 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
466
510
|
[]
|
|
467
511
|
);
|
|
468
512
|
|
|
513
|
+
/**
|
|
514
|
+
* Reconnect to an MCP server (tears down existing session, then connects fresh)
|
|
515
|
+
*/
|
|
516
|
+
const reconnect = useCallback(
|
|
517
|
+
async (params: {
|
|
518
|
+
serverId: string;
|
|
519
|
+
serverName: string;
|
|
520
|
+
serverUrl: string;
|
|
521
|
+
callbackUrl: string;
|
|
522
|
+
transportType?: 'sse' | 'streamable_http';
|
|
523
|
+
}): Promise<string> => {
|
|
524
|
+
if (!clientRef.current) {
|
|
525
|
+
throw new Error('SSE client not initialized');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Find and disconnect existing session for the same server
|
|
529
|
+
const existing = connections.find(
|
|
530
|
+
(c: McpConnection) => c.serverId === params.serverId || c.serverUrl === params.serverUrl
|
|
531
|
+
);
|
|
532
|
+
if (existing) {
|
|
533
|
+
await clientRef.current.disconnectFromServer(existing.sessionId);
|
|
534
|
+
if (isMountedRef.current) {
|
|
535
|
+
setConnections((prev: McpConnection[]) =>
|
|
536
|
+
prev.filter((c: McpConnection) => c.sessionId !== existing.sessionId)
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Connect fresh
|
|
542
|
+
const result = await clientRef.current.connectToServer(params);
|
|
543
|
+
return result.sessionId;
|
|
544
|
+
},
|
|
545
|
+
[connections]
|
|
546
|
+
);
|
|
547
|
+
|
|
469
548
|
/**
|
|
470
549
|
* Disconnect from an MCP server
|
|
471
550
|
*/
|
|
@@ -635,6 +714,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
635
714
|
status,
|
|
636
715
|
isInitializing,
|
|
637
716
|
connect,
|
|
717
|
+
reconnect,
|
|
638
718
|
disconnect,
|
|
639
719
|
getConnection,
|
|
640
720
|
getConnectionByServerId,
|
|
@@ -658,6 +738,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
|
|
|
658
738
|
status,
|
|
659
739
|
isInitializing,
|
|
660
740
|
connect,
|
|
741
|
+
reconnect,
|
|
661
742
|
disconnect,
|
|
662
743
|
getConnection,
|
|
663
744
|
getConnectionByServerId,
|