@stigmer/react 0.0.80 → 0.0.82

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 (49) hide show
  1. package/environment/index.d.ts +1 -1
  2. package/environment/index.d.ts.map +1 -1
  3. package/environment/index.js +1 -1
  4. package/environment/index.js.map +1 -1
  5. package/environment/systemEnvVars.d.ts +16 -0
  6. package/environment/systemEnvVars.d.ts.map +1 -1
  7. package/environment/systemEnvVars.js +32 -0
  8. package/environment/systemEnvVars.js.map +1 -1
  9. package/index.d.ts +1 -1
  10. package/index.d.ts.map +1 -1
  11. package/index.js +1 -1
  12. package/index.js.map +1 -1
  13. package/mcp-server/McpServerConfigPanel.d.ts +23 -1
  14. package/mcp-server/McpServerConfigPanel.d.ts.map +1 -1
  15. package/mcp-server/McpServerConfigPanel.js +14 -5
  16. package/mcp-server/McpServerConfigPanel.js.map +1 -1
  17. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  18. package/mcp-server/McpServerDetailView.js +36 -14
  19. package/mcp-server/McpServerDetailView.js.map +1 -1
  20. package/mcp-server/McpServerPicker.d.ts.map +1 -1
  21. package/mcp-server/McpServerPicker.js +27 -3
  22. package/mcp-server/McpServerPicker.js.map +1 -1
  23. package/mcp-server/OAuthCallbackHandler.d.ts.map +1 -1
  24. package/mcp-server/OAuthCallbackHandler.js +33 -9
  25. package/mcp-server/OAuthCallbackHandler.js.map +1 -1
  26. package/mcp-server/useMcpServerConnect.d.ts +22 -12
  27. package/mcp-server/useMcpServerConnect.d.ts.map +1 -1
  28. package/mcp-server/useMcpServerConnect.js +17 -10
  29. package/mcp-server/useMcpServerConnect.js.map +1 -1
  30. package/mcp-server/useMcpServerCredentials.d.ts +53 -2
  31. package/mcp-server/useMcpServerCredentials.d.ts.map +1 -1
  32. package/mcp-server/useMcpServerCredentials.js +37 -6
  33. package/mcp-server/useMcpServerCredentials.js.map +1 -1
  34. package/mcp-server/useMcpServerOAuthConnect.d.ts +10 -1
  35. package/mcp-server/useMcpServerOAuthConnect.d.ts.map +1 -1
  36. package/mcp-server/useMcpServerOAuthConnect.js +60 -11
  37. package/mcp-server/useMcpServerOAuthConnect.js.map +1 -1
  38. package/package.json +4 -4
  39. package/src/environment/index.ts +1 -0
  40. package/src/environment/systemEnvVars.ts +39 -0
  41. package/src/index.ts +1 -0
  42. package/src/mcp-server/McpServerConfigPanel.tsx +82 -4
  43. package/src/mcp-server/McpServerDetailView.tsx +104 -16
  44. package/src/mcp-server/McpServerPicker.tsx +56 -18
  45. package/src/mcp-server/OAuthCallbackHandler.tsx +39 -12
  46. package/src/mcp-server/useMcpServerConnect.ts +33 -14
  47. package/src/mcp-server/useMcpServerCredentials.ts +68 -6
  48. package/src/mcp-server/useMcpServerOAuthConnect.ts +63 -15
  49. 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
 
