Mirror the recent MCP change for OpenAPI sources. Selecting OAuth2 as the auth strategy no longer forces the admin to complete the authorization flow before Save — the source is persisted with its OAuth config intact and a deterministic placeholder connectionId, and each user later mints their own backing connection via OpenApiSignInButton on the source detail page. The sign-in handler already rewrites `oauth2.connectionId` on successful sign-in, so the placeholder just needs to be distinct from any real `openapi-oauth2-\${uuid}` and stable per-namespace. UI: `oauth2DeferrableReady` now gates Save on having a preset + a client id secret (plus a client secret for clientCredentials) — enough metadata for the deferred sign-in path to run. The Connect via OAuth button grows a one-line hint clarifying it's optional. Plugin side needs no changes: `addSpec` → `rebuildSource` parses the spec and persists tool defs without ever resolving the connection. The access token is only fetched at invoke time.
Unified · Split
| @@ -69,6 +69,16 @@ const substituteUrlVariables = (url: string, values: Record<string, string>): st · 68 unmodified lines | ||
| 69 | 69 | return out; |
| 70 | 70 | }; |
| 71 | 71 | |
| 72 | +/** | |
| 73 | + * Stable placeholder connection id used when saving a source without | |
| 74 | + * completing the OAuth flow. OpenApiSignInButton rewrites `oauth2.connectionId` | |
| 75 | + * to a freshly minted id on first sign-in, so this only needs to be | |
| 76 | + * deterministic per-namespace and distinct from any real connection id | |
| 77 | + * (real ids are `openapi-oauth2-${uuid}`). | |
| 78 | + */ | |
| 79 | +const openApiOAuthConnectionId = (namespaceSlug: string): string => | |
| 80 | + `openapi-oauth2-pending-${namespaceSlug || "default"}`; | |
| 81 | + | |
| 72 | 82 | /** |
| 73 | 83 | * OpenAPI 3.x requires OAuth2 tokenUrl/authorizationUrl to be absolute, |
| 74 | 84 | * but some specs ship relative paths like `/api/rest/v1/oauth/token`. |
| @@ -270,8 +280,18 @@ export default function AddOpenApiSource(props: { · 195 unmodified lines | ||
| 270 | 280 | const selectedOAuth2Preset: OAuth2Preset | null = |
| 271 | 281 | strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null; |
| 272 | 282 | |
| 283 | + // OAuth is "ready to save" without completing the flow as long as we have | |
| 284 | + // enough metadata to run sign-in later: a preset, a client id secret, and — | |
| 285 | + // for clientCredentials — a client secret. Each user signs in via | |
| 286 | + // OpenApiSignInButton on the source detail page; innermost-wins scope | |
| 287 | + // shadowing resolves tokens per-user at invoke time. | |
| 288 | + const oauth2DeferrableReady = | |
| 289 | + selectedOAuth2Preset !== null && | |
| 290 | + oauth2ClientIdSecretId !== null && | |
| 291 | + (selectedOAuth2Preset.flow !== "clientCredentials" || | |
| 292 | + oauth2ClientSecretSecretId !== null); | |
| 273 | 293 | const oauth2Ready = |
| 274 | − strategy.kind !== "oauth2" || oauth2Auth !== null; | |
| 294 | + strategy.kind !== "oauth2" || oauth2Auth !== null || oauth2DeferrableReady; | |
| 275 | 295 | |
| 276 | 296 | const canAdd = |
| 277 | 297 | preview !== null && |
| @@ -523,6 +543,40 @@ export default function AddOpenApiSource(props: { · 245 unmodified lines | ||
| 523 | 543 | kind: "openapi", |
| 524 | 544 | url: resolvedBaseUrl || undefined, |
| 525 | 545 | }); |
| 546 | + // If the user picked oauth2 but didn't complete sign-in, persist a | |
| 547 | + // deferred OAuth2Auth with a stable placeholder connectionId so the | |
| 548 | + // source lands with its OAuth config intact. OpenApiSignInButton | |
| 549 | + // rewrites the pointer once a user actually signs in. | |
| 550 | + let oauth2ToSave: OAuth2Auth | null = oauth2Auth; | |
| 551 | + if ( | |
| 552 | + !oauth2ToSave && | |
| 553 | + strategy.kind === "oauth2" && | |
| 554 | + selectedOAuth2Preset && | |
| 555 | + oauth2ClientIdSecretId | |
| 556 | + ) { | |
| 557 | + const tokenUrl = resolveOAuthUrl( | |
| 558 | + selectedOAuth2Preset.tokenUrl, | |
| 559 | + resolvedBaseUrl, | |
| 560 | + ); | |
| 561 | + const authorizationUrl = | |
| 562 | + selectedOAuth2Preset.flow === "authorizationCode" | |
| 563 | + ? resolveOAuthUrl( | |
| 564 | + Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), | |
| 565 | + resolvedBaseUrl, | |
| 566 | + ) || null | |
| 567 | + : null; | |
| 568 | + oauth2ToSave = new OAuth2Auth({ | |
| 569 | + kind: "oauth2", | |
| 570 | + connectionId: openApiOAuthConnectionId(namespace), | |
| 571 | + securitySchemeName: selectedOAuth2Preset.securitySchemeName, | |
| 572 | + flow: selectedOAuth2Preset.flow, | |
| 573 | + tokenUrl, | |
| 574 | + authorizationUrl, | |
| 575 | + clientIdSecretId: oauth2ClientIdSecretId, | |
| 576 | + clientSecretSecretId: oauth2ClientSecretSecretId, | |
| 577 | + scopes: [...oauth2SelectedScopes], | |
| 578 | + }); | |
| 579 | + } | |
| 526 | 580 | try { |
| 527 | 581 | await doAdd({ |
| 528 | 582 | path: { scopeId }, |
| @@ -532,7 +586,7 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines | ||
| 532 | 586 | namespace: slugifyNamespace(identity.namespace) || undefined, |
| 533 | 587 | baseUrl: resolvedBaseUrl || undefined, |
| 534 | 588 | ...(hasHeaders ? { headers: allHeaders } : {}), |
| 535 | − ...(oauth2Auth ? { oauth2: oauth2Auth } : {}), | |
| 589 | + ...(oauth2ToSave ? { oauth2: oauth2ToSave } : {}), | |
| 536 | 590 | }, |
| 537 | 591 | reactivityKeys: sourceWriteKeys, |
| 538 | 592 | }); |
| @@ -960,14 +1014,20 @@ export default function AddOpenApiSource(props: { · 421 unmodified lines | ||
| 960 | 1014 | </Button> |
| 961 | 1015 | </div> |
| 962 | 1016 | ) : ( |
| 963 | − <Button | |
| 964 | − variant="secondary" | |
| 965 | − onClick={handleConnectOAuth2} | |
| 966 | − disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0} | |
| 967 | − className="w-full" | |
| 968 | − > | |
| 969 | − Connect via OAuth | |
| 970 | − </Button> | |
| 1017 | + <div className="flex flex-col gap-1.5"> | |
| 1018 | + <Button | |
| 1019 | + variant="secondary" | |
| 1020 | + onClick={handleConnectOAuth2} | |
| 1021 | + disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0} | |
| 1022 | + className="w-full" | |
| 1023 | + > | |
| 1024 | + Connect via OAuth | |
| 1025 | + </Button> | |
| 1026 | + <p className="text-[11px] text-muted-foreground"> | |
| 1027 | + Optional — you can save the source now and each user can sign | |
| 1028 | + in from the source detail page later. | |
| 1029 | + </p> | |
| 1030 | + </div> | |
| 971 | 1031 | )} |
| 972 | 1032 | |
| 973 | 1033 | {oauth2Error && ( |
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | ||
| 25 | 25 | |
| 26 | 26 | const TEST_SCOPE = "test-scope"; |
| 27 | 27 | import { openApiPlugin } from "./plugin"; |
| 28 | +import { OAuth2Auth } from "./types"; | |
| 28 | 29 | |
| 29 | 30 | const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; |
| 30 | 31 | |
| @@ -603,4 +604,69 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 572 unmodified lines | ||
| 603 | 604 | expect(orgView?.config.baseUrl).toBe("https://org.example.com"); |
| 604 | 605 | }), |
| 605 | 606 | ); |
| 607 | + | |
| 608 | + it.effect( | |
| 609 | + "addSpec persists a source with deferred OAuth2Auth (no live connection yet)", | |
| 610 | + () => | |
| 611 | + Effect.gen(function* () { | |
| 612 | + const httpClient = yield* HttpClient.HttpClient; | |
| 613 | + const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); | |
| 614 | + | |
| 615 | + const executor = yield* createExecutor( | |
| 616 | + makeTestConfig({ | |
| 617 | + plugins: [ | |
| 618 | + openApiPlugin({ httpClientLayer: clientLayer }), | |
| 619 | + memorySecretsPlugin(), | |
| 620 | + ] as const, | |
| 621 | + }), | |
| 622 | + ); | |
| 623 | + | |
| 624 | + // A team-shared client id secret, but no live connection for this | |
| 625 | + // scope — the admin is saving the source and deferring sign-in | |
| 626 | + // to individual users. | |
| 627 | + yield* executor.secrets.set( | |
| 628 | + new SetSecretInput({ | |
| 629 | + id: SecretId.make("acme-client-id"), | |
| 630 | + scope: ScopeId.make(TEST_SCOPE), | |
| 631 | + name: "Acme Client ID", | |
| 632 | + value: "client-abc", | |
| 633 | + }), | |
| 634 | + ); | |
| 635 | + | |
| 636 | + const deferredAuth = new OAuth2Auth({ | |
| 637 | + kind: "oauth2", | |
| 638 | + connectionId: "openapi-oauth2-pending-deferred", | |
| 639 | + securitySchemeName: "oauth2", | |
| 640 | + flow: "authorizationCode", | |
| 641 | + tokenUrl: "https://auth.example.com/token", | |
| 642 | + authorizationUrl: "https://auth.example.com/authorize", | |
| 643 | + clientIdSecretId: "acme-client-id", | |
| 644 | + clientSecretSecretId: null, | |
| 645 | + scopes: ["read:items"], | |
| 646 | + }); | |
| 647 | + | |
| 648 | + const result = yield* executor.openapi.addSpec({ | |
| 649 | + spec: specJson, | |
| 650 | + scope: TEST_SCOPE, | |
| 651 | + namespace: "deferred", | |
| 652 | + baseUrl: "https://api.example.com", | |
| 653 | + oauth2: deferredAuth, | |
| 654 | + }); | |
| 655 | + | |
| 656 | + expect(result.toolCount).toBeGreaterThan(0); | |
| 657 | + | |
| 658 | + const stored = yield* executor.openapi.getSource("deferred", TEST_SCOPE); | |
| 659 | + expect(stored).not.toBeNull(); | |
| 660 | + expect(stored?.config.oauth2?.connectionId).toBe( | |
| 661 | + "openapi-oauth2-pending-deferred", | |
| 662 | + ); | |
| 663 | + expect(stored?.config.oauth2?.clientIdSecretId).toBe("acme-client-id"); | |
| 664 | + expect(stored?.config.oauth2?.flow).toBe("authorizationCode"); | |
| 665 | + | |
| 666 | + // Tools should be listed even without a live connection; invocation | |
| 667 | + // is what requires the token, not registration. | |
| 668 | + const tools = yield* executor.tools.list(); | |
| 669 | + expect(tools.some((t) => t.id.startsWith("deferred."))).toBe(true); | |
| 670 | + }), | |
| 671 | + ); | |
| 606 | 672 | }); |