@stigmer/react 0.0.81 → 0.0.83
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/mcp-server/McpServerConfigPanel.d.ts +23 -1
- package/mcp-server/McpServerConfigPanel.d.ts.map +1 -1
- package/mcp-server/McpServerConfigPanel.js +14 -5
- package/mcp-server/McpServerConfigPanel.js.map +1 -1
- package/mcp-server/McpServerDetailView.d.ts.map +1 -1
- package/mcp-server/McpServerDetailView.js +28 -10
- package/mcp-server/McpServerDetailView.js.map +1 -1
- package/mcp-server/McpServerPicker.d.ts.map +1 -1
- package/mcp-server/McpServerPicker.js +27 -3
- package/mcp-server/McpServerPicker.js.map +1 -1
- package/mcp-server/OAuthCallbackHandler.d.ts.map +1 -1
- package/mcp-server/OAuthCallbackHandler.js +33 -9
- package/mcp-server/OAuthCallbackHandler.js.map +1 -1
- package/mcp-server/useMcpServerCredentials.d.ts +53 -2
- package/mcp-server/useMcpServerCredentials.d.ts.map +1 -1
- package/mcp-server/useMcpServerCredentials.js +37 -6
- package/mcp-server/useMcpServerCredentials.js.map +1 -1
- package/mcp-server/useMcpServerOAuthConnect.d.ts +7 -0
- package/mcp-server/useMcpServerOAuthConnect.d.ts.map +1 -1
- package/mcp-server/useMcpServerOAuthConnect.js +48 -5
- package/mcp-server/useMcpServerOAuthConnect.js.map +1 -1
- package/package.json +4 -4
- package/src/mcp-server/McpServerConfigPanel.tsx +82 -4
- package/src/mcp-server/McpServerDetailView.tsx +96 -12
- package/src/mcp-server/McpServerPicker.tsx +56 -18
- package/src/mcp-server/OAuthCallbackHandler.tsx +39 -12
- package/src/mcp-server/useMcpServerCredentials.ts +68 -6
- package/src/mcp-server/useMcpServerOAuthConnect.ts +46 -5
- package/styles.css +1 -1
|
@@ -121,7 +121,7 @@ export function McpServerDetailView({
|
|
|
121
121
|
className,
|
|
122
122
|
}: McpServerDetailViewProps) {
|
|
123
123
|
const { mcpServer, isLoading, error, refetch } = useMcpServer(org, slug);
|
|
124
|
-
const credentials = useMcpServerCredentials(org, mcpServer ?? null);
|
|
124
|
+
const credentials = useMcpServerCredentials(activeOrg ?? org, mcpServer ?? null);
|
|
125
125
|
const connection = useMcpServerConnect();
|
|
126
126
|
const oauth = useMcpServerOAuthConnect();
|
|
127
127
|
|
|
@@ -153,7 +153,11 @@ export function McpServerDetailView({
|
|
|
153
153
|
const handleConnectClick = useCallback(async () => {
|
|
154
154
|
if (!mcpServer?.metadata?.id) return;
|
|
155
155
|
|
|
156
|
-
if (
|
|
156
|
+
if (
|
|
157
|
+
credentials.authMode === "oauth" &&
|
|
158
|
+
!credentials.isOAuthConnected &&
|
|
159
|
+
!credentials.manualOverride
|
|
160
|
+
) {
|
|
157
161
|
handleOAuthSignIn();
|
|
158
162
|
return;
|
|
159
163
|
}
|
|
@@ -170,7 +174,7 @@ export function McpServerDetailView({
|
|
|
170
174
|
} catch {
|
|
171
175
|
// error state is managed by the hook
|
|
172
176
|
}
|
|
173
|
-
}, [mcpServer, credentials.authMode, credentials.isOAuthConnected, credentials.isReady, connection, refetch, handleOAuthSignIn]);
|
|
177
|
+
}, [mcpServer, credentials.authMode, credentials.isOAuthConnected, credentials.manualOverride, credentials.isReady, connection, refetch, handleOAuthSignIn]);
|
|
174
178
|
|
|
175
179
|
const handleCredentialSubmit = useCallback(
|
|
176
180
|
async (
|
|
@@ -292,6 +296,17 @@ export function McpServerDetailView({
|
|
|
292
296
|
isOAuthConnected={credentials.isOAuthConnected}
|
|
293
297
|
accessTokenExpiresAt={credentials.accessTokenExpiresAt}
|
|
294
298
|
tokenLifetimeHint={credentials.tokenLifetimeHint}
|
|
299
|
+
isVendorApprovalPending={credentials.isVendorApprovalPending}
|
|
300
|
+
vendorApprovalDocsUrl={credentials.vendorApprovalDocsUrl}
|
|
301
|
+
manualOverride={credentials.manualOverride}
|
|
302
|
+
onManualOverride={() => {
|
|
303
|
+
credentials.setManualOverride(true);
|
|
304
|
+
setShowCredentialForm(true);
|
|
305
|
+
}}
|
|
306
|
+
onBackToOAuth={() => {
|
|
307
|
+
credentials.setManualOverride(false);
|
|
308
|
+
setShowCredentialForm(false);
|
|
309
|
+
}}
|
|
295
310
|
/>
|
|
296
311
|
|
|
297
312
|
{showCredentialForm && credentials.missingVariables.length > 0 && (
|
|
@@ -361,6 +376,11 @@ function ConnectBar({
|
|
|
361
376
|
isOAuthConnected,
|
|
362
377
|
accessTokenExpiresAt,
|
|
363
378
|
tokenLifetimeHint,
|
|
379
|
+
isVendorApprovalPending,
|
|
380
|
+
vendorApprovalDocsUrl,
|
|
381
|
+
manualOverride,
|
|
382
|
+
onManualOverride,
|
|
383
|
+
onBackToOAuth,
|
|
364
384
|
}: {
|
|
365
385
|
readonly isConnecting: boolean;
|
|
366
386
|
readonly connectionError: Error | null;
|
|
@@ -375,6 +395,11 @@ function ConnectBar({
|
|
|
375
395
|
readonly isOAuthConnected: boolean;
|
|
376
396
|
readonly accessTokenExpiresAt: bigint;
|
|
377
397
|
readonly tokenLifetimeHint: string | null;
|
|
398
|
+
readonly isVendorApprovalPending: boolean;
|
|
399
|
+
readonly vendorApprovalDocsUrl: string | null;
|
|
400
|
+
readonly manualOverride: boolean;
|
|
401
|
+
readonly onManualOverride: () => void;
|
|
402
|
+
readonly onBackToOAuth: () => void;
|
|
378
403
|
}) {
|
|
379
404
|
const isOAuthBusy =
|
|
380
405
|
oauthPhase === "initiating" ||
|
|
@@ -382,17 +407,22 @@ function ConnectBar({
|
|
|
382
407
|
oauthPhase === "completing" ||
|
|
383
408
|
oauthPhase === "connecting";
|
|
384
409
|
|
|
410
|
+
const showOAuthPrimary =
|
|
411
|
+
authMode === "oauth" && !isOAuthConnected && !manualOverride;
|
|
412
|
+
|
|
413
|
+
const oauthSignInDisabled = isVendorApprovalPending && showOAuthPrimary;
|
|
414
|
+
|
|
385
415
|
const buttonLabel = (() => {
|
|
386
416
|
if (isOAuthBusy) return oauthPhaseLabel(oauthPhase);
|
|
387
417
|
if (isConnecting) return "Connecting...";
|
|
388
|
-
if (
|
|
418
|
+
if (showOAuthPrimary) return "Sign in to connect";
|
|
389
419
|
if (hasDiscoveredTools) return "Reconnect";
|
|
390
420
|
return "Connect";
|
|
391
421
|
})();
|
|
392
422
|
|
|
393
423
|
const buttonIcon = (() => {
|
|
394
424
|
if (isOAuthBusy || isConnecting) return <Spinner />;
|
|
395
|
-
if (
|
|
425
|
+
if (showOAuthPrimary) return <OAuthIcon className="size-3.5" />;
|
|
396
426
|
if (hasDiscoveredTools) return <RefreshIcon className="size-3.5" />;
|
|
397
427
|
return <ConnectIcon className="size-3.5" />;
|
|
398
428
|
})();
|
|
@@ -406,6 +436,7 @@ function ConnectBar({
|
|
|
406
436
|
: "";
|
|
407
437
|
return `Tokens refresh automatically${hint}`;
|
|
408
438
|
}
|
|
439
|
+
if (manualOverride) return "Entering token manually";
|
|
409
440
|
if (hasDiscoveredTools) return formatConnectionSummary(toolCount, policyCount);
|
|
410
441
|
return "Not connected yet";
|
|
411
442
|
})();
|
|
@@ -414,37 +445,43 @@ function ConnectBar({
|
|
|
414
445
|
<div className="flex flex-col">
|
|
415
446
|
<div className="flex items-center justify-between px-3 py-2">
|
|
416
447
|
<div className="flex items-center gap-2">
|
|
417
|
-
{authMode === "oauth" && (
|
|
448
|
+
{authMode === "oauth" && !manualOverride && (
|
|
418
449
|
<span
|
|
419
450
|
className={cn(
|
|
420
451
|
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-medium",
|
|
421
452
|
isOAuthConnected
|
|
422
453
|
? "bg-success/10 text-success"
|
|
423
|
-
:
|
|
454
|
+
: oauthSignInDisabled
|
|
455
|
+
? "bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
|
456
|
+
: "bg-muted text-muted-foreground",
|
|
424
457
|
)}
|
|
425
458
|
>
|
|
426
459
|
<span
|
|
427
460
|
className={cn(
|
|
428
461
|
"size-1.5 rounded-full",
|
|
429
|
-
isOAuthConnected
|
|
462
|
+
isOAuthConnected
|
|
463
|
+
? "bg-success"
|
|
464
|
+
: oauthSignInDisabled
|
|
465
|
+
? "bg-amber-500"
|
|
466
|
+
: "bg-muted-foreground",
|
|
430
467
|
)}
|
|
431
468
|
aria-hidden="true"
|
|
432
469
|
/>
|
|
433
|
-
{isOAuthConnected ? "Connected" : "Not connected"}
|
|
470
|
+
{isOAuthConnected ? "Connected" : oauthSignInDisabled ? "Pending approval" : "Not connected"}
|
|
434
471
|
</span>
|
|
435
472
|
)}
|
|
436
473
|
<span className="text-xs text-muted-foreground">
|
|
437
|
-
{statusText}
|
|
474
|
+
{oauthSignInDisabled ? "OAuth sign-in is pending vendor approval" : statusText}
|
|
438
475
|
</span>
|
|
439
476
|
</div>
|
|
440
477
|
<button
|
|
441
478
|
type="button"
|
|
442
479
|
onClick={onConnect}
|
|
443
|
-
disabled={isConnecting || isOAuthBusy || credentialsLoading}
|
|
480
|
+
disabled={isConnecting || isOAuthBusy || credentialsLoading || oauthSignInDisabled}
|
|
444
481
|
data-cursor-target="connect-button"
|
|
445
482
|
className={cn(
|
|
446
483
|
"inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium",
|
|
447
|
-
|
|
484
|
+
showOAuthPrimary
|
|
448
485
|
? "bg-primary text-primary-foreground hover:bg-primary-hover"
|
|
449
486
|
: "border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
|
|
450
487
|
"disabled:pointer-events-none disabled:opacity-50",
|
|
@@ -455,6 +492,53 @@ function ConnectBar({
|
|
|
455
492
|
</button>
|
|
456
493
|
</div>
|
|
457
494
|
|
|
495
|
+
{/* Vendor approval pending banner with docs link */}
|
|
496
|
+
{oauthSignInDisabled && (
|
|
497
|
+
<div className="flex items-start gap-2 border-t border-amber-500/20 bg-amber-500/5 px-3 py-2">
|
|
498
|
+
<WarningIcon className="mt-0.5 size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
|
499
|
+
<div className="flex-1 text-xs text-amber-700 dark:text-amber-300">
|
|
500
|
+
<p>
|
|
501
|
+
The platform's OAuth app is awaiting vendor approval.
|
|
502
|
+
You can still connect by entering your own token manually.
|
|
503
|
+
</p>
|
|
504
|
+
{vendorApprovalDocsUrl && (
|
|
505
|
+
<a
|
|
506
|
+
href={vendorApprovalDocsUrl}
|
|
507
|
+
target="_blank"
|
|
508
|
+
rel="noopener noreferrer"
|
|
509
|
+
className="mt-1 inline-flex items-center gap-1 underline decoration-amber-600/40 underline-offset-2 hover:decoration-amber-600 dark:decoration-amber-400/40 dark:hover:decoration-amber-400"
|
|
510
|
+
>
|
|
511
|
+
Learn how to bring your own token
|
|
512
|
+
<ExternalLinkIcon className="size-3 shrink-0" />
|
|
513
|
+
</a>
|
|
514
|
+
)}
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
)}
|
|
518
|
+
|
|
519
|
+
{/* Secondary action: switch between OAuth and manual token entry */}
|
|
520
|
+
{authMode === "oauth" && !isOAuthConnected && !isOAuthBusy && !isConnecting && (
|
|
521
|
+
<div className="border-t border-border px-3 py-1.5">
|
|
522
|
+
{manualOverride ? (
|
|
523
|
+
<button
|
|
524
|
+
type="button"
|
|
525
|
+
onClick={onBackToOAuth}
|
|
526
|
+
className="text-[11px] text-muted-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:text-foreground hover:decoration-foreground"
|
|
527
|
+
>
|
|
528
|
+
{isVendorApprovalPending ? "Back to OAuth status" : "Sign in with OAuth instead"}
|
|
529
|
+
</button>
|
|
530
|
+
) : (
|
|
531
|
+
<button
|
|
532
|
+
type="button"
|
|
533
|
+
onClick={onManualOverride}
|
|
534
|
+
className="text-[11px] text-muted-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:text-foreground hover:decoration-foreground"
|
|
535
|
+
>
|
|
536
|
+
Enter token manually
|
|
537
|
+
</button>
|
|
538
|
+
)}
|
|
539
|
+
</div>
|
|
540
|
+
)}
|
|
541
|
+
|
|
458
542
|
{connectionError && (
|
|
459
543
|
<div className="flex items-start gap-2 border-t border-destructive/20 bg-destructive/5 px-3 py-2">
|
|
460
544
|
<WarningIcon className="mt-0.5 size-3.5 shrink-0 text-destructive" />
|
|
@@ -272,6 +272,9 @@ export function McpServerPicker({
|
|
|
272
272
|
? { type: "configure", serverKey: initialServerKey }
|
|
273
273
|
: { type: "list" },
|
|
274
274
|
);
|
|
275
|
+
const [manualOverrideKeys, setManualOverrideKeys] = useState<Set<string>>(
|
|
276
|
+
() => new Set(),
|
|
277
|
+
);
|
|
275
278
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
276
279
|
const results_ = useScrollShadows();
|
|
277
280
|
const selected_ = useScrollShadows();
|
|
@@ -397,13 +400,15 @@ export function McpServerPicker({
|
|
|
397
400
|
const auth = entry.mcpServer.spec?.auth;
|
|
398
401
|
const oauthTargetEnvVar = auth?.targetEnvVar || null;
|
|
399
402
|
const hasOAuth = !!auth;
|
|
403
|
+
const isManualOverride = manualOverrideKeys.has(view.serverKey);
|
|
400
404
|
|
|
401
405
|
const entryMissingVars =
|
|
402
406
|
entry.status === "needsSetup" ? entry.missingVariables : [];
|
|
403
407
|
|
|
404
|
-
const filteredMissingVars =
|
|
405
|
-
|
|
406
|
-
|
|
408
|
+
const filteredMissingVars =
|
|
409
|
+
oauthTargetEnvVar && !isManualOverride
|
|
410
|
+
? entryMissingVars.filter((v) => v.key !== oauthTargetEnvVar)
|
|
411
|
+
: entryMissingVars;
|
|
407
412
|
|
|
408
413
|
const hasManualVars = filteredMissingVars.length > 0;
|
|
409
414
|
|
|
@@ -411,24 +416,55 @@ export function McpServerPicker({
|
|
|
411
416
|
? entryMissingVars.some((v) => v.key === oauthTargetEnvVar)
|
|
412
417
|
: false;
|
|
413
418
|
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
419
|
+
const vendorApprovalStatus = auth?.vendorApprovalStatus;
|
|
420
|
+
const isVendorApprovalPending =
|
|
421
|
+
hasOAuth && vendorApprovalStatus === 1; // VendorApprovalStatus.PENDING
|
|
422
|
+
|
|
423
|
+
const oauthSignInProps =
|
|
424
|
+
hasOAuth && !isManualOverride
|
|
425
|
+
? {
|
|
426
|
+
onSignIn: async () => {
|
|
427
|
+
if (!entry.mcpServer.metadata?.id) return;
|
|
428
|
+
try {
|
|
429
|
+
await oauth.startOAuth(
|
|
430
|
+
entry.mcpServer.metadata.id,
|
|
431
|
+
activeOrg ?? org,
|
|
432
|
+
);
|
|
433
|
+
setup.onServerAdded(ref);
|
|
434
|
+
} catch {
|
|
435
|
+
// error state managed by oauth hook
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
phase: oauth.phase,
|
|
439
|
+
isConnected: !oauthTokenMissing,
|
|
440
|
+
error: oauth.error,
|
|
441
|
+
onClearError: oauth.clearError,
|
|
442
|
+
isVendorApprovalPending,
|
|
443
|
+
vendorApprovalDocsUrl: auth?.vendorApprovalDocsUrl || null,
|
|
444
|
+
}
|
|
445
|
+
: undefined;
|
|
446
|
+
|
|
447
|
+
const handleSwitchToManual = hasOAuth
|
|
448
|
+
? () => {
|
|
449
|
+
setManualOverrideKeys((prev) => {
|
|
450
|
+
const next = new Set(prev);
|
|
451
|
+
next.add(view.serverKey);
|
|
452
|
+
return next;
|
|
453
|
+
});
|
|
429
454
|
}
|
|
430
455
|
: undefined;
|
|
431
456
|
|
|
457
|
+
const handleSwitchToOAuth =
|
|
458
|
+
hasOAuth && isManualOverride
|
|
459
|
+
? () => {
|
|
460
|
+
setManualOverrideKeys((prev) => {
|
|
461
|
+
const next = new Set(prev);
|
|
462
|
+
next.delete(view.serverKey);
|
|
463
|
+
return next;
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
: undefined;
|
|
467
|
+
|
|
432
468
|
return (
|
|
433
469
|
<div className={cn("w-72", className)}>
|
|
434
470
|
<McpServerConfigPanel
|
|
@@ -445,6 +481,8 @@ export function McpServerPicker({
|
|
|
445
481
|
}
|
|
446
482
|
: undefined
|
|
447
483
|
}
|
|
484
|
+
onSwitchToManual={handleSwitchToManual}
|
|
485
|
+
onSwitchToOAuth={handleSwitchToOAuth}
|
|
448
486
|
discoveredTools={entry.discoveredTools}
|
|
449
487
|
toolApprovals={entry.toolApprovals}
|
|
450
488
|
enabledTools={
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
import { cn } from "@stigmer/theme";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
OAUTH_CALLBACK_MESSAGE_TYPE,
|
|
7
|
+
OAUTH_BROADCAST_CHANNEL,
|
|
8
|
+
} from "./useMcpServerOAuthConnect";
|
|
6
9
|
import type { OAuthCallbackMessage } from "./useMcpServerOAuthConnect";
|
|
7
10
|
|
|
8
11
|
/** Parameters extracted from the OAuth callback URL. */
|
|
@@ -92,28 +95,52 @@ export function OAuthCallbackHandler({
|
|
|
92
95
|
return;
|
|
93
96
|
}
|
|
94
97
|
|
|
98
|
+
const message: OAuthCallbackMessage = {
|
|
99
|
+
type: OAUTH_CALLBACK_MESSAGE_TYPE,
|
|
100
|
+
code,
|
|
101
|
+
state,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Always broadcast via BroadcastChannel — works even when
|
|
105
|
+
// Cross-Origin-Opener-Policy has severed window.opener.
|
|
106
|
+
let broadcastSent = false;
|
|
107
|
+
try {
|
|
108
|
+
const bc = new BroadcastChannel(OAUTH_BROADCAST_CHANNEL);
|
|
109
|
+
bc.postMessage(message);
|
|
110
|
+
bc.close();
|
|
111
|
+
broadcastSent = true;
|
|
112
|
+
} catch {
|
|
113
|
+
// BroadcastChannel unavailable — rely on postMessage below.
|
|
114
|
+
}
|
|
115
|
+
|
|
95
116
|
const opener = window.opener as Window | null;
|
|
96
117
|
if (opener && !opener.closed) {
|
|
97
|
-
const message: OAuthCallbackMessage = {
|
|
98
|
-
type: OAUTH_CALLBACK_MESSAGE_TYPE,
|
|
99
|
-
code,
|
|
100
|
-
state,
|
|
101
|
-
};
|
|
102
|
-
|
|
103
118
|
try {
|
|
104
119
|
opener.postMessage(message, window.location.origin);
|
|
105
120
|
setStatus("done");
|
|
106
121
|
window.close();
|
|
107
122
|
} catch {
|
|
108
|
-
|
|
109
|
-
"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
123
|
+
if (broadcastSent) {
|
|
124
|
+
setStatus("done");
|
|
125
|
+
window.close();
|
|
126
|
+
} else {
|
|
127
|
+
setErrorMessage(
|
|
128
|
+
"Could not communicate with the parent window. " +
|
|
129
|
+
"Please close this tab and try again.",
|
|
130
|
+
);
|
|
131
|
+
setStatus("error");
|
|
132
|
+
}
|
|
113
133
|
}
|
|
114
134
|
return;
|
|
115
135
|
}
|
|
116
136
|
|
|
137
|
+
// opener is null (COOP case) but BroadcastChannel delivered the message.
|
|
138
|
+
if (broadcastSent) {
|
|
139
|
+
setStatus("done");
|
|
140
|
+
window.close();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
117
144
|
if (onFallback) {
|
|
118
145
|
onFallback({ code, state });
|
|
119
146
|
setStatus("done");
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useMemo } from "react";
|
|
3
|
+
import { useCallback, useMemo, useState } from "react";
|
|
4
4
|
import type { EnvVarInput } from "@stigmer/sdk";
|
|
5
5
|
import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
|
|
6
|
+
import { VendorApprovalStatus } from "@stigmer/protos/ai/stigmer/iam/oauthapp/v1/spec_pb";
|
|
6
7
|
import { usePersonalEnvironment } from "../environment/usePersonalEnvironment";
|
|
7
8
|
import { diffEnv } from "../environment/diffEnv";
|
|
8
9
|
import { SYSTEM_ENV_VAR_KEYS } from "../environment/systemEnvVars";
|
|
@@ -91,6 +92,35 @@ export interface UseMcpServerCredentialsReturn {
|
|
|
91
92
|
readonly isSaving: boolean;
|
|
92
93
|
/** Re-check the personal environment. */
|
|
93
94
|
readonly refetch: () => void;
|
|
95
|
+
/**
|
|
96
|
+
* `true` when the referenced OAuthApp's vendor approval is still pending.
|
|
97
|
+
* When pending, the platform-managed OAuth sign-in flow is unavailable
|
|
98
|
+
* and the sign-in button should be disabled. Users can still connect
|
|
99
|
+
* via manual token entry (manual override).
|
|
100
|
+
*/
|
|
101
|
+
readonly isVendorApprovalPending: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Documentation URL for users who want to bring their own OAuth
|
|
104
|
+
* credentials while the platform's OAuth app is pending vendor approval.
|
|
105
|
+
* `null` when no documentation link is available.
|
|
106
|
+
*/
|
|
107
|
+
readonly vendorApprovalDocsUrl: string | null;
|
|
108
|
+
/**
|
|
109
|
+
* When `true`, the user has opted to bypass OAuth and enter the
|
|
110
|
+
* `target_env_var` token manually. In this state:
|
|
111
|
+
*
|
|
112
|
+
* - {@link missingVariables} includes the OAuth-managed variable
|
|
113
|
+
* - {@link isReady} no longer requires an active OAuth grant
|
|
114
|
+
*
|
|
115
|
+
* Only meaningful when `authMode` is `"oauth"`. Has no effect on
|
|
116
|
+
* manual-only servers.
|
|
117
|
+
*/
|
|
118
|
+
readonly manualOverride: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* Toggle the manual override. Pass `true` to switch from OAuth to
|
|
121
|
+
* manual token entry; `false` to revert to the OAuth flow.
|
|
122
|
+
*/
|
|
123
|
+
readonly setManualOverride: (override: boolean) => void;
|
|
94
124
|
}
|
|
95
125
|
|
|
96
126
|
/**
|
|
@@ -121,9 +151,31 @@ export interface UseMcpServerCredentialsReturn {
|
|
|
121
151
|
* ```tsx
|
|
122
152
|
* const creds = useMcpServerCredentials("acme", mcpServer);
|
|
123
153
|
*
|
|
124
|
-
* // OAuth server — sign-in button +
|
|
154
|
+
* // OAuth server — sign-in button + manual override escape hatch
|
|
125
155
|
* if (creds.authMode === "oauth" && !creds.isOAuthConnected) {
|
|
126
|
-
*
|
|
156
|
+
* if (creds.manualOverride) {
|
|
157
|
+
* // User opted to enter the token manually
|
|
158
|
+
* return (
|
|
159
|
+
* <>
|
|
160
|
+
* <EnvVarForm
|
|
161
|
+
* variables={creds.missingVariables}
|
|
162
|
+
* onSubmit={(values) => creds.saveCredentials(values)}
|
|
163
|
+
* isSubmitting={creds.isSaving}
|
|
164
|
+
* />
|
|
165
|
+
* <button onClick={() => creds.setManualOverride(false)}>
|
|
166
|
+
* Sign in with OAuth instead
|
|
167
|
+
* </button>
|
|
168
|
+
* </>
|
|
169
|
+
* );
|
|
170
|
+
* }
|
|
171
|
+
* return (
|
|
172
|
+
* <>
|
|
173
|
+
* <button onClick={startOAuth}>Sign in</button>
|
|
174
|
+
* <button onClick={() => creds.setManualOverride(true)}>
|
|
175
|
+
* Enter token manually
|
|
176
|
+
* </button>
|
|
177
|
+
* </>
|
|
178
|
+
* );
|
|
127
179
|
* }
|
|
128
180
|
*
|
|
129
181
|
* // Manual vars still needed (mixed mode or manual-only)
|
|
@@ -144,12 +196,18 @@ export function useMcpServerCredentials(
|
|
|
144
196
|
mcpServer: McpServer | null,
|
|
145
197
|
): UseMcpServerCredentialsReturn {
|
|
146
198
|
const personalEnv = usePersonalEnvironment(org);
|
|
199
|
+
const [manualOverride, setManualOverride] = useState(false);
|
|
147
200
|
|
|
148
201
|
const auth = mcpServer?.spec?.auth;
|
|
149
202
|
const authMode: McpServerAuthMode = auth ? "oauth" : "manual";
|
|
150
203
|
const oauthTargetEnvVar = auth?.targetEnvVar || null;
|
|
151
204
|
const tokenLifetimeHint = auth?.tokenLifetimeHint || null;
|
|
152
205
|
|
|
206
|
+
const isVendorApprovalPending =
|
|
207
|
+
authMode === "oauth" &&
|
|
208
|
+
auth?.vendorApprovalStatus === VendorApprovalStatus.PENDING;
|
|
209
|
+
const vendorApprovalDocsUrl = auth?.vendorApprovalDocsUrl || null;
|
|
210
|
+
|
|
153
211
|
const grantStatus = useOAuthGrantStatus(
|
|
154
212
|
authMode === "oauth" ? (mcpServer?.metadata?.id ?? null) : null,
|
|
155
213
|
authMode === "oauth" ? org : null,
|
|
@@ -178,15 +236,15 @@ export function useMcpServerCredentials(
|
|
|
178
236
|
);
|
|
179
237
|
|
|
180
238
|
const missingVariables = useMemo(() => {
|
|
181
|
-
if (!oauthTargetEnvVar) return requiredMissing;
|
|
239
|
+
if (!oauthTargetEnvVar || manualOverride) return requiredMissing;
|
|
182
240
|
return requiredMissing.filter((v) => v.key !== oauthTargetEnvVar);
|
|
183
|
-
}, [requiredMissing, oauthTargetEnvVar]);
|
|
241
|
+
}, [requiredMissing, oauthTargetEnvVar, manualOverride]);
|
|
184
242
|
|
|
185
243
|
const isReady =
|
|
186
244
|
!personalEnv.isLoading &&
|
|
187
245
|
!grantStatus.isLoading &&
|
|
188
246
|
missingVariables.length === 0 &&
|
|
189
|
-
(authMode === "manual" || isOAuthConnected);
|
|
247
|
+
(authMode === "manual" || manualOverride || isOAuthConnected);
|
|
190
248
|
|
|
191
249
|
const saveCredentials = useCallback(
|
|
192
250
|
async (values: Record<string, EnvVarInput>): Promise<void> => {
|
|
@@ -207,6 +265,8 @@ export function useMcpServerCredentials(
|
|
|
207
265
|
isOAuthConnected,
|
|
208
266
|
accessTokenExpiresAt: grantStatus.accessTokenExpiresAt,
|
|
209
267
|
tokenLifetimeHint,
|
|
268
|
+
isVendorApprovalPending,
|
|
269
|
+
vendorApprovalDocsUrl,
|
|
210
270
|
missingVariables,
|
|
211
271
|
isReady,
|
|
212
272
|
isLoading: personalEnv.isLoading || grantStatus.isLoading,
|
|
@@ -214,5 +274,7 @@ export function useMcpServerCredentials(
|
|
|
214
274
|
saveCredentials,
|
|
215
275
|
isSaving: personalEnv.isMutating,
|
|
216
276
|
refetch,
|
|
277
|
+
manualOverride,
|
|
278
|
+
setManualOverride,
|
|
217
279
|
};
|
|
218
280
|
}
|
|
@@ -19,6 +19,14 @@ import { toError } from "../internal/toError";
|
|
|
19
19
|
*/
|
|
20
20
|
export const OAUTH_CALLBACK_MESSAGE_TYPE = "stigmer:oauth:callback";
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* BroadcastChannel name used as a fallback when `window.opener` is severed
|
|
24
|
+
* by `Cross-Origin-Opener-Policy` headers on the OAuth provider.
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export const OAUTH_BROADCAST_CHANNEL = "stigmer:oauth:broadcast";
|
|
29
|
+
|
|
22
30
|
/**
|
|
23
31
|
* Shape of the `postMessage` payload sent from the OAuth callback popup.
|
|
24
32
|
*
|
|
@@ -231,6 +239,14 @@ export function useMcpServerOAuthConnect(): UseMcpServerOAuthConnectReturn {
|
|
|
231
239
|
// Popup callback listener
|
|
232
240
|
// ---------------------------------------------------------------------------
|
|
233
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Grace period (ms) after `popup.closed` is first detected before treating
|
|
244
|
+
* it as a user-initiated close. COOP providers sever the opener reference
|
|
245
|
+
* immediately, making `popup.closed` appear `true` while the popup is still
|
|
246
|
+
* active. The grace period lets the BroadcastChannel callback arrive.
|
|
247
|
+
*/
|
|
248
|
+
const POPUP_CLOSED_GRACE_MS = 5_000;
|
|
249
|
+
|
|
234
250
|
function waitForOAuthCallback(
|
|
235
251
|
popup: Window,
|
|
236
252
|
expectedState: string,
|
|
@@ -240,11 +256,13 @@ function waitForOAuthCallback(
|
|
|
240
256
|
let settled = false;
|
|
241
257
|
let timeoutId: ReturnType<typeof setTimeout>;
|
|
242
258
|
let pollId: ReturnType<typeof setInterval>;
|
|
259
|
+
let bc: BroadcastChannel | null = null;
|
|
243
260
|
|
|
244
261
|
function cleanup() {
|
|
245
262
|
if (timeoutId) clearTimeout(timeoutId);
|
|
246
263
|
if (pollId) clearInterval(pollId);
|
|
247
264
|
window.removeEventListener("message", onMessage);
|
|
265
|
+
try { bc?.close(); } catch { /* ignore */ }
|
|
248
266
|
}
|
|
249
267
|
|
|
250
268
|
function settle(
|
|
@@ -265,10 +283,7 @@ function waitForOAuthCallback(
|
|
|
265
283
|
closePopup(popup);
|
|
266
284
|
});
|
|
267
285
|
|
|
268
|
-
function
|
|
269
|
-
if (event.origin !== window.location.origin) return;
|
|
270
|
-
|
|
271
|
-
const data = event.data as OAuthCallbackMessage | undefined;
|
|
286
|
+
function validateAndSettle(data: OAuthCallbackMessage | undefined) {
|
|
272
287
|
if (data?.type !== OAUTH_CALLBACK_MESSAGE_TYPE) return;
|
|
273
288
|
|
|
274
289
|
if (data.state !== expectedState) {
|
|
@@ -289,8 +304,23 @@ function waitForOAuthCallback(
|
|
|
289
304
|
settle({ code: data.code, state: data.state });
|
|
290
305
|
}
|
|
291
306
|
|
|
307
|
+
function onMessage(event: MessageEvent) {
|
|
308
|
+
if (event.origin !== window.location.origin) return;
|
|
309
|
+
validateAndSettle(event.data as OAuthCallbackMessage | undefined);
|
|
310
|
+
}
|
|
311
|
+
|
|
292
312
|
window.addEventListener("message", onMessage);
|
|
293
313
|
|
|
314
|
+
// BroadcastChannel — works even when COOP severs window.opener.
|
|
315
|
+
try {
|
|
316
|
+
bc = new BroadcastChannel(OAUTH_BROADCAST_CHANNEL);
|
|
317
|
+
bc.onmessage = (event: MessageEvent) => {
|
|
318
|
+
validateAndSettle(event.data as OAuthCallbackMessage | undefined);
|
|
319
|
+
};
|
|
320
|
+
} catch {
|
|
321
|
+
// BroadcastChannel unsupported — rely on postMessage only.
|
|
322
|
+
}
|
|
323
|
+
|
|
294
324
|
timeoutId = setTimeout(() => {
|
|
295
325
|
settle(
|
|
296
326
|
new Error(
|
|
@@ -302,9 +332,20 @@ function waitForOAuthCallback(
|
|
|
302
332
|
closePopup(popup);
|
|
303
333
|
}, POPUP_CALLBACK_TIMEOUT_MS);
|
|
304
334
|
|
|
335
|
+
// COOP providers make popup.closed appear true immediately after
|
|
336
|
+
// cross-origin navigation. Wait a grace period before treating it
|
|
337
|
+
// as a real user-initiated close.
|
|
338
|
+
let popupClosedAt: number | null = null;
|
|
339
|
+
|
|
305
340
|
pollId = setInterval(() => {
|
|
306
341
|
if (popup.closed) {
|
|
307
|
-
|
|
342
|
+
if (popupClosedAt === null) {
|
|
343
|
+
popupClosedAt = Date.now();
|
|
344
|
+
} else if (Date.now() - popupClosedAt > POPUP_CLOSED_GRACE_MS) {
|
|
345
|
+
settle(new Error("The authentication window was closed before completing sign-in."));
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
popupClosedAt = null;
|
|
308
349
|
}
|
|
309
350
|
}, 500);
|
|
310
351
|
});
|