@@ -140,8 +140,9 @@ export function McpServerDetailView({
140
140
  const handleOAuthSignIn = useCallback(async () => {
141
141
  if (!mcpServer?.metadata?.id) return;
142
142
 
143
+ const envKeys = Object.keys(mcpServer.spec?.env ?? {});
143
144
  try {
144
- await oauth.startOAuth(mcpServer.metadata.id, activeOrg ?? org);
145
+ await oauth.startOAuth(mcpServer.metadata.id, activeOrg ?? org, envKeys);
145
146
  credentials.refetch();
146
147
  refetch();
147
148
  } catch {
@@ -152,7 +153,11 @@ export function McpServerDetailView({
152
153
  const handleConnectClick = useCallback(async () => {
153
154
  if (!mcpServer?.metadata?.id) return;
154
155
 
155
- if (credentials.authMode === "oauth" && !credentials.isOAuthConnected) {
156
+ if (
157
+ credentials.authMode === "oauth" &&
158
+ !credentials.isOAuthConnected &&
159
+ !credentials.manualOverride
160
+ ) {
156
161
  handleOAuthSignIn();
157
162
  return;
158
163
  }
@@ -162,13 +167,14 @@ export function McpServerDetailView({
162
167
  return;
163
168
  }
164
169
 
170
+ const envKeys = Object.keys(mcpServer.spec?.env ?? {});
165
171
  try {
166
- await connection.connect(mcpServer.metadata.id);
172
+ await connection.connect(mcpServer.metadata.id, activeOrg ?? org, undefined, envKeys);
167
173
  refetch();
168
174
  } catch {
169
175
  // error state is managed by the hook
170
176
  }
171
- }, [mcpServer, credentials.authMode, credentials.isOAuthConnected, credentials.isReady, connection, refetch, handleOAuthSignIn]);
177
+ }, [mcpServer, credentials.authMode, credentials.isOAuthConnected, credentials.manualOverride, credentials.isReady, connection, refetch, handleOAuthSignIn]);
172
178
 
173
179
  const handleCredentialSubmit = useCallback(
174
180
  async (
@@ -184,10 +190,12 @@ export function McpServerDetailView({
184
190
  setShowCredentialForm(false);
185
191
 
186
192
  if (mcpServer?.metadata?.id) {
193
+ const envKeys = Object.keys(mcpServer.spec?.env ?? {});
194
+ const connectOrg = activeOrg ?? org;
187
195
  if (options.saveForFuture) {
188
- await connection.connect(mcpServer.metadata.id);
196
+ await connection.connect(mcpServer.metadata.id, connectOrg, undefined, envKeys);
189
197
  } else {
190
- await connection.connect(mcpServer.metadata.id, values);
198
+ await connection.connect(mcpServer.metadata.id, connectOrg, values, envKeys);
191
199
  }
192
200
  refetch();
193
201
  }
@@ -288,6 +296,17 @@ export function McpServerDetailView({
288
296
  isOAuthConnected={credentials.isOAuthConnected}
289
297
  accessTokenExpiresAt={credentials.accessTokenExpiresAt}
290
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
+ }}
291
310
  />
292
311
 
293
312
  {showCredentialForm && credentials.missingVariables.length > 0 && (
@@ -357,6 +376,11 @@ function ConnectBar({
357
376
  isOAuthConnected,
358
377
  accessTokenExpiresAt,
359
378
  tokenLifetimeHint,
379
+ isVendorApprovalPending,
380
+ vendorApprovalDocsUrl,
381
+ manualOverride,
382
+ onManualOverride,
383
+ onBackToOAuth,
360
384
  }: {
361
385
  readonly isConnecting: boolean;
362
386
  readonly connectionError: Error | null;
@@ -371,6 +395,11 @@ function ConnectBar({
371
395
  readonly isOAuthConnected: boolean;
372
396
  readonly accessTokenExpiresAt: bigint;
373
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;
374
403
  }) {
375
404
  const isOAuthBusy =
376
405
  oauthPhase === "initiating" ||
@@ -378,17 +407,22 @@ function ConnectBar({
378
407
  oauthPhase === "completing" ||
379
408
  oauthPhase === "connecting";
380
409
 
410
+ const showOAuthPrimary =
411
+ authMode === "oauth" && !isOAuthConnected && !manualOverride;
412
+
413
+ const oauthSignInDisabled = isVendorApprovalPending && showOAuthPrimary;
414
+
381
415
  const buttonLabel = (() => {
382
416
  if (isOAuthBusy) return oauthPhaseLabel(oauthPhase);
383
417
  if (isConnecting) return "Connecting...";
384
- if (authMode === "oauth" && !isOAuthConnected) return "Sign in to connect";
418
+ if (showOAuthPrimary) return "Sign in to connect";
385
419
  if (hasDiscoveredTools) return "Reconnect";
386
420
  return "Connect";
387
421
  })();
388
422
 
389
423
  const buttonIcon = (() => {
390
424
  if (isOAuthBusy || isConnecting) return <Spinner />;
391
- if (authMode === "oauth" && !isOAuthConnected) return <OAuthIcon className="size-3.5" />;
425
+ if (showOAuthPrimary) return <OAuthIcon className="size-3.5" />;
392
426
  if (hasDiscoveredTools) return <RefreshIcon className="size-3.5" />;
393
427
  return <ConnectIcon className="size-3.5" />;
394
428
  })();
@@ -402,6 +436,7 @@ function ConnectBar({
402
436
  : "";
403
437
  return `Tokens refresh automatically${hint}`;
404
438
  }
439
+ if (manualOverride) return "Entering token manually";
405
440
  if (hasDiscoveredTools) return formatConnectionSummary(toolCount, policyCount);
406
441
  return "Not connected yet";
407
442
  })();
@@ -410,37 +445,43 @@ function ConnectBar({
410
445
  <div className="flex flex-col">
411
446
  <div className="flex items-center justify-between px-3 py-2">
412
447
  <div className="flex items-center gap-2">
413
- {authMode === "oauth" && (
448
+ {authMode === "oauth" && !manualOverride && (
414
449
  <span
415
450
  className={cn(
416
451
  "inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-medium",
417
452
  isOAuthConnected
418
453
  ? "bg-success/10 text-success"
419
- : "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",
420
457
  )}
421
458
  >
422
459
  <span
423
460
  className={cn(
424
461
  "size-1.5 rounded-full",
425
- isOAuthConnected ? "bg-success" : "bg-muted-foreground",
462
+ isOAuthConnected
463
+ ? "bg-success"
464
+ : oauthSignInDisabled
465
+ ? "bg-amber-500"
466
+ : "bg-muted-foreground",
426
467
  )}
427
468
  aria-hidden="true"
428
469
  />
429
- {isOAuthConnected ? "Connected" : "Not connected"}
470
+ {isOAuthConnected ? "Connected" : oauthSignInDisabled ? "Pending approval" : "Not connected"}
430
471
  </span>
431
472
  )}
432
473
  <span className="text-xs text-muted-foreground">
433
- {statusText}
474
+ {oauthSignInDisabled ? "OAuth sign-in is pending vendor approval" : statusText}
434
475
  </span>
435
476
  </div>
436
477
  <button
437
478
  type="button"
438
479
  onClick={onConnect}
439
- disabled={isConnecting || isOAuthBusy || credentialsLoading}
480
+ disabled={isConnecting || isOAuthBusy || credentialsLoading || oauthSignInDisabled}
440
481
  data-cursor-target="connect-button"
441
482
  className={cn(
442
483
  "inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium",
443
- authMode === "oauth" && !isOAuthConnected
484
+ showOAuthPrimary
444
485
  ? "bg-primary text-primary-foreground hover:bg-primary-hover"
445
486
  : "border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
446
487
  "disabled:pointer-events-none disabled:opacity-50",
@@ -451,6 +492,53 @@ function ConnectBar({
451
492
  </button>
452
493
  </div>
453
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
+
454
542
  {connectionError && (
455
543
  <div className="flex items-start gap-2 border-t border-destructive/20 bg-destructive/5 px-3 py-2">
456
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");
@@ -6,7 +6,7 @@ import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/
6
6
  import { ConnectInputSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
7
7
  import type { EnvVarInput } from "@stigmer/sdk";
8
8
  import { useStigmer } from "../hooks";
9
- import { resolveSystemEnvVarValues } from "../environment/systemEnvVars";
9
+ import { resolveDeclaredSystemEnvVars } from "../environment/systemEnvVars";
10
10
  import { toError } from "../internal/toError";
11
11
 
12
12
  /** Return value of {@link useMcpServerConnect}. */
@@ -20,20 +20,30 @@ export interface UseMcpServerConnectReturn {
20
20
  * (typically 5-15 seconds, ~30s timeout).
21
21
  *
22
22
  * Platform system env vars (`STIGMER_SERVER_ADDRESS`,
23
- * `STIGMER_API_KEY`) are always injected automatically from
24
- * the current SDK context. When `runtimeEnv` is also provided,
25
- * those values are merged on top (caller values win). The backend
26
- * merges the result on top of the user's personal environment so
27
- * saved credentials (e.g., OAuth tokens) are still resolved.
23
+ * `STIGMER_API_KEY`) are injected only when the MCP server
24
+ * declares them in its `spec.env`. Pass `declaredEnvKeys` so the
25
+ * hook can determine which system vars the server actually needs.
26
+ * When omitted, no system vars are injected.
27
+ *
28
+ * When `runtimeEnv` is provided, those values are merged on top
29
+ * (caller values win). The backend merges the result on top of
30
+ * the user's personal environment so saved credentials (e.g.,
31
+ * OAuth tokens) are still resolved.
28
32
  *
29
33
  * @param mcpServerId - System-generated ID of the MCP server (metadata.id).
34
+ * @param org - The caller's active organization slug. Required for
35
+ * OAuth grant lookup and personal environment resolution.
30
36
  * @param runtimeEnv - Optional additional environment variables.
37
+ * @param declaredEnvKeys - Keys from the server's `spec.env` declaration.
38
+ * System vars are only injected when declared here.
31
39
  * @returns The updated McpServer with populated status.discovered_capabilities
32
40
  * and status.tool_approvals.
33
41
  */
34
42
  readonly connect: (
35
43
  mcpServerId: string,
44
+ org: string,
36
45
  runtimeEnv?: Record<string, EnvVarInput>,
46
+ declaredEnvKeys?: readonly string[],
37
47
  ) => Promise<McpServer>;
38
48
  /** `true` while the connect RPC is in flight. */
39
49
  readonly isConnecting: boolean;
@@ -52,10 +62,10 @@ export interface UseMcpServerConnectReturn {
52
62
  * approval policy via a structured-output LLM call.
53
63
  *
54
64
  * Platform system env vars (`STIGMER_SERVER_ADDRESS`,
55
- * `STIGMER_API_KEY`) are always injected from the current SDK
56
- * context. The backend merges these on top of the caller's personal
57
- * environment, so saved credentials (e.g., OAuth tokens) are still
58
- * resolved while platform addresses are always up to date.
65
+ * `STIGMER_API_KEY`) are injected only when the target MCP server
66
+ * declares them in `spec.env`. Pass the server's declared env keys
67
+ * via `declaredEnvKeys` so the hook knows which system vars to
68
+ * include. When not provided, no system vars are injected.
59
69
  *
60
70
  * Additional one-time credentials can be passed via `runtimeEnv`
61
71
  * and will override both system vars and personal env values.
@@ -67,13 +77,15 @@ export interface UseMcpServerConnectReturn {
67
77
  *
68
78
  * // Saved credentials: already in personal environment
69
79
  * async function handleConnectSaved() {
70
- * await connect(mcpServer.metadata.id);
80
+ * const envKeys = Object.keys(mcpServer.spec?.env ?? {});
81
+ * await connect(mcpServer.metadata.id, undefined, envKeys);
71
82
  * refetch();
72
83
  * }
73
84
  *
74
85
  * // One-time use: pass credentials directly
75
86
  * async function handleConnectTemporary(values: Record<string, EnvVarInput>) {
76
- * await connect(mcpServer.metadata.id, values);
87
+ * const envKeys = Object.keys(mcpServer.spec?.env ?? {});
88
+ * await connect(mcpServer.metadata.id, values, envKeys);
77
89
  * refetch();
78
90
  * }
79
91
  * ```
@@ -88,13 +100,17 @@ export function useMcpServerConnect(): UseMcpServerConnectReturn {
88
100
  const connect = useCallback(
89
101
  async (
90
102
  mcpServerId: string,
103
+ org: string,
91
104
  runtimeEnv?: Record<string, EnvVarInput>,
105
+ declaredEnvKeys?: readonly string[],
92
106
  ): Promise<McpServer> => {
93
107
  setIsConnecting(true);
94
108
  setError(null);
95
109
 
96
110
  try {
97
- const systemEnv = await resolveSystemEnvVarValues(stigmer);
111
+ const systemEnv = declaredEnvKeys
112
+ ? await resolveDeclaredSystemEnvVars(stigmer, declaredEnvKeys)
113
+ : {};
98
114
  const mergedEnv = { ...systemEnv, ...(runtimeEnv ?? {}) };
99
115
 
100
116
  const runtimeEnvMap: Record<string, { value: string; isSecret: boolean }> = {};
@@ -107,7 +123,10 @@ export function useMcpServerConnect(): UseMcpServerConnectReturn {
107
123
 
108
124
  const input = create(ConnectInputSchema, {
109
125
  mcpServerId,
110
- runtimeEnv: runtimeEnvMap,
126
+ org,
127
+ ...(Object.keys(runtimeEnvMap).length > 0
128
+ ? { runtimeEnv: runtimeEnvMap }
129
+ : {}),
111
130
  });
112
131
 
113
132
  return await stigmer.mcpServer.connect(input);
@@ -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
  }