diff --git a/e2e/scenarios/connect-handoff.test.ts b/e2e/scenarios/connect-handoff.test.ts
new file mode 100644
index 000000000..75a5e1ae7
--- /dev/null
+++ b/e2e/scenarios/connect-handoff.test.ts
@@ -0,0 +1,218 @@
+// The agentic connect handoff: an agent adds an API over MCP, asks for a
+// handoff URL (`coreTools.connections.createHandoff`), and the user opens that
+// URL in a browser to paste the credential. This scenario walks the WHOLE
+// path — the exact flow that failed in production with a "wrong / bad" URL —
+// against a real emulated provider (resend.emulators.dev) so the failure
+// point is captured with trace + screenshots instead of guessed at:
+//
+// 1. MCP `execute` → `openapi.addSpec` registers the emulated Resend API
+// 2. MCP `execute` → `connections.createHandoff` returns the browser URL
+// 3. The URL's origin must be THIS deployment (not a hardcoded host)
+// 4. Playwright opens it: the Add connection modal must be open with a
+// credential field, the emulator-minted API key is pasted and submitted
+// 5. The saved connection is proven live: `execute` sends an email through
+// the new tools and the emulator's request ledger shows the call
+// arriving with the pasted bearer token
+import { randomBytes } from "node:crypto";
+
+import { expect } from "@effect/vitest";
+import { Effect } from "effect";
+import { composePluginApi } from "@executor-js/api/server";
+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
+
+import { scenario } from "../src/scenario";
+import { Api, Browser, Mcp, Target } from "../src/services";
+import type { Identity, Target as TargetShape } from "../src/target";
+import type { BrowserSurface } from "../src/surfaces/browser";
+import type { McpSession } from "../src/surfaces/mcp";
+
+const api = composePluginApi([openApiHttpPlugin()] as const);
+
+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
+
+const EMULATOR_BASE = "https://resend.emulators.dev";
+
+// The emulator serves its own OpenAPI document (bearer auth, same shape as
+// real Resend — and as the Sentry spec that failed in prod). Adding it by URL
+// with no authenticationTemplate exercises exactly the agentic path: the
+// add-account modal must render a paste-a-token flow derived from the spec's
+// bare `http`/`bearer` security scheme.
+const EMULATOR_SPEC_URL = `${EMULATOR_BASE}/openapi.json`;
+
+const addSpecCode = (slug: string) => `
+const added = await tools.executor.openapi.addSpec({
+ spec: { kind: "url", url: ${JSON.stringify(EMULATOR_SPEC_URL)} },
+ slug: ${JSON.stringify(slug)},
+});
+return added.ok ? { ok: true, slug: added.data.slug, toolCount: added.data.toolCount } : { ok: false, error: added.error };
+`;
+
+const createHandoffCode = (slug: string) => `
+const handoff = await tools.executor.coreTools.connections.createHandoff({
+ integration: ${JSON.stringify(slug)},
+ owner: "org",
+ label: "Resend (emulated)",
+});
+return handoff.ok ? { ok: true, url: handoff.data.url } : { ok: false, error: handoff.error };
+`;
+
+// Selfhost scenarios share one workspace identity — leaked connections fail
+// other scenarios' zero-state assertions, so remove everything this one made.
+const removeConnectionsCode = (slug: string) => `
+const list = await tools.executor.coreTools.connections.list({});
+const mine = (list.ok ? list.data.connections : []).filter((c) => c.integration === ${JSON.stringify(slug)});
+for (const c of mine) {
+ await tools.executor.coreTools.connections.remove({ owner: c.owner, integration: c.integration, name: c.name });
+}
+return { removed: mine.length };
+`;
+
+const sendEmailCode = (slug: string, subject: string) => `
+const found = await tools.search({ namespace: ${JSON.stringify(slug)}, query: "send email", limit: 5 });
+const path = found.items[0]?.path;
+if (!path) return { ok: false, error: "no send tool found", items: found.items };
+let t = tools;
+for (const seg of path.split(".")) t = t[seg];
+const sent = await t({
+ body: {
+ from: "onboarding@example.com",
+ to: "e2e@example.com",
+ subject: ${JSON.stringify(subject)},
+ html: "
connect-handoff e2e
",
+ },
+});
+return { ok: sent.ok, path, result: sent.ok ? sent.data : sent.error };
+`;
+
+/** Run `execute`, auto-approving a paused execution (policy elicitation) once,
+ * and parse the sandbox's JSON return value. */
+const executeJson = (session: McpSession, code: string) =>
+ Effect.gen(function* () {
+ let result = yield* session.call("execute", { code });
+ if (result.text.includes("executionId:")) {
+ result = yield* session.approvePaused(result.text);
+ }
+ expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true);
+ return JSON.parse(result.text) as Record;
+ });
+
+const mintEmulatorApiKey = Effect.promise(async () => {
+ const response = await fetch(`${EMULATOR_BASE}/_emulate/credentials`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ type: "api-key" }),
+ });
+ const body = (await response.json()) as { credential?: { token?: string } };
+ const token = body.credential?.token;
+ if (!token) throw new Error(`emulator credential mint failed: ${JSON.stringify(body)}`);
+ return token;
+});
+
+const fetchLedgerText = Effect.promise(async () => {
+ const response = await fetch(`${EMULATOR_BASE}/_emulate/ledger`);
+ return response.text();
+});
+
+scenario(
+ "Connect · the agentic handoff URL opens this deployment's add-account flow and the pasted key works",
+ { timeout: 240_000 },
+ Effect.gen(function* () {
+ const target = yield* Target;
+ const mcp = yield* Mcp;
+ const browser = yield* Browser;
+ const { client: makeApiClient } = yield* Api;
+
+ const integration = unique("resendhf");
+ const emailSubject = unique("connect-handoff");
+ const apiKey = yield* mintEmulatorApiKey;
+
+ const identity = yield* target.newIdentity();
+ const session = mcp.session(identity);
+ const client = yield* makeApiClient(api, identity);
+
+ yield* runScenario({
+ target,
+ browser,
+ session,
+ identity,
+ integration,
+ emailSubject,
+ apiKey,
+ }).pipe(
+ // Best-effort cleanup even on failure: drop the created connection(s)
+ // over MCP, then the integration over the API.
+ Effect.ensuring(
+ Effect.gen(function* () {
+ yield* session.call("execute", { code: removeConnectionsCode(integration) });
+ yield* client.openapi.removeSpec({ params: { slug: integration } });
+ }).pipe(Effect.ignore),
+ ),
+ );
+ }),
+);
+
+const runScenario = (input: {
+ readonly target: TargetShape;
+ readonly browser: BrowserSurface;
+ readonly session: McpSession;
+ readonly identity: Identity;
+ readonly integration: string;
+ readonly emailSubject: string;
+ readonly apiKey: string;
+}) =>
+ Effect.gen(function* () {
+ const { target, browser, session, identity, integration, emailSubject, apiKey } = input;
+
+ // 1. Agent registers the emulated provider over MCP.
+ const added = yield* executeJson(session, addSpecCode(integration));
+ expect(added.ok, `addSpec succeeded: ${JSON.stringify(added)}`).toBe(true);
+
+ // 2. Agent asks for the browser handoff URL.
+ const handoff = yield* executeJson(session, createHandoffCode(integration));
+ expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true);
+ const handoffUrl = String(handoff.url);
+
+ // 3. The URL must target THIS deployment. (Production returned a URL the
+ // user called "wrong/bad" — pin the contract here.)
+ const parsed = new URL(handoffUrl);
+ expect(parsed.origin, `handoff URL (${handoffUrl}) targets this deployment`).toBe(
+ new URL(target.baseUrl).origin,
+ );
+ expect(parsed.pathname).toBe(`/integrations/${integration}`);
+ expect(parsed.searchParams.get("addAccount")).toBe("1");
+
+ // 4. The user opens the handoff URL and pastes the credential.
+ yield* browser.session(identity, async ({ page, step }) => {
+ await step("Open the handoff URL from the agent", async () => {
+ await page.goto(handoffUrl, { waitUntil: "networkidle" });
+ });
+
+ await step("The Add connection modal is open", async () => {
+ await page.getByRole("heading", { name: /Add connection/ }).waitFor({ timeout: 15_000 });
+ });
+
+ await step("Paste the emulator API key", async () => {
+ const credential = page.getByPlaceholder(/paste the value \/ token/i);
+ await credential.waitFor({ timeout: 15_000 });
+ await credential.fill(apiKey);
+ });
+
+ await step("Submit Add connection", async () => {
+ await page.getByRole("button", { name: "Add connection", exact: true }).click();
+ await page
+ .getByRole("heading", { name: /Add connection/ })
+ .waitFor({ state: "hidden", timeout: 20_000 });
+ });
+ });
+
+ // 5. The connection is live: send an email through the new tools and see
+ // it arrive at the emulator with the pasted token.
+ const sent = yield* executeJson(session, sendEmailCode(integration, emailSubject));
+ expect(sent.ok, `email sent through the pasted connection: ${JSON.stringify(sent)}`).toBe(true);
+
+ const ledger = yield* fetchLedgerText;
+ expect(
+ ledger.includes(emailSubject),
+ "the emulator's request ledger recorded the call made through Executor",
+ ).toBe(true);
+ });
diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx
index 9755032e1..3632a16b9 100644
--- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx
+++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx
@@ -4,11 +4,7 @@ import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Option from "effect/Option";
-import {
- AuthTemplateSlug,
- IntegrationSlug,
- type OAuthAuthentication,
-} from "@executor-js/sdk/shared";
+import { IntegrationSlug } from "@executor-js/sdk/shared";
import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
import {
slugifyNamespace,
@@ -48,9 +44,10 @@ import {
googleOpenApiPresets,
type GoogleOpenApiPreset,
} from "../sdk/google-presets";
-import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
-import { type APIKeyAuthentication, type Authentication, type ServerInfo } from "../sdk/types";
+import type { SpecPreview } from "../sdk/preview";
+import { type Authentication, type ServerInfo } from "../sdk/types";
import { expandServerUrlOptions } from "../sdk/openapi-utils";
+import { detectedAuthenticationTemplates, firstBaseUrlForPreview } from "../sdk/derive-auth";
const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";
const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";
@@ -73,46 +70,6 @@ const googleBundleUrls = (
return [...new Set([...fromPresets, ...customUrls])];
};
-// ---------------------------------------------------------------------------
-// OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve
-// them against the chosen base URL so the stored auth template is absolute.
-// ---------------------------------------------------------------------------
-
-export function resolveOAuthUrl(url: string, baseUrl: string): string {
- if (!url) return url;
- // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs
- try {
- new URL(url);
- return url;
- } catch {
- if (!baseUrl) return url;
- // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs
- try {
- return new URL(url, baseUrl).toString();
- } catch {
- return url;
- }
- }
-}
-
-const standardOidcIdentityScopes = ["openid", "email", "profile"] as const;
-
-const identityScopesForPreset = (
- identityScopes: OAuth2Preset["identityScopes"],
-): readonly string[] => {
- if (identityScopes === false) return [];
- return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes;
-};
-
-const resolvedOAuthScopes = (
- apiScopes: Iterable,
- identityScopes: OAuth2Preset["identityScopes"],
-): string[] => {
- const merged = new Set(apiScopes);
- for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope);
- return [...merged];
-};
-
const isGoogleDiscoveryUrl = (url: string): boolean => {
const trimmed = url.trim();
if (!URL.canParse(trimmed)) return false;
@@ -146,100 +103,9 @@ const specInputForAdd = (input: string) => {
: { kind: "blob" as const, value };
};
-// ---------------------------------------------------------------------------
-// Auth-template builders — turn a preview preset into the integration's stored
-// `Authentication` template (v2). The header preset becomes an `apiKey` template
-// whose secret header value renders the resolved credential via `variable(token)`;
-// the oauth2 preset becomes an `oauth` template carrying the provider endpoints.
-//
-// Post-redesign the add flow no longer asks the user to pick ONE method: every
-// spec-detected method is registered so the integration's detail hub can list
-// them and Add-account can choose among them (P6: add without auth, connect
-// later).
-// ---------------------------------------------------------------------------
-
-const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefined => {
- const label = preset.label.toLowerCase();
- if (headerName.toLowerCase() === "authorization") {
- if (label.includes("bearer")) return "Bearer ";
- if (label.includes("basic")) return "Basic ";
- }
- return undefined;
-};
-
-const apiKeyTemplateFromHeaderPreset = (
- preset: HeaderPreset,
- slug: AuthTemplateSlug,
-): APIKeyAuthentication => ({
- slug,
- kind: "apikey",
- // Every secret header shares the one credential input (the canonical
- // `token`, stored as an absent placement variable).
- placements: preset.secretHeaders.map((headerName) => {
- const prefix = headerPrefix(preset, headerName);
- return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) };
- }),
-});
-
-const oauthTemplateFromPreset = (
- preset: OAuth2Preset,
- baseUrl: string,
- slug: AuthTemplateSlug,
- scopes: readonly string[],
-): OAuthAuthentication => ({
- slug,
- kind: "oauth2",
- authorizationUrl: resolveOAuthUrl(
- Option.getOrElse(preset.authorizationUrl, () => ""),
- baseUrl,
- ),
- tokenUrl: resolveOAuthUrl(preset.tokenUrl, baseUrl),
- scopes: [...scopes],
-});
-
const expandServerOptions = (server: ServerInfo) =>
expandServerUrlOptions(server).map((value) => ({ value, label: value }));
-const firstBaseUrlForPreview = (preview: SpecPreview): string => {
- const firstServer = preview.servers[0];
- return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : "";
-};
-
-// ---------------------------------------------------------------------------
-// All spec-detected auth methods → the union of stored `Authentication`
-// templates. Header presets become apiKey templates; each oauth2 preset becomes
-// an oauth template (with its declared API scopes plus, for auth-code flows,
-// the standard identity scopes). Slugs stay deterministic per method so the
-// stored template is stable across previews of the same spec. Adding an
-// integration whose slug already exists is blocked (see the existing-slug
-// guard below); to add more auth, update the existing integration instead.
-// ---------------------------------------------------------------------------
-
-const detectedAuthenticationTemplates = (
- headerPresets: readonly HeaderPreset[],
- oauth2Presets: readonly OAuth2Preset[],
- baseUrl: string,
-): readonly Authentication[] => {
- const templates: Authentication[] = [];
- headerPresets.forEach((preset, index) => {
- templates.push(
- apiKeyTemplateFromHeaderPreset(preset, AuthTemplateSlug.make(`apikey-${index}`)),
- );
- });
- for (const preset of oauth2Presets) {
- const scopes = resolvedOAuthScopes(Object.keys(preset.scopes), preset.identityScopes);
- templates.push(
- oauthTemplateFromPreset(
- preset,
- baseUrl,
- AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`),
- scopes,
- ),
- );
- }
- return templates;
-};
-
// ---------------------------------------------------------------------------
// Component — single progressive form. Post-redesign: preview → addSpec
// (register the integration catalog entry with ALL detected auth methods) →
@@ -452,7 +318,13 @@ export default function AddOpenApiSource(props: {
slug: resolvedSourceId,
description: resolvedDisplayName,
baseUrl: resolvedBaseUrl,
- ...(!isGoogleBundlePreset && editedAuthenticationTemplate.length > 0
+ // Always send the edited method list (even empty) when the user has
+ // inspected a preview: an explicit [] means "no auth methods", while
+ // OMITTING the field tells the server to derive defaults from the
+ // spec — which would silently resurrect methods the user deleted.
+ // The Google bundle path stays omitted; its auth is converter-derived
+ // server-side.
+ ...(!isGoogleBundlePreset
? {
// Serialize to the wire input dialect (apikey → request-shaped).
authenticationTemplate: editedAuthenticationTemplate.map(openApiWireAuthInput),
diff --git a/packages/plugins/openapi/src/sdk/derive-auth.ts b/packages/plugins/openapi/src/sdk/derive-auth.ts
new file mode 100644
index 000000000..8bd6336ba
--- /dev/null
+++ b/packages/plugins/openapi/src/sdk/derive-auth.ts
@@ -0,0 +1,150 @@
+// Spec-detected auth → stored `Authentication` templates, shared by every add
+// path. The React add flow derives templates from the preview before calling
+// `addSpec`; `addSpec` itself falls back to the same derivation when the
+// caller omits `authenticationTemplate` (the agentic/API path has no client
+// to do it). One implementation so the web UI and headless callers cannot
+// drift: an integration added over MCP gets the same auth methods the add
+// page would have produced.
+import * as Option from "effect/Option";
+
+import { AuthTemplateSlug, type OAuthAuthentication } from "@executor-js/sdk/shared";
+
+import type { HeaderPreset, OAuth2Preset, SpecPreview } from "./preview";
+import type { APIKeyAuthentication, Authentication } from "./types";
+import { expandServerUrlOptions } from "./openapi-utils";
+
+// ---------------------------------------------------------------------------
+// OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve
+// them against the chosen base URL so the stored auth template is absolute.
+// ---------------------------------------------------------------------------
+
+export function resolveOAuthUrl(url: string, baseUrl: string): string {
+ if (!url) return url;
+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs
+ try {
+ new URL(url);
+ return url;
+ } catch {
+ if (!baseUrl) return url;
+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs
+ try {
+ return new URL(url, baseUrl).toString();
+ } catch {
+ return url;
+ }
+ }
+}
+
+const standardOidcIdentityScopes = ["openid", "email", "profile"] as const;
+
+const identityScopesForPreset = (
+ identityScopes: OAuth2Preset["identityScopes"],
+): readonly string[] => {
+ if (identityScopes === false) return [];
+ return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes;
+};
+
+export const resolvedOAuthScopes = (
+ apiScopes: Iterable,
+ identityScopes: OAuth2Preset["identityScopes"],
+): string[] => {
+ const merged = new Set(apiScopes);
+ for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope);
+ return [...merged];
+};
+
+// ---------------------------------------------------------------------------
+// Auth-template builders — turn a preview preset into the integration's stored
+// `Authentication` template (v2). The header preset becomes an `apiKey` template
+// whose secret header value renders the resolved credential via `variable(token)`;
+// the oauth2 preset becomes an `oauth` template carrying the provider endpoints.
+// ---------------------------------------------------------------------------
+
+const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefined => {
+ const label = preset.label.toLowerCase();
+ if (headerName.toLowerCase() === "authorization") {
+ if (label.includes("bearer")) return "Bearer ";
+ if (label.includes("basic")) return "Basic ";
+ }
+ return undefined;
+};
+
+const apiKeyTemplateFromHeaderPreset = (
+ preset: HeaderPreset,
+ slug: AuthTemplateSlug,
+): APIKeyAuthentication => ({
+ slug,
+ kind: "apikey",
+ // Every secret header shares the one credential input (the canonical
+ // `token`, stored as an absent placement variable).
+ placements: preset.secretHeaders.map((headerName) => {
+ const prefix = headerPrefix(preset, headerName);
+ return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) };
+ }),
+});
+
+const oauthTemplateFromPreset = (
+ preset: OAuth2Preset,
+ baseUrl: string,
+ slug: AuthTemplateSlug,
+ scopes: readonly string[],
+): OAuthAuthentication => ({
+ slug,
+ kind: "oauth2",
+ authorizationUrl: resolveOAuthUrl(
+ Option.getOrElse(preset.authorizationUrl, () => ""),
+ baseUrl,
+ ),
+ tokenUrl: resolveOAuthUrl(preset.tokenUrl, baseUrl),
+ scopes: [...scopes],
+});
+
+// ---------------------------------------------------------------------------
+// All spec-detected auth methods → the union of stored `Authentication`
+// templates. Header presets become apiKey templates; each oauth2 preset becomes
+// an oauth template (with its declared API scopes plus, for auth-code flows,
+// the standard identity scopes). Slugs stay deterministic per method so the
+// stored template is stable across previews of the same spec.
+// ---------------------------------------------------------------------------
+
+export const detectedAuthenticationTemplates = (
+ headerPresets: readonly HeaderPreset[],
+ oauth2Presets: readonly OAuth2Preset[],
+ baseUrl: string,
+): readonly Authentication[] => {
+ const templates: Authentication[] = [];
+ headerPresets.forEach((preset, index) => {
+ templates.push(
+ apiKeyTemplateFromHeaderPreset(preset, AuthTemplateSlug.make(`apikey-${index}`)),
+ );
+ });
+ for (const preset of oauth2Presets) {
+ const scopes = resolvedOAuthScopes(Object.keys(preset.scopes), preset.identityScopes);
+ templates.push(
+ oauthTemplateFromPreset(
+ preset,
+ baseUrl,
+ AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`),
+ scopes,
+ ),
+ );
+ }
+ return templates;
+};
+
+export const firstBaseUrlForPreview = (preview: SpecPreview): string => {
+ const firstServer = preview.servers[0];
+ return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : "";
+};
+
+/** The fallback `addSpec` uses when no explicit template was passed: every
+ * spec-detected method, resolved against the integration's base URL. */
+export const deriveAuthenticationTemplateFromPreview = (
+ preview: SpecPreview,
+ baseUrl: string | undefined,
+): readonly Authentication[] =>
+ detectedAuthenticationTemplates(
+ preview.headerPresets,
+ preview.oauth2Presets,
+ baseUrl ?? firstBaseUrlForPreview(preview),
+ );
diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts
index ca8d7450c..c3578da05 100644
--- a/packages/plugins/openapi/src/sdk/plugin.test.ts
+++ b/packages/plugins/openapi/src/sdk/plugin.test.ts
@@ -506,6 +506,118 @@ describe("OpenAPI Plugin", () => {
),
);
+ it.effect("addSpec derives auth methods from the spec's security schemes by default", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const server = yield* servePluginTestApi();
+ const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
+
+ // The spec declares bearer auth; the caller passes NO template — the
+ // agentic add path (MCP/API) does exactly this. Without server-side
+ // derivation the integration is auth-less and its Add-connection
+ // modal is a dead end (e2e/scenarios/connect-handoff.test.ts).
+ // oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture surgery on the test server's own spec JSON
+ const spec = JSON.parse(server.specJson) as Record;
+ const specWithBearer = JSON.stringify({
+ ...spec,
+ components: {
+ ...(spec.components as Record | undefined),
+ securitySchemes: { auth_token: { type: "http", scheme: "bearer" } },
+ },
+ security: [{ auth_token: [] }],
+ });
+
+ yield* executor.openapi.addSpec({
+ spec: { kind: "blob", value: specWithBearer },
+ slug: "derived_auth_api",
+ baseUrl: server.baseUrl,
+ });
+
+ // The derived template is persisted on the integration…
+ const config = yield* executor.openapi.getConfig("derived_auth_api");
+ const derived = config?.authenticationTemplate ?? [];
+ expect(derived.map((a) => ({ slug: String(a.slug), kind: a.kind }))).toEqual([
+ { slug: "apikey-0", kind: "apikey" },
+ ]);
+
+ // …and it renders a pasted credential as a bearer Authorization header.
+ yield* executor.connections.create({
+ owner: "org",
+ name: ConnectionName.make("main"),
+ integration: IntegrationSlug.make("derived_auth_api"),
+ template: AuthTemplateSlug.make("apikey-0"),
+ value: "pasted-token-xyz",
+ });
+ const result = unwrapInvocation(
+ yield* executor.execute(
+ ToolAddress.make("tools.derived_auth_api.org.main.items.echoHeaders"),
+ {},
+ ),
+ ).data as { authorization?: string };
+ expect(result.authorization).toBe("Bearer pasted-token-xyz");
+ }),
+ ),
+ );
+
+ it.effect("addSpec defaults baseUrl to the spec's first server when omitted", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const server = yield* servePluginTestApi();
+ const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
+
+ // No baseUrl passed — the spec's own servers entry must fill it, or
+ // every invocation on the integration fails with no host to call.
+ // oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture surgery on the test server's own spec JSON
+ const spec = JSON.parse(server.specJson) as Record;
+ const specWithServer = JSON.stringify({
+ ...spec,
+ servers: [{ url: server.baseUrl }],
+ });
+
+ yield* executor.openapi.addSpec({
+ spec: { kind: "blob", value: specWithServer },
+ slug: "derived_base_api",
+ });
+
+ const config = yield* executor.openapi.getConfig("derived_base_api");
+ expect(config?.baseUrl).toBe(server.baseUrl);
+ }),
+ ),
+ );
+
+ it.effect("addSpec treats an explicit empty authenticationTemplate as no auth", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const server = yield* servePluginTestApi();
+ const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
+
+ // The add page sends [] when the user deletes every detected method.
+ // That intent must survive — deriving methods back from the spec here
+ // would silently override the user's choice.
+ // oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture surgery on the test server's own spec JSON
+ const spec = JSON.parse(server.specJson) as Record;
+ const specWithBearer = JSON.stringify({
+ ...spec,
+ components: {
+ ...(spec.components as Record | undefined),
+ securitySchemes: { auth_token: { type: "http", scheme: "bearer" } },
+ },
+ security: [{ auth_token: [] }],
+ });
+
+ yield* executor.openapi.addSpec({
+ spec: { kind: "blob", value: specWithBearer },
+ slug: "no_auth_api",
+ baseUrl: server.baseUrl,
+ authenticationTemplate: [],
+ });
+
+ const config = yield* executor.openapi.getConfig("no_auth_api");
+ expect(config?.authenticationTemplate ?? []).toEqual([]);
+ }),
+ ),
+ );
+
it.effect("removeSpec cleans up the integration and its tools", () =>
Effect.scoped(
Effect.gen(function* () {
diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts
index ee1816c55..e80e81d9f 100644
--- a/packages/plugins/openapi/src/sdk/plugin.ts
+++ b/packages/plugins/openapi/src/sdk/plugin.ts
@@ -41,7 +41,8 @@ import {
import { extract } from "./extract";
import { compileToolDefinitions, type ToolDefinition } from "./definitions";
import { annotationsForOperation, invokeWithLayer } from "./invoke";
-import { previewSpec, type SpecPreview } from "./preview";
+import { previewSpec, previewSpecText, type SpecPreview } from "./preview";
+import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";
import { openApiPresets } from "./presets";
import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store";
import type { Authentication } from "./types";
@@ -713,6 +714,32 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {
const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer);
const compiled = yield* compileSpec(resolved.specText);
+ // Defaults the add page derives from its preview, applied here so
+ // headless callers (MCP, API) get the same integration the UI's
+ // add flow would produce — see e2e/scenarios/connect-handoff.test.ts:
+ // - baseUrl: the spec's first server (else tools have no host to
+ // call and every invocation fails with "HTTP request failed")
+ // - authenticationTemplate: the spec's declared security schemes
+ // (else the Add-connection modal is a dead "No authentication"
+ // end with nowhere to paste a credential)
+ // An explicit input always wins; for auth, an explicit EMPTY array
+ // means "no auth methods" and suppresses the derivation.
+ const explicitBaseUrl = config.baseUrl ?? resolved.baseUrl;
+ const needsDerivedBaseUrl = explicitBaseUrl == null;
+ const needsDerivedAuth =
+ config.authenticationTemplate == null && resolved.authenticationTemplate == null;
+ const preview =
+ needsDerivedBaseUrl || needsDerivedAuth
+ ? yield* previewSpecText(resolved.specText)
+ : undefined;
+ const derivedBaseUrl =
+ needsDerivedBaseUrl && preview ? firstBaseUrlForPreview(preview) : undefined;
+ const effectiveBaseUrl = explicitBaseUrl ?? (derivedBaseUrl || undefined);
+ const derivedAuthenticationTemplate =
+ needsDerivedAuth && preview
+ ? deriveAuthenticationTemplateFromPreview(preview, effectiveBaseUrl)
+ : undefined;
+
const slug = IntegrationSlug.make(config.slug);
// Block re-adding an existing slug. The core `integrations.register`
@@ -735,21 +762,22 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {
...(specInputToGoogleBundle(config.spec) !== undefined
? { googleDiscoveryUrls: specInputToGoogleBundle(config.spec) }
: {}),
- ...((config.baseUrl ?? resolved.baseUrl)
- ? { baseUrl: config.baseUrl ?? resolved.baseUrl }
- : {}),
+ ...(effectiveBaseUrl ? { baseUrl: effectiveBaseUrl } : {}),
...(config.headers ? { headers: config.headers } : {}),
...(config.queryParams ? { queryParams: config.queryParams } : {}),
// Prefer the caller's explicit template; otherwise adopt the one the
// Google Discovery converter derived from the spec (the bundle add
- // path relies on this — it has no preview to detect auth from).
+ // path relies on this — it has no preview to detect auth from);
+ // otherwise derive from the spec's declared security schemes.
...(config.authenticationTemplate
? {
authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate),
}
: resolved.authenticationTemplate
? { authenticationTemplate: resolved.authenticationTemplate }
- : {}),
+ : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0
+ ? { authenticationTemplate: derivedAuthenticationTemplate }
+ : {}),
};
// The spec blob is written OUTSIDE the transaction: it's
@@ -894,7 +922,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {
tool({
name: "addSpec",
description:
- "Add an OpenAPI integration to the catalog and persist its operations as tools. Recommended flow: call `previewSpec`, choose a `slug`, declare an `authenticationTemplate` for how a credential is applied (apiKey header/query, or oauth bearer), then create a connection for that integration with the user's API key or via `oauth.start`.",
+ "Add an OpenAPI integration to the catalog and persist its operations as tools. Recommended flow: call `previewSpec`, choose a `slug`, then create a connection for that integration with the user's API key or via `oauth.start`. When `baseUrl` is omitted it defaults to the spec's first server; when `authenticationTemplate` is omitted the auth methods are derived from the spec's declared security schemes (pass an explicit template to override how a credential is applied — apiKey header/query, or oauth bearer — or an empty array for no auth methods).",
annotations: {
requiresApproval: true,
approvalDescription: "Add an OpenAPI integration",
diff --git a/packages/plugins/openapi/src/sdk/preview.ts b/packages/plugins/openapi/src/sdk/preview.ts
index e38d86f51..8066789fb 100644
--- a/packages/plugins/openapi/src/sdk/preview.ts
+++ b/packages/plugins/openapi/src/sdk/preview.ts
@@ -374,10 +374,9 @@ const collectTags = (result: ExtractionResult): string[] => {
// Public API
// ---------------------------------------------------------------------------
-/** Preview an OpenAPI spec — extract metadata without registering anything.
- * Accepts either a URL or raw JSON/YAML text. */
-export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: string) {
- const specText = yield* resolveSpecText(input);
+/** Preview already-resolved spec text — extract metadata without registering
+ * anything and without any HTTP dependency. */
+export const previewSpecText = Effect.fn("OpenApi.previewSpecText")(function* (specText: string) {
const doc: ParsedDocument = yield* parse(specText);
const result = yield* extract(doc);
@@ -417,3 +416,10 @@ export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: st
oauth2Presets: buildOAuth2Presets(securitySchemes),
});
});
+
+/** Preview an OpenAPI spec — extract metadata without registering anything.
+ * Accepts either a URL or raw JSON/YAML text. */
+export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: string) {
+ const specText = yield* resolveSpecText(input);
+ return yield* previewSpecText(specText);
+});