Selecting the OAuth auth method when adding an MCP source no longer forces the admin to complete the authorization flow before Save. The source is persisted with `{kind: "oauth2", connectionId}` pointing at the stable `mcp-oauth2-${namespace}` id, and each user later mints their own backing connection via McpSignInButton on the source detail page (per-scope shadowing resolves tokens per-user at invoke time). On the plugin side, remote addSource now captures resolver failures (e.g. no connection minted yet, missing header secret) the same way it already handles discovery failures: the source row still lands in the catalog so it shows up in the list and exposes a Sign-in affordance, while the underlying error still surfaces to the caller for logging. Stdio behaviour is unchanged — a stdio resolver failure is a config error the admin must fix first.
Unified · Split
| @@ -378,12 +378,16 @@ export default function AddMcpSource(props: { · 377 unmodified lines | ||
| 378 | 378 | const remoteHeadersComplete = remoteHeaders.every( |
| 379 | 379 | (header) => header.name.trim() && header.value.trim(), |
| 380 | 380 | ); |
| 381 | + // OAuth is "ready to save" even without tokens — the source is stored | |
| 382 | + // with a stable connectionId pointer, and each user completes their | |
| 383 | + // own sign-in via McpSignInButton on the source detail page (per-user | |
| 384 | + // scope shadowing means each user's tokens land at their own scope). | |
| 381 | 385 | const authReady = |
| 382 | 386 | remoteAuthMode === "none" |
| 383 | 387 | ? canUseNone |
| 384 | 388 | : remoteAuthMode === "header" |
| 385 | 389 | ? headerAuthComplete |
| 386 | − : tokens !== null; | |
| 390 | + : true; | |
| 387 | 391 | const canAdd = Boolean(probe) && authReady && remoteHeadersComplete && !isAdding && !isOAuthBusy; |
| 388 | 392 | // Probe failures are shown inline on the URL field; other failures |
| 389 | 393 | // (OAuth start, add source) render in the bottom error block. |
| @@ -493,6 +497,17 @@ export default function AddMcpSource(props: { · 103 unmodified lines | ||
| 493 | 497 | if (!probe) return; |
| 494 | 498 | dispatch({ type: "add-start" }); |
| 495 | 499 | const headerAuth = remoteAuthHeaders[0]; |
| 500 | + // For oauth2 sources saved without completing the flow, use the | |
| 501 | + // same stable connectionId the handleOAuth path would have used. | |
| 502 | + // This pins the source's auth pointer, so when a per-user sign-in | |
| 503 | + // runs later (via McpSignInButton) it mints the connection at the | |
| 504 | + // user scope against the same id — innermost-wins shadowing then | |
| 505 | + // resolves tokens per-user at invoke time. | |
| 506 | + const deferredOAuthConnectionId = mcpOAuthConnectionId( | |
| 507 | + slugifyNamespace(remoteIdentity.namespace) || | |
| 508 | + slugifyNamespace(probe.namespace ?? "") || | |
| 509 | + "mcp", | |
| 510 | + ); | |
| 496 | 511 | const auth = |
| 497 | 512 | remoteAuthMode === "header" && headerAuth?.secretId |
| 498 | 513 | ? { |
| @@ -501,10 +516,10 @@ export default function AddMcpSource(props: { · 2 unmodified lines | ||
| 501 | 516 | secretId: headerAuth.secretId, |
| 502 | 517 | ...(headerAuth.prefix ? { prefix: headerAuth.prefix } : {}), |
| 503 | 518 | } |
| 504 | − : remoteAuthMode === "oauth2" && tokens | |
| 519 | + : remoteAuthMode === "oauth2" | |
| 505 | 520 | ? { |
| 506 | 521 | kind: "oauth2" as const, |
| 507 | − connectionId: tokens.connectionId, | |
| 522 | + connectionId: tokens?.connectionId ?? deferredOAuthConnectionId, | |
| 508 | 523 | } |
| 509 | 524 | : { kind: "none" as const }; |
| 510 | 525 | const headers = Object.fromEntries( |
| @@ -788,9 +803,15 @@ export default function AddMcpSource(props: { · 277 unmodified lines | ||
| 788 | 803 | {remoteAuthMode === "oauth2" && ( |
| 789 | 804 | <> |
| 790 | 805 | {!tokens && state.step === "probed" && ( |
| 791 | − <Button onClick={handleOAuth} variant="outline"> | |
| 792 | − Sign in | |
| 793 | − </Button> | |
| 806 | + <div className="flex flex-col gap-2"> | |
| 807 | + <Button onClick={handleOAuth} variant="outline"> | |
| 808 | + Sign in | |
| 809 | + </Button> | |
| 810 | + <p className="text-[11px] text-muted-foreground"> | |
| 811 | + Optional — you can save the source now and each user can sign | |
| 812 | + in from the source detail page later. | |
| 813 | + </p> | |
| 814 | + </div> | |
| 794 | 815 | )} |
| 795 | 816 | |
| 796 | 817 | {!tokens && state.step === "oauth-starting" && ( |
Binary file not shown.
| @@ -650,8 +650,15 @@ export const mcpPlugin = definePlugin( · 649 unmodified lines | ||
| 650 | 650 | const namespace = normalizeNamespace(config); |
| 651 | 651 | const sd = toStoredSourceData(config); |
| 652 | 652 | |
| 653 | − // Resolve auth (may fail if stdio gate is off) | |
| 654 | − const ci = yield* resolveConnectorInput(sd, ctx, allowStdio).pipe( | |
| 653 | + // Stdio sources are gated — a resolver failure there is a | |
| 654 | + // config error the admin must fix before the source makes | |
| 655 | + // sense to persist at all. For remote sources we defer the | |
| 656 | + // resolver failure: auth might not be ready yet (oauth2 | |
| 657 | + // connection awaiting per-user sign-in, header secret | |
| 658 | + // awaiting upload) but the source row should still land so | |
| 659 | + // it shows up in the list and exposes a Sign-in affordance. | |
| 660 | + const resolved = yield* resolveConnectorInput(sd, ctx, allowStdio).pipe( | |
| 661 | + Effect.either, | |
| 655 | 662 | Effect.withSpan("mcp.plugin.resolve_connector", { |
| 656 | 663 | attributes: { |
| 657 | 664 | "mcp.source.namespace": namespace, |
| @@ -660,20 +667,26 @@ export const mcpPlugin = definePlugin( · 2 unmodified lines | ||
| 660 | 667 | }), |
| 661 | 668 | ); |
| 662 | 669 | |
| 663 | − const connector = createMcpConnector(ci); | |
| 664 | − // Try discovery. If it fails (auth, network, bad spec), we still | |
| 665 | − // want the source to land in the catalog so users see it in | |
| 666 | − // their list and can retry via refresh. The error still | |
| 667 | − // propagates to the caller so boot-time sync logs the reason. | |
| 668 | − const discovery = yield* discoverTools(connector).pipe( | |
| 669 | − Effect.mapError((err) => | |
| 670 | − mcpDiscoveryError(`MCP discovery failed: ${err.message}`), | |
| 671 | − ), | |
| 672 | − Effect.either, | |
| 673 | − Effect.withSpan("mcp.plugin.discover_tools", { | |
| 674 | − attributes: { "mcp.source.namespace": namespace }, | |
| 675 | − }), | |
| 676 | − ); | |
| 670 | + if (resolved._tag === "Left" && sd.transport === "stdio") { | |
| 671 | + return yield* Effect.fail(resolved.left); | |
| 672 | + } | |
| 673 | + | |
| 674 | + // Try discovery only if we have a live connector input. | |
| 675 | + // Otherwise fall straight through to the persist step with | |
| 676 | + // an empty manifest and surface the resolver failure to | |
| 677 | + // the caller at the end. | |
| 678 | + const discovery = | |
| 679 | + resolved._tag === "Right" | |
| 680 | + ? yield* discoverTools(createMcpConnector(resolved.right)).pipe( | |
| 681 | + Effect.mapError((err) => | |
| 682 | + mcpDiscoveryError(`MCP discovery failed: ${err.message}`), | |
| 683 | + ), | |
| 684 | + Effect.either, | |
| 685 | + Effect.withSpan("mcp.plugin.discover_tools", { | |
| 686 | + attributes: { "mcp.source.namespace": namespace }, | |
| 687 | + }), | |
| 688 | + ) | |
| 689 | + : ({ _tag: "Left", left: resolved.left } as const); | |
| 677 | 690 | const manifest = |
| 678 | 691 | discovery._tag === "Right" |
| 679 | 692 | ? discovery.right |