@decocms/mesh-sdk 1.1.0
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 +368 -0
- package/package.json +51 -0
- package/src/context/index.ts +9 -0
- package/src/context/project-context.tsx +89 -0
- package/src/hooks/index.ts +73 -0
- package/src/hooks/use-collections.ts +357 -0
- package/src/hooks/use-connection.ts +82 -0
- package/src/hooks/use-mcp-client.ts +127 -0
- package/src/hooks/use-mcp-prompts.ts +126 -0
- package/src/hooks/use-mcp-resources.ts +128 -0
- package/src/hooks/use-mcp-tools.ts +184 -0
- package/src/hooks/use-virtual-mcp.ts +91 -0
- package/src/index.ts +128 -0
- package/src/lib/constants.ts +204 -0
- package/src/lib/mcp-oauth.ts +742 -0
- package/src/lib/query-keys.ts +178 -0
- package/src/lib/streamable-http-client-transport.ts +79 -0
- package/src/types/connection.ts +204 -0
- package/src/types/index.ts +27 -0
- package/src/types/virtual-mcp.ts +218 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP OAuth Client Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides OAuth authentication flow for MCP servers.
|
|
5
|
+
* Uses the MCP SDK's auth module with in-memory storage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
auth,
|
|
10
|
+
exchangeAuthorization,
|
|
11
|
+
discoverOAuthProtectedResourceMetadata,
|
|
12
|
+
discoverAuthorizationServerMetadata,
|
|
13
|
+
} from "@modelcontextprotocol/sdk/client/auth.js";
|
|
14
|
+
import type {
|
|
15
|
+
OAuthClientProvider,
|
|
16
|
+
AuthResult,
|
|
17
|
+
} from "@modelcontextprotocol/sdk/client/auth.js";
|
|
18
|
+
import type {
|
|
19
|
+
OAuthClientMetadata,
|
|
20
|
+
OAuthClientInformation,
|
|
21
|
+
OAuthClientInformationFull,
|
|
22
|
+
OAuthTokens,
|
|
23
|
+
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Simple hash function for server URLs
|
|
27
|
+
*/
|
|
28
|
+
function hashServerUrl(url: string): string {
|
|
29
|
+
let hash = 0;
|
|
30
|
+
for (let i = 0; i < url.length; i++) {
|
|
31
|
+
const char = url.charCodeAt(i);
|
|
32
|
+
hash = (hash << 5) - hash + char;
|
|
33
|
+
hash = hash & hash;
|
|
34
|
+
}
|
|
35
|
+
return Math.abs(hash).toString(16);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Global in-memory store for active OAuth sessions.
|
|
40
|
+
*/
|
|
41
|
+
const activeOAuthSessions = new Map<string, McpOAuthProvider>();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Storage key prefix for OAuth callback fallback
|
|
45
|
+
*/
|
|
46
|
+
const OAUTH_CALLBACK_STORAGE_KEY = "mcp:oauth:callback:";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Window mode for OAuth flow
|
|
50
|
+
* - "popup": Opens in a popup window (default, may be blocked on some devices)
|
|
51
|
+
* - "tab": Opens in a new tab (works on all devices, uses localStorage for communication)
|
|
52
|
+
*/
|
|
53
|
+
export type OAuthWindowMode = "popup" | "tab";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Options for the MCP OAuth provider
|
|
57
|
+
*/
|
|
58
|
+
export interface McpOAuthProviderOptions {
|
|
59
|
+
/** MCP server URL */
|
|
60
|
+
serverUrl: string;
|
|
61
|
+
/** OAuth client name */
|
|
62
|
+
clientName?: string;
|
|
63
|
+
/** OAuth client URI */
|
|
64
|
+
clientUri?: string;
|
|
65
|
+
/** OAuth callback URL */
|
|
66
|
+
callbackUrl?: string;
|
|
67
|
+
/** OAuth scopes to request (space-separated or array). If not provided, no scope is requested */
|
|
68
|
+
scope?: string | string[];
|
|
69
|
+
/** Window mode: "popup" (default) or "tab" (for devices that block popups) */
|
|
70
|
+
windowMode?: OAuthWindowMode;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* MCP OAuth client provider using in-memory storage only.
|
|
75
|
+
* No localStorage or sessionStorage - everything is ephemeral.
|
|
76
|
+
*/
|
|
77
|
+
class McpOAuthProvider implements OAuthClientProvider {
|
|
78
|
+
private serverUrl: string;
|
|
79
|
+
private _clientMetadata: OAuthClientMetadata;
|
|
80
|
+
private _redirectUrl: string;
|
|
81
|
+
private _windowMode: OAuthWindowMode;
|
|
82
|
+
|
|
83
|
+
// In-memory storage for OAuth flow data
|
|
84
|
+
private _state: string | null = null;
|
|
85
|
+
private _codeVerifier: string | null = null;
|
|
86
|
+
private _clientInfo: OAuthClientInformation | null = null;
|
|
87
|
+
private _tokens: OAuthTokens | null = null;
|
|
88
|
+
|
|
89
|
+
constructor(options: McpOAuthProviderOptions) {
|
|
90
|
+
this.serverUrl = options.serverUrl;
|
|
91
|
+
this._redirectUrl =
|
|
92
|
+
options.callbackUrl ?? `${window.location.origin}/oauth/callback`;
|
|
93
|
+
this._windowMode = options.windowMode ?? "popup";
|
|
94
|
+
|
|
95
|
+
// Build scope string if provided
|
|
96
|
+
const scopeStr = options.scope
|
|
97
|
+
? Array.isArray(options.scope)
|
|
98
|
+
? options.scope.join(" ")
|
|
99
|
+
: options.scope
|
|
100
|
+
: undefined;
|
|
101
|
+
|
|
102
|
+
this._clientMetadata = {
|
|
103
|
+
redirect_uris: [this._redirectUrl],
|
|
104
|
+
token_endpoint_auth_method: "none",
|
|
105
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
106
|
+
response_types: ["code"],
|
|
107
|
+
client_name: options.clientName ?? "@decocms/mesh MCP client",
|
|
108
|
+
// Only include scope if explicitly provided - some servers have their own scope requirements
|
|
109
|
+
...(scopeStr && { scope: scopeStr }),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Register this session for callback handling
|
|
113
|
+
activeOAuthSessions.set(hashServerUrl(this.serverUrl), this);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get redirectUrl(): string {
|
|
117
|
+
return this._redirectUrl;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get clientMetadata(): OAuthClientMetadata {
|
|
121
|
+
return this._clientMetadata;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
state(): string {
|
|
125
|
+
if (!this._state) {
|
|
126
|
+
this._state = crypto.randomUUID();
|
|
127
|
+
}
|
|
128
|
+
return this._state;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getStoredState(): string | null {
|
|
132
|
+
return this._state;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
clientInformation(): OAuthClientInformation | undefined {
|
|
136
|
+
return this._clientInfo ?? undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
saveClientInformation(clientInfo: OAuthClientInformationFull): void {
|
|
140
|
+
this._clientInfo = clientInfo;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
tokens(): OAuthTokens | undefined {
|
|
144
|
+
return this._tokens ?? undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
saveTokens(tokens: OAuthTokens): void {
|
|
148
|
+
this._tokens = tokens;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
redirectToAuthorization(authorizationUrl: URL): void {
|
|
152
|
+
if (this._windowMode === "tab") {
|
|
153
|
+
// Open in new tab - uses localStorage for cross-tab communication
|
|
154
|
+
const tab = window.open(authorizationUrl.toString(), "_blank");
|
|
155
|
+
if (!tab) {
|
|
156
|
+
// Fallback: navigate current window (will lose state, but works)
|
|
157
|
+
window.location.href = authorizationUrl.toString();
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
// Open in popup (default)
|
|
161
|
+
const width = 600;
|
|
162
|
+
const height = 700;
|
|
163
|
+
const left = window.screenX + (window.outerWidth - width) / 2;
|
|
164
|
+
const top = window.screenY + (window.outerHeight - height) / 2;
|
|
165
|
+
|
|
166
|
+
const popup = window.open(
|
|
167
|
+
authorizationUrl.toString(),
|
|
168
|
+
"mcp-oauth",
|
|
169
|
+
`width=${width},height=${height},left=${left},top=${top},popup=yes`,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
if (!popup) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
"OAuth popup was blocked. Please allow popups for this site and try again.",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
saveCodeVerifier(codeVerifier: string): void {
|
|
181
|
+
this._codeVerifier = codeVerifier;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
codeVerifier(): string {
|
|
185
|
+
if (!this._codeVerifier) {
|
|
186
|
+
throw new Error("Code verifier not found");
|
|
187
|
+
}
|
|
188
|
+
return this._codeVerifier;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
invalidateCredentials(): void {
|
|
192
|
+
this._clientInfo = null;
|
|
193
|
+
this._tokens = null;
|
|
194
|
+
this._codeVerifier = null;
|
|
195
|
+
this._state = null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
getServerUrl(): string {
|
|
199
|
+
return this.serverUrl;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
cleanup(): void {
|
|
203
|
+
activeOAuthSessions.delete(hashServerUrl(this.serverUrl));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Full OAuth token info for persistence
|
|
209
|
+
*/
|
|
210
|
+
export interface OAuthTokenInfo {
|
|
211
|
+
accessToken: string;
|
|
212
|
+
refreshToken: string | null;
|
|
213
|
+
expiresIn: number | null;
|
|
214
|
+
scope: string | null;
|
|
215
|
+
// Dynamic Client Registration info
|
|
216
|
+
clientId: string | null;
|
|
217
|
+
clientSecret: string | null;
|
|
218
|
+
tokenEndpoint: string | null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Result from authenticateMcp
|
|
223
|
+
*/
|
|
224
|
+
export interface AuthenticateMcpResult {
|
|
225
|
+
token: string | null;
|
|
226
|
+
/** Full token info for persistence (includes refresh token) */
|
|
227
|
+
tokenInfo: OAuthTokenInfo | null;
|
|
228
|
+
error: string | null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Extended token result with all info needed for persistence
|
|
233
|
+
*/
|
|
234
|
+
interface FullTokenResult {
|
|
235
|
+
tokens: OAuthTokens;
|
|
236
|
+
clientId: string | null;
|
|
237
|
+
clientSecret: string | null;
|
|
238
|
+
tokenEndpoint: string | null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Authenticate with an MCP server using OAuth
|
|
243
|
+
* @param params.connectionId - The connection ID to authenticate
|
|
244
|
+
* @param params.meshUrl - Mesh server URL (optional, defaults to window.location.origin for same-origin apps)
|
|
245
|
+
* @param params.clientName - OAuth client name
|
|
246
|
+
* @param params.clientUri - OAuth client URI
|
|
247
|
+
* @param params.callbackUrl - OAuth callback URL (defaults to current origin + /oauth/callback)
|
|
248
|
+
* @param params.timeout - Timeout in ms (default 120000)
|
|
249
|
+
* @param params.scope - OAuth scopes to request
|
|
250
|
+
* @param params.windowMode - "popup" (default) or "tab" (for devices that block popups)
|
|
251
|
+
*/
|
|
252
|
+
export async function authenticateMcp(params: {
|
|
253
|
+
connectionId: string;
|
|
254
|
+
/** Mesh server URL - optional, defaults to window.location.origin (for external apps, provide your Mesh server URL) */
|
|
255
|
+
meshUrl?: string;
|
|
256
|
+
clientName?: string;
|
|
257
|
+
clientUri?: string;
|
|
258
|
+
callbackUrl?: string;
|
|
259
|
+
timeout?: number;
|
|
260
|
+
/** OAuth scopes to request. If not provided, no scope is requested (server decides) */
|
|
261
|
+
scope?: string | string[];
|
|
262
|
+
/** Window mode: "popup" (default) or "tab" (for devices that block popups). Tab mode uses localStorage for cross-tab communication. */
|
|
263
|
+
windowMode?: OAuthWindowMode;
|
|
264
|
+
}): Promise<AuthenticateMcpResult> {
|
|
265
|
+
const baseUrl = params.meshUrl ?? window.location.origin;
|
|
266
|
+
const serverUrl = new URL(`/mcp/${params.connectionId}`, baseUrl);
|
|
267
|
+
const provider = new McpOAuthProvider({
|
|
268
|
+
serverUrl: serverUrl.href,
|
|
269
|
+
clientName: params.clientName,
|
|
270
|
+
clientUri: params.clientUri,
|
|
271
|
+
callbackUrl: params.callbackUrl,
|
|
272
|
+
scope: params.scope,
|
|
273
|
+
windowMode: params.windowMode,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
// Wait for OAuth callback message from popup and handle token exchange
|
|
278
|
+
// Uses both postMessage (primary) and localStorage (fallback for when opener is lost)
|
|
279
|
+
const oauthCompletePromise = new Promise<FullTokenResult>(
|
|
280
|
+
(resolve, reject) => {
|
|
281
|
+
const timeout = params.timeout || 120000;
|
|
282
|
+
let timeoutId: ReturnType<typeof setTimeout>;
|
|
283
|
+
let resolved = false;
|
|
284
|
+
// Use the OAuth state as the storage key - it's already unique per flow
|
|
285
|
+
// and will be available to the callback page via URL params
|
|
286
|
+
const oauthState = provider.state();
|
|
287
|
+
const storageKey = `${OAUTH_CALLBACK_STORAGE_KEY}${oauthState}`;
|
|
288
|
+
|
|
289
|
+
const cleanup = () => {
|
|
290
|
+
// Note: Race condition prevention is handled in processCallback by setting
|
|
291
|
+
// resolved = true immediately. This function just does the actual cleanup.
|
|
292
|
+
window.removeEventListener("message", handleMessage);
|
|
293
|
+
window.removeEventListener("storage", handleStorageEvent);
|
|
294
|
+
clearTimeout(timeoutId);
|
|
295
|
+
// Clean up storage key
|
|
296
|
+
try {
|
|
297
|
+
localStorage.removeItem(storageKey);
|
|
298
|
+
} catch {
|
|
299
|
+
// Ignore storage errors
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const processCallback = async (data: {
|
|
304
|
+
success: boolean;
|
|
305
|
+
code?: string;
|
|
306
|
+
state?: string;
|
|
307
|
+
error?: string;
|
|
308
|
+
}) => {
|
|
309
|
+
// Set resolved immediately to prevent race condition with concurrent callbacks
|
|
310
|
+
if (resolved) return;
|
|
311
|
+
resolved = true;
|
|
312
|
+
|
|
313
|
+
if (!data.success) {
|
|
314
|
+
cleanup();
|
|
315
|
+
reject(new Error(data.error || "OAuth authentication failed"));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const { code, state } = data;
|
|
320
|
+
|
|
321
|
+
if (!code) {
|
|
322
|
+
cleanup();
|
|
323
|
+
reject(new Error("Missing authorization code"));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Verify state matches
|
|
328
|
+
const storedState = provider.getStoredState();
|
|
329
|
+
if (storedState !== state) {
|
|
330
|
+
cleanup();
|
|
331
|
+
reject(new Error("OAuth state mismatch - possible CSRF attack"));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
// Do token exchange in parent window (we have provider in memory)
|
|
337
|
+
const resourceMetadata =
|
|
338
|
+
await discoverOAuthProtectedResourceMetadata(serverUrl);
|
|
339
|
+
const authServerUrl =
|
|
340
|
+
resourceMetadata?.authorization_servers?.[0] || serverUrl;
|
|
341
|
+
const authServerMetadata =
|
|
342
|
+
await discoverAuthorizationServerMetadata(authServerUrl);
|
|
343
|
+
|
|
344
|
+
const clientInfo = provider.clientInformation();
|
|
345
|
+
if (!clientInfo) {
|
|
346
|
+
cleanup();
|
|
347
|
+
reject(new Error("Client information not found"));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const codeVerifier = provider.codeVerifier();
|
|
352
|
+
|
|
353
|
+
const tokens = await exchangeAuthorization(authServerUrl, {
|
|
354
|
+
metadata: authServerMetadata,
|
|
355
|
+
clientInformation: clientInfo,
|
|
356
|
+
authorizationCode: code,
|
|
357
|
+
codeVerifier,
|
|
358
|
+
redirectUri: provider.redirectUrl,
|
|
359
|
+
resource: new URL(serverUrl),
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
cleanup();
|
|
363
|
+
|
|
364
|
+
// Resolve with full result including client info for token refresh
|
|
365
|
+
resolve({
|
|
366
|
+
tokens,
|
|
367
|
+
clientId: clientInfo.client_id ?? null,
|
|
368
|
+
clientSecret:
|
|
369
|
+
"client_secret" in clientInfo
|
|
370
|
+
? (clientInfo.client_secret as string)
|
|
371
|
+
: null,
|
|
372
|
+
tokenEndpoint: authServerMetadata?.token_endpoint ?? null,
|
|
373
|
+
});
|
|
374
|
+
} catch (err) {
|
|
375
|
+
cleanup();
|
|
376
|
+
reject(err);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// Primary: Listen for postMessage from popup
|
|
381
|
+
const handleMessage = async (event: MessageEvent) => {
|
|
382
|
+
if (event.origin !== window.location.origin) return;
|
|
383
|
+
if (event.data?.type === "mcp:oauth:callback") {
|
|
384
|
+
await processCallback(event.data);
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
// Fallback: Listen for localStorage events (when window.opener is lost)
|
|
389
|
+
const handleStorageEvent = async (event: StorageEvent) => {
|
|
390
|
+
if (event.key !== storageKey || !event.newValue) return;
|
|
391
|
+
try {
|
|
392
|
+
const data = JSON.parse(event.newValue);
|
|
393
|
+
await processCallback(data);
|
|
394
|
+
} catch {
|
|
395
|
+
// Ignore parse errors
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
window.addEventListener("message", handleMessage);
|
|
400
|
+
window.addEventListener("storage", handleStorageEvent);
|
|
401
|
+
|
|
402
|
+
timeoutId = setTimeout(() => {
|
|
403
|
+
if (resolved) return;
|
|
404
|
+
resolved = true;
|
|
405
|
+
cleanup();
|
|
406
|
+
reject(new Error("OAuth authentication timeout"));
|
|
407
|
+
}, timeout);
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
// Start the auth flow
|
|
412
|
+
const result: AuthResult = await auth(provider, { serverUrl });
|
|
413
|
+
|
|
414
|
+
if (result === "REDIRECT") {
|
|
415
|
+
const fullResult = await oauthCompletePromise;
|
|
416
|
+
return {
|
|
417
|
+
token: fullResult.tokens.access_token,
|
|
418
|
+
tokenInfo: {
|
|
419
|
+
accessToken: fullResult.tokens.access_token,
|
|
420
|
+
refreshToken: fullResult.tokens.refresh_token ?? null,
|
|
421
|
+
expiresIn: fullResult.tokens.expires_in ?? null,
|
|
422
|
+
scope: fullResult.tokens.scope ?? null,
|
|
423
|
+
clientId: fullResult.clientId,
|
|
424
|
+
clientSecret: fullResult.clientSecret,
|
|
425
|
+
tokenEndpoint: fullResult.tokenEndpoint,
|
|
426
|
+
},
|
|
427
|
+
error: null,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// If we got here without redirect, check for tokens
|
|
432
|
+
const tokens = provider.tokens();
|
|
433
|
+
const clientInfo = provider.clientInformation();
|
|
434
|
+
return {
|
|
435
|
+
token: tokens?.access_token || null,
|
|
436
|
+
tokenInfo: tokens
|
|
437
|
+
? {
|
|
438
|
+
accessToken: tokens.access_token,
|
|
439
|
+
refreshToken: tokens.refresh_token ?? null,
|
|
440
|
+
expiresIn: tokens.expires_in ?? null,
|
|
441
|
+
scope: tokens.scope ?? null,
|
|
442
|
+
clientId: clientInfo?.client_id ?? null,
|
|
443
|
+
clientSecret:
|
|
444
|
+
clientInfo && "client_secret" in clientInfo
|
|
445
|
+
? (clientInfo.client_secret as string)
|
|
446
|
+
: null,
|
|
447
|
+
tokenEndpoint: null, // Would need to be passed through
|
|
448
|
+
}
|
|
449
|
+
: null,
|
|
450
|
+
error: null,
|
|
451
|
+
};
|
|
452
|
+
} catch (error) {
|
|
453
|
+
return {
|
|
454
|
+
token: null,
|
|
455
|
+
tokenInfo: null,
|
|
456
|
+
error: error instanceof Error ? error.message : String(error),
|
|
457
|
+
};
|
|
458
|
+
} finally {
|
|
459
|
+
provider.cleanup();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Send callback data via postMessage or localStorage fallback
|
|
465
|
+
* @param data - The callback data to send
|
|
466
|
+
* @param state - The OAuth state parameter (used as localStorage key for fallback)
|
|
467
|
+
*/
|
|
468
|
+
function sendCallbackData(
|
|
469
|
+
data: {
|
|
470
|
+
type: string;
|
|
471
|
+
success: boolean;
|
|
472
|
+
code?: string;
|
|
473
|
+
state?: string;
|
|
474
|
+
error?: string;
|
|
475
|
+
},
|
|
476
|
+
state: string | null,
|
|
477
|
+
): boolean {
|
|
478
|
+
// Try postMessage first (primary method)
|
|
479
|
+
if (window.opener && !window.opener.closed) {
|
|
480
|
+
window.opener.postMessage(data, window.location.origin);
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Fallback: Use localStorage to communicate with parent window
|
|
485
|
+
// This works even when window.opener is lost due to redirects
|
|
486
|
+
// Use the OAuth state as the key since the parent window knows it
|
|
487
|
+
if (state) {
|
|
488
|
+
try {
|
|
489
|
+
const storageKey = `${OAUTH_CALLBACK_STORAGE_KEY}${state}`;
|
|
490
|
+
localStorage.setItem(storageKey, JSON.stringify(data));
|
|
491
|
+
return true;
|
|
492
|
+
} catch {
|
|
493
|
+
// Ignore storage errors
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Handle the OAuth callback (to be called from the callback page)
|
|
502
|
+
*
|
|
503
|
+
* Forwards the authorization code to the parent window via postMessage.
|
|
504
|
+
* Falls back to localStorage if window.opener is not available (common with OAuth redirects).
|
|
505
|
+
* The parent window handles the token exchange.
|
|
506
|
+
*/
|
|
507
|
+
export async function handleOAuthCallback(): Promise<{
|
|
508
|
+
success: boolean;
|
|
509
|
+
error?: string;
|
|
510
|
+
}> {
|
|
511
|
+
const params = new URLSearchParams(window.location.search);
|
|
512
|
+
const code = params.get("code");
|
|
513
|
+
let state = params.get("state");
|
|
514
|
+
const errorParam = params.get("error");
|
|
515
|
+
const errorDescription = params.get("error_description");
|
|
516
|
+
|
|
517
|
+
// Try to decode wrapped state from deco.cx first (needed for localStorage key)
|
|
518
|
+
let decodedState = state;
|
|
519
|
+
if (state) {
|
|
520
|
+
try {
|
|
521
|
+
const decoded = atob(state);
|
|
522
|
+
const stateObj = JSON.parse(decoded);
|
|
523
|
+
if (stateObj.clientState) {
|
|
524
|
+
decodedState = stateObj.clientState;
|
|
525
|
+
}
|
|
526
|
+
} catch {
|
|
527
|
+
// Use state as-is
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (errorParam) {
|
|
532
|
+
const errorMsg = errorDescription || errorParam;
|
|
533
|
+
sendCallbackData(
|
|
534
|
+
{
|
|
535
|
+
type: "mcp:oauth:callback",
|
|
536
|
+
success: false,
|
|
537
|
+
error: errorMsg,
|
|
538
|
+
},
|
|
539
|
+
decodedState,
|
|
540
|
+
);
|
|
541
|
+
return {
|
|
542
|
+
success: false,
|
|
543
|
+
error: errorMsg,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (!code || !state) {
|
|
548
|
+
const error = "Missing code or state parameter";
|
|
549
|
+
sendCallbackData(
|
|
550
|
+
{
|
|
551
|
+
type: "mcp:oauth:callback",
|
|
552
|
+
success: false,
|
|
553
|
+
error,
|
|
554
|
+
},
|
|
555
|
+
decodedState,
|
|
556
|
+
);
|
|
557
|
+
return {
|
|
558
|
+
success: false,
|
|
559
|
+
error,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Use the decoded state for the callback
|
|
564
|
+
state = decodedState || state;
|
|
565
|
+
|
|
566
|
+
// Forward code and state to parent window for token exchange
|
|
567
|
+
const sent = sendCallbackData(
|
|
568
|
+
{
|
|
569
|
+
type: "mcp:oauth:callback",
|
|
570
|
+
success: true,
|
|
571
|
+
code,
|
|
572
|
+
state,
|
|
573
|
+
},
|
|
574
|
+
state,
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
if (sent) {
|
|
578
|
+
return { success: true };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return {
|
|
582
|
+
success: false,
|
|
583
|
+
error: "Parent window not available",
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Authentication status for an MCP connection
|
|
589
|
+
*/
|
|
590
|
+
export interface McpAuthStatus {
|
|
591
|
+
/** Whether the connection is authenticated and working */
|
|
592
|
+
isAuthenticated: boolean;
|
|
593
|
+
/** Whether the server supports OAuth (has WWW-Authenticate header on 401) */
|
|
594
|
+
supportsOAuth: boolean;
|
|
595
|
+
/** Whether the current authentication is via OAuth (has stored OAuth token) */
|
|
596
|
+
hasOAuthToken: boolean;
|
|
597
|
+
/** Error message if authentication failed */
|
|
598
|
+
error?: string;
|
|
599
|
+
/** Whether this was a server error (5xx) - OAuth support is unknown in this case */
|
|
600
|
+
isServerError?: boolean;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Extract connection ID from MCP proxy URL
|
|
605
|
+
*/
|
|
606
|
+
function extractConnectionIdFromUrl(url: string): string | null {
|
|
607
|
+
try {
|
|
608
|
+
const urlObj = new URL(url);
|
|
609
|
+
const match = urlObj.pathname.match(/^\/mcp\/([^/]+)/);
|
|
610
|
+
return match?.[1] ?? null;
|
|
611
|
+
} catch {
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Check if connection has a stored OAuth token
|
|
618
|
+
* @param connectionId - The connection ID to check
|
|
619
|
+
* @param apiBaseUrl - Base URL for the API call (optional, defaults to relative path)
|
|
620
|
+
*/
|
|
621
|
+
async function checkOAuthTokenStatus(
|
|
622
|
+
connectionId: string,
|
|
623
|
+
apiBaseUrl?: string,
|
|
624
|
+
): Promise<{ hasToken: boolean }> {
|
|
625
|
+
try {
|
|
626
|
+
const path = `/api/connections/${connectionId}/oauth-token/status`;
|
|
627
|
+
const url = apiBaseUrl ? new URL(path, apiBaseUrl).href : path;
|
|
628
|
+
const response = await fetch(url, {
|
|
629
|
+
credentials: apiBaseUrl ? "omit" : "include", // Don't send cookies for cross-origin
|
|
630
|
+
});
|
|
631
|
+
if (!response.ok) {
|
|
632
|
+
return { hasToken: false };
|
|
633
|
+
}
|
|
634
|
+
const data = await response.json();
|
|
635
|
+
return { hasToken: data.hasToken === true };
|
|
636
|
+
} catch {
|
|
637
|
+
return { hasToken: false };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Check if an MCP connection is authenticated and whether it supports OAuth
|
|
643
|
+
* @param params.url - The MCP URL to check
|
|
644
|
+
* @param params.token - Authorization token (optional)
|
|
645
|
+
* @param params.meshUrl - Mesh server URL for API calls (optional, defaults to URL origin)
|
|
646
|
+
*/
|
|
647
|
+
export async function isConnectionAuthenticated({
|
|
648
|
+
url,
|
|
649
|
+
token,
|
|
650
|
+
meshUrl,
|
|
651
|
+
}: {
|
|
652
|
+
url: string;
|
|
653
|
+
token: string | null;
|
|
654
|
+
/** Mesh server URL for API calls - optional, defaults to extracting from url parameter */
|
|
655
|
+
meshUrl?: string;
|
|
656
|
+
}): Promise<McpAuthStatus> {
|
|
657
|
+
try {
|
|
658
|
+
const headers = new Headers();
|
|
659
|
+
headers.set("Content-Type", "application/json");
|
|
660
|
+
headers.set("Accept", "application/json, text/event-stream");
|
|
661
|
+
if (token) {
|
|
662
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const response = await fetch(url, {
|
|
666
|
+
method: "POST",
|
|
667
|
+
headers,
|
|
668
|
+
body: JSON.stringify({
|
|
669
|
+
jsonrpc: "2.0",
|
|
670
|
+
id: 1,
|
|
671
|
+
method: "initialize",
|
|
672
|
+
params: {
|
|
673
|
+
protocolVersion: "2025-06-18",
|
|
674
|
+
capabilities: {},
|
|
675
|
+
clientInfo: {
|
|
676
|
+
name: "@decocms/mesh MCP client",
|
|
677
|
+
version: "1.0.0",
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
}),
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// Extract connection ID for OAuth token status check
|
|
684
|
+
const connectionId = extractConnectionIdFromUrl(url);
|
|
685
|
+
// Determine base URL for API calls (meshUrl > URL origin > window.location.origin)
|
|
686
|
+
const apiBaseUrl = meshUrl ?? new URL(url).origin;
|
|
687
|
+
|
|
688
|
+
if (response.ok) {
|
|
689
|
+
// Check if we have an OAuth token stored for this connection
|
|
690
|
+
const oauthStatus = connectionId
|
|
691
|
+
? await checkOAuthTokenStatus(connectionId, apiBaseUrl)
|
|
692
|
+
: { hasToken: false };
|
|
693
|
+
|
|
694
|
+
return {
|
|
695
|
+
isAuthenticated: true,
|
|
696
|
+
// When authenticated, we can't determine OAuth support from the response
|
|
697
|
+
// (no 401 to check WWW-Authenticate header). Default to false.
|
|
698
|
+
supportsOAuth: false,
|
|
699
|
+
hasOAuthToken: oauthStatus.hasToken,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Try to get error message from response body
|
|
704
|
+
let error: string | undefined;
|
|
705
|
+
try {
|
|
706
|
+
const body = await response.json();
|
|
707
|
+
error = body.error || body.message;
|
|
708
|
+
} catch {
|
|
709
|
+
// Ignore JSON parse errors
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Handle 5xx server errors separately - we can't determine OAuth support
|
|
713
|
+
if (response.status >= 500) {
|
|
714
|
+
return {
|
|
715
|
+
isAuthenticated: false,
|
|
716
|
+
supportsOAuth: false,
|
|
717
|
+
hasOAuthToken: false,
|
|
718
|
+
error: error || `HTTP ${response.status}`,
|
|
719
|
+
isServerError: true,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// For 401/403, check if server supports OAuth by looking for WWW-Authenticate header
|
|
724
|
+
const wwwAuth = response.headers.get("WWW-Authenticate");
|
|
725
|
+
const supportsOAuth = !!wwwAuth;
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
isAuthenticated: false,
|
|
729
|
+
supportsOAuth,
|
|
730
|
+
hasOAuthToken: false,
|
|
731
|
+
error: error || `HTTP ${response.status}`,
|
|
732
|
+
};
|
|
733
|
+
} catch (error) {
|
|
734
|
+
console.error("[isConnectionAuthenticated] Error:", error);
|
|
735
|
+
return {
|
|
736
|
+
isAuthenticated: false,
|
|
737
|
+
supportsOAuth: false,
|
|
738
|
+
hasOAuthToken: false,
|
|
739
|
+
error: (error as Error).message,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
}
|