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 | return out; | 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 | * OpenAPI 3.x requires OAuth2 tokenUrl/authorizationUrl to be absolute, | 83 | * OpenAPI 3.x requires OAuth2 tokenUrl/authorizationUrl to be absolute, |
| 74 | * but some specs ship relative paths like `/api/rest/v1/oauth/token`. | 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 | const selectedOAuth2Preset: OAuth2Preset | null = | 280 | const selectedOAuth2Preset: OAuth2Preset | null = |
| 271 | strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null; | 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 | const oauth2Ready = | 293 | const oauth2Ready = |
| 274 | strategy.kind !== "oauth2" || oauth2Auth !== null; | 294 | strategy.kind !== "oauth2" || oauth2Auth !== null || oauth2DeferrableReady; |
| 275 | 295 | ||
| 276 | const canAdd = | 296 | const canAdd = |
| 277 | preview !== null && | 297 | preview !== null && |
| @@ -523,6 +543,40 @@ export default function AddOpenApiSource(props: { · 245 unmodified lines | |||
| 523 | kind: "openapi", | 543 | kind: "openapi", |
| 524 | url: resolvedBaseUrl || undefined, | 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 | try { | 580 | try { |
| 527 | await doAdd({ | 581 | await doAdd({ |
| 528 | path: { scopeId }, | 582 | path: { scopeId }, |
| @@ -532,7 +586,7 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines | |||
| 532 | namespace: slugifyNamespace(identity.namespace) || undefined, | 586 | namespace: slugifyNamespace(identity.namespace) || undefined, |
| 533 | baseUrl: resolvedBaseUrl || undefined, | 587 | baseUrl: resolvedBaseUrl || undefined, |
| 534 | ...(hasHeaders ? { headers: allHeaders } : {}), | 588 | ...(hasHeaders ? { headers: allHeaders } : {}), |
| 535 | ...(oauth2Auth ? { oauth2: oauth2Auth } : {}), | 589 | ...(oauth2ToSave ? { oauth2: oauth2ToSave } : {}), |
| 536 | }, | 590 | }, |
| 537 | reactivityKeys: sourceWriteKeys, | 591 | reactivityKeys: sourceWriteKeys, |
| 538 | }); | 592 | }); |
| @@ -960,14 +1014,20 @@ export default function AddOpenApiSource(props: { · 421 unmodified lines | |||
| 960 | </Button> | 1014 | </Button> |
| 961 | </div> | 1015 | </div> |
| 962 | ) : ( | 1016 | ) : ( |
| 963 | <Button | 1017 | <div className="flex flex-col gap-1.5"> |
| 964 | variant="secondary" | 1018 | <Button |
| 965 | onClick={handleConnectOAuth2} | 1019 | variant="secondary" |
| 966 | disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0} | 1020 | onClick={handleConnectOAuth2} |
| 967 | className="w-full" | 1021 | disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0} |
| 968 | > | 1022 | className="w-full" |
| 969 | Connect via OAuth | 1023 | > |
| 970 | </Button> | 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 | {oauth2Error && ( | 1033 | {oauth2Error && ( |
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | |||
| 25 | 25 | ||
| 26 | const TEST_SCOPE = "test-scope"; | 26 | const TEST_SCOPE = "test-scope"; |
| 27 | import { openApiPlugin } from "./plugin"; | 27 | import { openApiPlugin } from "./plugin"; |
| 28 | import { OAuth2Auth } from "./types"; | ||
| 28 | 29 | ||
| 29 | const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; | 30 | const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; |
| 30 | 31 | ||
| @@ -603,4 +604,69 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 572 unmodified lines | |||
| 603 | expect(orgView?.config.baseUrl).toBe("https://org.example.com"); | 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 | }); |