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 | const remoteHeadersComplete = remoteHeaders.every( | 378 | const remoteHeadersComplete = remoteHeaders.every( |
| 379 | (header) => header.name.trim() && header.value.trim(), | 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 | const authReady = | 385 | const authReady = |
| 382 | remoteAuthMode === "none" | 386 | remoteAuthMode === "none" |
| 383 | ? canUseNone | 387 | ? canUseNone |
| 384 | : remoteAuthMode === "header" | 388 | : remoteAuthMode === "header" |
| 385 | ? headerAuthComplete | 389 | ? headerAuthComplete |
| 386 | : tokens !== null; | 390 | : true; |
| 387 | const canAdd = Boolean(probe) && authReady && remoteHeadersComplete && !isAdding && !isOAuthBusy; | 391 | const canAdd = Boolean(probe) && authReady && remoteHeadersComplete && !isAdding && !isOAuthBusy; |
| 388 | // Probe failures are shown inline on the URL field; other failures | 392 | // Probe failures are shown inline on the URL field; other failures |
| 389 | // (OAuth start, add source) render in the bottom error block. | 393 | // (OAuth start, add source) render in the bottom error block. |
| @@ -493,6 +497,17 @@ export default function AddMcpSource(props: { · 103 unmodified lines | |||
| 493 | if (!probe) return; | 497 | if (!probe) return; |
| 494 | dispatch({ type: "add-start" }); | 498 | dispatch({ type: "add-start" }); |
| 495 | const headerAuth = remoteAuthHeaders[0]; | 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 | const auth = | 511 | const auth = |
| 497 | remoteAuthMode === "header" && headerAuth?.secretId | 512 | remoteAuthMode === "header" && headerAuth?.secretId |
| 498 | ? { | 513 | ? { |
| @@ -501,10 +516,10 @@ export default function AddMcpSource(props: { · 2 unmodified lines | |||
| 501 | secretId: headerAuth.secretId, | 516 | secretId: headerAuth.secretId, |
| 502 | ...(headerAuth.prefix ? { prefix: headerAuth.prefix } : {}), | 517 | ...(headerAuth.prefix ? { prefix: headerAuth.prefix } : {}), |
| 503 | } | 518 | } |
| 504 | : remoteAuthMode === "oauth2" && tokens | 519 | : remoteAuthMode === "oauth2" |
| 505 | ? { | 520 | ? { |
| 506 | kind: "oauth2" as const, | 521 | kind: "oauth2" as const, |
| 507 | connectionId: tokens.connectionId, | 522 | connectionId: tokens?.connectionId ?? deferredOAuthConnectionId, |
| 508 | } | 523 | } |
| 509 | : { kind: "none" as const }; | 524 | : { kind: "none" as const }; |
| 510 | const headers = Object.fromEntries( | 525 | const headers = Object.fromEntries( |
| @@ -788,9 +803,15 @@ export default function AddMcpSource(props: { · 277 unmodified lines | |||
| 788 | {remoteAuthMode === "oauth2" && ( | 803 | {remoteAuthMode === "oauth2" && ( |
| 789 | <> | 804 | <> |
| 790 | {!tokens && state.step === "probed" && ( | 805 | {!tokens && state.step === "probed" && ( |
| 791 | <Button onClick={handleOAuth} variant="outline"> | 806 | <div className="flex flex-col gap-2"> |
| 792 | Sign in | 807 | <Button onClick={handleOAuth} variant="outline"> |
| 793 | </Button> | 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 | {!tokens && state.step === "oauth-starting" && ( | 817 | {!tokens && state.step === "oauth-starting" && ( |
Binary file not shown.
| @@ -650,8 +650,15 @@ export const mcpPlugin = definePlugin( · 649 unmodified lines | |||
| 650 | const namespace = normalizeNamespace(config); | 650 | const namespace = normalizeNamespace(config); |
| 651 | const sd = toStoredSourceData(config); | 651 | const sd = toStoredSourceData(config); |
| 652 | 652 | ||
| 653 | // Resolve auth (may fail if stdio gate is off) | 653 | // Stdio sources are gated — a resolver failure there is a |
| 654 | const ci = yield* resolveConnectorInput(sd, ctx, allowStdio).pipe( | 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 | Effect.withSpan("mcp.plugin.resolve_connector", { | 662 | Effect.withSpan("mcp.plugin.resolve_connector", { |
| 656 | attributes: { | 663 | attributes: { |
| 657 | "mcp.source.namespace": namespace, | 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); | 670 | if (resolved._tag === "Left" && sd.transport === "stdio") { |
| 664 | // Try discovery. If it fails (auth, network, bad spec), we still | 671 | return yield* Effect.fail(resolved.left); |
| 665 | // want the source to land in the catalog so users see it in | 672 | } |
| 666 | // their list and can retry via refresh. The error still | 673 | |
| 667 | // propagates to the caller so boot-time sync logs the reason. | 674 | // Try discovery only if we have a live connector input. |
| 668 | const discovery = yield* discoverTools(connector).pipe( | 675 | // Otherwise fall straight through to the persist step with |
| 669 | Effect.mapError((err) => | 676 | // an empty manifest and surface the resolver failure to |
| 670 | mcpDiscoveryError(`MCP discovery failed: ${err.message}`), | 677 | // the caller at the end. |
| 671 | ), | 678 | const discovery = |
| 672 | Effect.either, | 679 | resolved._tag === "Right" |
| 673 | Effect.withSpan("mcp.plugin.discover_tools", { | 680 | ? yield* discoverTools(createMcpConnector(resolved.right)).pipe( |
| 674 | attributes: { "mcp.source.namespace": namespace }, | 681 | Effect.mapError((err) => |
| 675 | }), | 682 | mcpDiscoveryError(`MCP discovery failed: ${err.message}`), |
| 676 | ); | 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 | const manifest = | 690 | const manifest = |
| 678 | discovery._tag === "Right" | 691 | discovery._tag === "Right" |
| 679 | ? discovery.right | 692 | ? discovery.right |