@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.
Files changed (29) hide show
  1. package/mcp-server/McpServerConfigPanel.d.ts +23 -1
  2. package/mcp-server/McpServerConfigPanel.d.ts.map +1 -1
  3. package/mcp-server/McpServerConfigPanel.js +14 -5
  4. package/mcp-server/McpServerConfigPanel.js.map +1 -1
  5. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  6. package/mcp-server/McpServerDetailView.js +28 -10
  7. package/mcp-server/McpServerDetailView.js.map +1 -1
  8. package/mcp-server/McpServerPicker.d.ts.map +1 -1
  9. package/mcp-server/McpServerPicker.js +27 -3
  10. package/mcp-server/McpServerPicker.js.map +1 -1
  11. package/mcp-server/OAuthCallbackHandler.d.ts.map +1 -1
  12. package/mcp-server/OAuthCallbackHandler.js +33 -9
  13. package/mcp-server/OAuthCallbackHandler.js.map +1 -1
  14. package/mcp-server/useMcpServerCredentials.d.ts +53 -2
  15. package/mcp-server/useMcpServerCredentials.d.ts.map +1 -1
  16. package/mcp-server/useMcpServerCredentials.js +37 -6
  17. package/mcp-server/useMcpServerCredentials.js.map +1 -1
  18. package/mcp-server/useMcpServerOAuthConnect.d.ts +7 -0
  19. package/mcp-server/useMcpServerOAuthConnect.d.ts.map +1 -1
  20. package/mcp-server/useMcpServerOAuthConnect.js +48 -5
  21. package/mcp-server/useMcpServerOAuthConnect.js.map +1 -1
  22. package/package.json +4 -4
  23. package/src/mcp-server/McpServerConfigPanel.tsx +82 -4
  24. package/src/mcp-server/McpServerDetailView.tsx +96 -12
  25. package/src/mcp-server/McpServerPicker.tsx +56 -18
  26. package/src/mcp-server/OAuthCallbackHandler.tsx +39 -12
  27. package/src/mcp-server/useMcpServerCredentials.ts +68 -6
  28. package/src/mcp-server/useMcpServerOAuthConnect.ts +46 -5
  29. 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 (credentials.authMode === "oauth" && !credentials.isOAuthConnected) {
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 (authMode === "oauth" && !isOAuthConnected) return "Sign in to connect";
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 (authMode === "oauth" && !isOAuthConnected) return <OAuthIcon className="size-3.5" />;
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
- : "bg-muted text-muted-foreground",
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 ? "bg-success" : "bg-muted-foreground",
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
- authMode === "oauth" && !isOAuthConnected
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&apos;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 = oauthTargetEnvVar
405
- ? entryMissingVars.filter((v) => v.key !== oauthTargetEnvVar)
406
- : entryMissingVars;
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 oauthSignInProps = hasOAuth
415
- ? {
416
- onSignIn: async () => {
417
- if (!entry.mcpServer.metadata?.id) return;
418
- try {
419
- await oauth.startOAuth(entry.mcpServer.metadata.id, activeOrg ?? org);
420
- setup.onServerAdded(ref);
421
- } catch {
422
- // error state managed by oauth hook
423
- }
424
- },
425
- phase: oauth.phase,
426
- isConnected: !oauthTokenMissing,
427
- error: oauth.error,
428
- onClearError: oauth.clearError,
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 { OAUTH_CALLBACK_MESSAGE_TYPE } from "./useMcpServerOAuthConnect";
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
- setErrorMessage(
109
- "Could not communicate with the parent window. " +
110
- "Please close this tab and try again.",
111
- );
112
- setStatus("error");
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 + optional manual form
154
+ * // OAuth server — sign-in button + manual override escape hatch
125
155
  * if (creds.authMode === "oauth" && !creds.isOAuthConnected) {
126
- * return <button onClick={startOAuth}>Sign in</button>;
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 onMessage(event: MessageEvent) {
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
- settle(new Error("The authentication window was closed before completing sign-in."));
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
  });