History

Derive OpenAPI auth methods from the spec when addSpec gets no template

addSpec only persisted auth methods when the caller passed an explicit authenticationTemplate (or for Google Discovery conversions). The add page derives templates from the preview client-side before calling it, but headless callers (MCP execute, direct API) don't — so any spec added that way silently lost its declared security schemes. The resulting integration had zero auth methods and its Add-connection modal rendered a dead 'No authentication' state with nowhere to paste a credential, even though the connections.createHandoff URL pointing at it was correct. - Move the preset-to-template derivation from AddOpenApiSource into the sdk (derive-auth.ts); the React add flow now imports it, so the two paths cannot drift. - addSpec falls back to that derivation when no template is passed, via a new previewSpecText (preview minus the URL fetch, so addSpec keeps its error/context channels). - Unit test: a bearer-scheme spec added with no template persists a derived apikey method and renders a pasted token as a bearer header. - New e2e scenario walks the whole agentic handoff: addSpec over MCP, createHandoff URL contract, browser paste into the add-account modal, then a live call through the connection verified against an emulated provider's request ledger.

Rhys Sullivan committed 5 months ago

Commit 06131021 parent 13580d16 files · +489 −145Browse files

Unified · Split

6 files
  1. e2e/scenarios/connect-handoff.test.ts +252 −0
  2. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +4 −138
  3. packages/plugins/openapi/src/sdk/derive-auth.ts +150 −0
  4. packages/plugins/openapi/src/sdk/plugin.test.ts +53 −0
  5. packages/plugins/openapi/src/sdk/plugin.ts +20 −3
  6. packages/plugins/openapi/src/sdk/preview.ts +10 −4
e2e/scenarios/connect-handoff.test.ts+252−0
@@ -0,0 +1,252 @@
1+// The agentic connect handoff: an agent adds an API over MCP, asks for a
2+// handoff URL (`coreTools.connections.createHandoff`), and the user opens that
3+// URL in a browser to paste the credential. This scenario walks the WHOLE
4+// path — the exact flow that failed in production with a "wrong / bad" URL —
5+// against a real emulated provider (resend.emulators.dev) so the failure
6+// point is captured with trace + screenshots instead of guessed at:
7+//
8+// 1. MCP `execute` → `openapi.addSpec` registers the emulated Resend API
9+// 2. MCP `execute` → `connections.createHandoff` returns the browser URL
10+// 3. The URL's origin must be THIS deployment (not a hardcoded host)
11+// 4. Playwright opens it: the Add connection modal must be open with a
12+// credential field, the emulator-minted API key is pasted and submitted
13+// 5. The saved connection is proven live: `execute` sends an email through
14+// the new tools and the emulator's request ledger shows the call
15+// arriving with the pasted bearer token
16+import { randomBytes } from "node:crypto";
17+
18+import { expect } from "@effect/vitest";
19+import { Effect } from "effect";
20+import { composePluginApi } from "@executor-js/api/server";
21+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
22+
23+import { scenario } from "../src/scenario";
24+import { Api, Browser, Mcp, Target } from "../src/services";
25+import type { Identity, Target as TargetShape } from "../src/target";
26+import type { BrowserSurface } from "../src/surfaces/browser";
27+import type { McpSession } from "../src/surfaces/mcp";
28+
29+const api = composePluginApi([openApiHttpPlugin()] as const);
30+
31+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
32+
33+const EMULATOR_BASE = "https://resend.emulators.dev";
34+
35+/** Minimal Resend-shaped OpenAPI subset pointed at the emulator. Bearer auth
36+ * mirrors the real provider (and the Sentry spec that failed in prod): the
37+ * add-account modal must render a paste-a-token flow from a bare
38+ * `http`/`bearer` security scheme, not just from an explicit apiKey
39+ * authenticationTemplate. */
40+const resendSpec = {
41+ openapi: "3.0.3",
42+ info: { title: "Resend (emulated)", version: "1.0.0" },
43+ paths: {
44+ "/emails": {
45+ post: {
46+ operationId: "sendEmail",
47+ tags: ["emails"],
48+ requestBody: {
49+ required: true,
50+ content: {
51+ "application/json": {
52+ schema: {
53+ type: "object",
54+ properties: {
55+ from: { type: "string" },
56+ to: { type: "string" },
57+ subject: { type: "string" },
58+ html: { type: "string" },
59+ },
60+ required: ["from", "to", "subject"],
61+ },
62+ },
63+ },
64+ },
65+ responses: { "200": { description: "sent" } },
66+ },
67+ },
68+ },
69+ components: {
70+ securitySchemes: { auth_token: { type: "http", scheme: "bearer" } },
71+ },
72+ security: [{ auth_token: [] }],
73+} as const;
74+
75+const addSpecCode = (slug: string) => `
76+const added = await tools.executor.openapi.addSpec({
77+ spec: { kind: "blob", value: ${JSON.stringify(JSON.stringify(resendSpec))} },
78+ slug: ${JSON.stringify(slug)},
79+ baseUrl: ${JSON.stringify(EMULATOR_BASE)},
80+});
81+return added.ok ? { ok: true, slug: added.data.slug, toolCount: added.data.toolCount } : { ok: false, error: added.error };
82+`;
83+
84+const createHandoffCode = (slug: string) => `
85+const handoff = await tools.executor.coreTools.connections.createHandoff({
86+ integration: ${JSON.stringify(slug)},
87+ owner: "org",
88+ label: "Resend (emulated)",
89+});
90+return handoff.ok ? { ok: true, url: handoff.data.url } : { ok: false, error: handoff.error };
91+`;
92+
93+// Selfhost scenarios share one workspace identity — leaked connections fail
94+// other scenarios' zero-state assertions, so remove everything this one made.
95+const removeConnectionsCode = (slug: string) => `
96+const list = await tools.executor.coreTools.connections.list({});
97+const mine = (list.ok ? list.data.connections : []).filter((c) => c.integration === ${JSON.stringify(slug)});
98+for (const c of mine) {
99+ await tools.executor.coreTools.connections.remove({ owner: c.owner, integration: c.integration, name: c.name });
100+}
101+return { removed: mine.length };
102+`;
103+
104+const sendEmailCode = (slug: string, subject: string) => `
105+const found = await tools.search({ namespace: ${JSON.stringify(slug)}, query: "send email", limit: 5 });
106+const path = found.items[0]?.path;
107+if (!path) return { ok: false, error: "no send tool found", items: found.items };
108+let t = tools;
109+for (const seg of path.split(".")) t = t[seg];
110+const sent = await t({
111+ body: {
112+ from: "onboarding@example.com",
113+ to: "e2e@example.com",
114+ subject: ${JSON.stringify(subject)},
115+ html: "<p>connect-handoff e2e</p>",
116+ },
117+});
118+return { ok: sent.ok, path, result: sent.ok ? sent.data : sent.error };
119+`;
120+
121+/** Run `execute`, auto-approving a paused execution (policy elicitation) once,
122+ * and parse the sandbox's JSON return value. */
123+const executeJson = (session: McpSession, code: string) =>
124+ Effect.gen(function* () {
125+ let result = yield* session.call("execute", { code });
126+ if (result.text.includes("executionId:")) {
127+ result = yield* session.approvePaused(result.text);
128+ }
129+ expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true);
130+ return JSON.parse(result.text) as Record<string, unknown>;
131+ });
132+
133+const mintEmulatorApiKey = Effect.promise(async () => {
134+ const response = await fetch(`${EMULATOR_BASE}/_emulate/credentials`, {
135+ method: "POST",
136+ headers: { "content-type": "application/json" },
137+ body: JSON.stringify({ type: "api-key" }),
138+ });
139+ const body = (await response.json()) as { credential?: { token?: string } };
140+ const token = body.credential?.token;
141+ if (!token) throw new Error(`emulator credential mint failed: ${JSON.stringify(body)}`);
142+ return token;
143+});
144+
145+const fetchLedgerText = Effect.promise(async () => {
146+ const response = await fetch(`${EMULATOR_BASE}/_emulate/ledger`);
147+ return response.text();
148+});
149+
150+scenario(
151+ "Connect · the agentic handoff URL opens this deployment's add-account flow and the pasted key works",
152+ { timeout: 240_000 },
153+ Effect.gen(function* () {
154+ const target = yield* Target;
155+ const mcp = yield* Mcp;
156+ const browser = yield* Browser;
157+ const { client: makeApiClient } = yield* Api;
158+
159+ const integration = unique("resendhf");
160+ const emailSubject = unique("connect-handoff");
161+ const apiKey = yield* mintEmulatorApiKey;
162+
163+ const identity = yield* target.newIdentity();
164+ const session = mcp.session(identity);
165+ const client = yield* makeApiClient(api, identity);
166+
167+ yield* runScenario({
168+ target,
169+ browser,
170+ session,
171+ identity,
172+ integration,
173+ emailSubject,
174+ apiKey,
175+ }).pipe(
176+ // Best-effort cleanup even on failure: drop the created connection(s)
177+ // over MCP, then the integration over the API.
178+ Effect.ensuring(
179+ Effect.gen(function* () {
180+ yield* session.call("execute", { code: removeConnectionsCode(integration) });
181+ yield* client.openapi.removeSpec({ params: { slug: integration } });
182+ }).pipe(Effect.ignore),
183+ ),
184+ );
185+ }),
186+);
187+
188+const runScenario = (input: {
189+ readonly target: TargetShape;
190+ readonly browser: BrowserSurface;
191+ readonly session: McpSession;
192+ readonly identity: Identity;
193+ readonly integration: string;
194+ readonly emailSubject: string;
195+ readonly apiKey: string;
196+}) =>
197+ Effect.gen(function* () {
198+ const { target, browser, session, identity, integration, emailSubject, apiKey } = input;
199+
200+ // 1. Agent registers the emulated provider over MCP.
201+ const added = yield* executeJson(session, addSpecCode(integration));
202+ expect(added.ok, `addSpec succeeded: ${JSON.stringify(added)}`).toBe(true);
203+
204+ // 2. Agent asks for the browser handoff URL.
205+ const handoff = yield* executeJson(session, createHandoffCode(integration));
206+ expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true);
207+ const handoffUrl = String(handoff.url);
208+
209+ // 3. The URL must target THIS deployment. (Production returned a URL the
210+ // user called "wrong/bad" — pin the contract here.)
211+ const parsed = new URL(handoffUrl);
212+ expect(parsed.origin, `handoff URL (${handoffUrl}) targets this deployment`).toBe(
213+ new URL(target.baseUrl).origin,
214+ );
215+ expect(parsed.pathname).toBe(`/integrations/${integration}`);
216+ expect(parsed.searchParams.get("addAccount")).toBe("1");
217+
218+ // 4. The user opens the handoff URL and pastes the credential.
219+ yield* browser.session(identity, async ({ page, step }) => {
220+ await step("Open the handoff URL from the agent", async () => {
221+ await page.goto(handoffUrl, { waitUntil: "networkidle" });
222+ });
223+
224+ await step("The Add connection modal is open", async () => {
225+ await page.getByRole("heading", { name: /Add connection/ }).waitFor({ timeout: 15_000 });
226+ });
227+
228+ await step("Paste the emulator API key", async () => {
229+ const credential = page.getByPlaceholder(/paste the value \/ token/i);
230+ await credential.waitFor({ timeout: 15_000 });
231+ await credential.fill(apiKey);
232+ });
233+
234+ await step("Submit Add connection", async () => {
235+ await page.getByRole("button", { name: "Add connection", exact: true }).click();
236+ await page
237+ .getByRole("heading", { name: /Add connection/ })
238+ .waitFor({ state: "hidden", timeout: 20_000 });
239+ });
240+ });
241+
242+ // 5. The connection is live: send an email through the new tools and see
243+ // it arrive at the emulator with the pasted token.
244+ const sent = yield* executeJson(session, sendEmailCode(integration, emailSubject));
245+ expect(sent.ok, `email sent through the pasted connection: ${JSON.stringify(sent)}`).toBe(true);
246+
247+ const ledger = yield* fetchLedgerText;
248+ expect(
249+ ledger.includes(emailSubject),
250+ "the emulator's request ledger recorded the call made through Executor",
251+ ).toBe(true);
252+ });
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+4−138
@@ -4,11 +4,7 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines
44 import * as Exit from "effect/Exit";
55 import * as Option from "effect/Option";
66
7−import {
8− AuthTemplateSlug,
9− IntegrationSlug,
10− type OAuthAuthentication,
11−} from "@executor-js/sdk/shared";
7+import { IntegrationSlug } from "@executor-js/sdk/shared";
128 import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
139 import {
1410 slugifyNamespace,
@@ -48,9 +44,10 @@ import { · 33 unmodified lines
4844 googleOpenApiPresets,
4945 type GoogleOpenApiPreset,
5046 } from "../sdk/google-presets";
51−import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
52−import { type APIKeyAuthentication, type Authentication, type ServerInfo } from "../sdk/types";
47+import type { SpecPreview } from "../sdk/preview";
48+import { type Authentication, type ServerInfo } from "../sdk/types";
5349 import { expandServerUrlOptions } from "../sdk/openapi-utils";
50+import { detectedAuthenticationTemplates, firstBaseUrlForPreview } from "../sdk/derive-auth";
5451
5552 const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";
5653 const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";
@@ -73,46 +70,6 @@ const googleBundleUrls = ( · 16 unmodified lines
7370 return [...new Set([...fromPresets, ...customUrls])];
7471 };
7572
76−// ---------------------------------------------------------------------------
77−// OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve
78−// them against the chosen base URL so the stored auth template is absolute.
79−// ---------------------------------------------------------------------------
80−
81−export function resolveOAuthUrl(url: string, baseUrl: string): string {
82− if (!url) return url;
83− // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs
84− try {
85− new URL(url);
86− return url;
87− } catch {
88− if (!baseUrl) return url;
89− // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs
90− try {
91− return new URL(url, baseUrl).toString();
92− } catch {
93− return url;
94− }
95− }
96−}
97−
98−const standardOidcIdentityScopes = ["openid", "email", "profile"] as const;
99−
100−const identityScopesForPreset = (
101− identityScopes: OAuth2Preset["identityScopes"],
102−): readonly string[] => {
103− if (identityScopes === false) return [];
104− return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes;
105−};
106−
107−const resolvedOAuthScopes = (
108− apiScopes: Iterable<string>,
109− identityScopes: OAuth2Preset["identityScopes"],
110−): string[] => {
111− const merged = new Set(apiScopes);
112− for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope);
113− return [...merged];
114−};
115−
11673 const isGoogleDiscoveryUrl = (url: string): boolean => {
11774 const trimmed = url.trim();
11875 if (!URL.canParse(trimmed)) return false;
@@ -146,100 +103,9 @@ const specInputForAdd = (input: string) => { · 27 unmodified lines
146103 : { kind: "blob" as const, value };
147104 };
148105
149−// ---------------------------------------------------------------------------
150−// Auth-template builders — turn a preview preset into the integration's stored
151−// `Authentication` template (v2). The header preset becomes an `apiKey` template
152−// whose secret header value renders the resolved credential via `variable(token)`;
153−// the oauth2 preset becomes an `oauth` template carrying the provider endpoints.
154−//
155−// Post-redesign the add flow no longer asks the user to pick ONE method: every
156−// spec-detected method is registered so the integration's detail hub can list
157−// them and Add-account can choose among them (P6: add without auth, connect
158−// later).
159−// ---------------------------------------------------------------------------
160−
161−const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefined => {
162− const label = preset.label.toLowerCase();
163− if (headerName.toLowerCase() === "authorization") {
164− if (label.includes("bearer")) return "Bearer ";
165− if (label.includes("basic")) return "Basic ";
166− }
167− return undefined;
168−};
169−
170−const apiKeyTemplateFromHeaderPreset = (
171− preset: HeaderPreset,
172− slug: AuthTemplateSlug,
173−): APIKeyAuthentication => ({
174− slug,
175− kind: "apikey",
176− // Every secret header shares the one credential input (the canonical
177− // `token`, stored as an absent placement variable).
178− placements: preset.secretHeaders.map((headerName) => {
179− const prefix = headerPrefix(preset, headerName);
180− return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) };
181− }),
182−});
183−
184−const oauthTemplateFromPreset = (
185− preset: OAuth2Preset,
186− baseUrl: string,
187− slug: AuthTemplateSlug,
188− scopes: readonly string[],
189−): OAuthAuthentication => ({
190− slug,
191− kind: "oauth2",
192− authorizationUrl: resolveOAuthUrl(
193− Option.getOrElse(preset.authorizationUrl, () => ""),
194− baseUrl,
195− ),
196− tokenUrl: resolveOAuthUrl(preset.tokenUrl, baseUrl),
197− scopes: [...scopes],
198−});
199−
200106 const expandServerOptions = (server: ServerInfo) =>
201107 expandServerUrlOptions(server).map((value) => ({ value, label: value }));
202108
203−const firstBaseUrlForPreview = (preview: SpecPreview): string => {
204− const firstServer = preview.servers[0];
205− return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : "";
206−};
207−
208−// ---------------------------------------------------------------------------
209−// All spec-detected auth methods → the union of stored `Authentication`
210−// templates. Header presets become apiKey templates; each oauth2 preset becomes
211−// an oauth template (with its declared API scopes plus, for auth-code flows,
212−// the standard identity scopes). Slugs stay deterministic per method so the
213−// stored template is stable across previews of the same spec. Adding an
214−// integration whose slug already exists is blocked (see the existing-slug
215−// guard below); to add more auth, update the existing integration instead.
216−// ---------------------------------------------------------------------------
217−
218−const detectedAuthenticationTemplates = (
219− headerPresets: readonly HeaderPreset[],
220− oauth2Presets: readonly OAuth2Preset[],
221− baseUrl: string,
222−): readonly Authentication[] => {
223− const templates: Authentication[] = [];
224− headerPresets.forEach((preset, index) => {
225− templates.push(
226− apiKeyTemplateFromHeaderPreset(preset, AuthTemplateSlug.make(`apikey-${index}`)),
227− );
228− });
229− for (const preset of oauth2Presets) {
230− const scopes = resolvedOAuthScopes(Object.keys(preset.scopes), preset.identityScopes);
231− templates.push(
232− oauthTemplateFromPreset(
233− preset,
234− baseUrl,
235− AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`),
236− scopes,
237− ),
238− );
239− }
240− return templates;
241−};
242−
243109 // ---------------------------------------------------------------------------
244110 // Component — single progressive form. Post-redesign: preview → addSpec
245111 // (register the integration catalog entry with ALL detected auth methods) →
packages/plugins/openapi/src/sdk/derive-auth.ts+150−0
@@ -0,0 +1,150 @@
1+// Spec-detected auth → stored `Authentication` templates, shared by every add
2+// path. The React add flow derives templates from the preview before calling
3+// `addSpec`; `addSpec` itself falls back to the same derivation when the
4+// caller omits `authenticationTemplate` (the agentic/API path has no client
5+// to do it). One implementation so the web UI and headless callers cannot
6+// drift: an integration added over MCP gets the same auth methods the add
7+// page would have produced.
8+import * as Option from "effect/Option";
9+
10+import { AuthTemplateSlug, type OAuthAuthentication } from "@executor-js/sdk/shared";
11+
12+import type { HeaderPreset, OAuth2Preset, SpecPreview } from "./preview";
13+import type { APIKeyAuthentication, Authentication } from "./types";
14+import { expandServerUrlOptions } from "./openapi-utils";
15+
16+// ---------------------------------------------------------------------------
17+// OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve
18+// them against the chosen base URL so the stored auth template is absolute.
19+// ---------------------------------------------------------------------------
20+
21+export function resolveOAuthUrl(url: string, baseUrl: string): string {
22+ if (!url) return url;
23+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs
24+ try {
25+ new URL(url);
26+ return url;
27+ } catch {
28+ if (!baseUrl) return url;
29+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs
30+ try {
31+ return new URL(url, baseUrl).toString();
32+ } catch {
33+ return url;
34+ }
35+ }
36+}
37+
38+const standardOidcIdentityScopes = ["openid", "email", "profile"] as const;
39+
40+const identityScopesForPreset = (
41+ identityScopes: OAuth2Preset["identityScopes"],
42+): readonly string[] => {
43+ if (identityScopes === false) return [];
44+ return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes;
45+};
46+
47+export const resolvedOAuthScopes = (
48+ apiScopes: Iterable<string>,
49+ identityScopes: OAuth2Preset["identityScopes"],
50+): string[] => {
51+ const merged = new Set(apiScopes);
52+ for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope);
53+ return [...merged];
54+};
55+
56+// ---------------------------------------------------------------------------
57+// Auth-template builders — turn a preview preset into the integration's stored
58+// `Authentication` template (v2). The header preset becomes an `apiKey` template
59+// whose secret header value renders the resolved credential via `variable(token)`;
60+// the oauth2 preset becomes an `oauth` template carrying the provider endpoints.
61+// ---------------------------------------------------------------------------
62+
63+const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefined => {
64+ const label = preset.label.toLowerCase();
65+ if (headerName.toLowerCase() === "authorization") {
66+ if (label.includes("bearer")) return "Bearer ";
67+ if (label.includes("basic")) return "Basic ";
68+ }
69+ return undefined;
70+};
71+
72+const apiKeyTemplateFromHeaderPreset = (
73+ preset: HeaderPreset,
74+ slug: AuthTemplateSlug,
75+): APIKeyAuthentication => ({
76+ slug,
77+ kind: "apikey",
78+ // Every secret header shares the one credential input (the canonical
79+ // `token`, stored as an absent placement variable).
80+ placements: preset.secretHeaders.map((headerName) => {
81+ const prefix = headerPrefix(preset, headerName);
82+ return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) };
83+ }),
84+});
85+
86+const oauthTemplateFromPreset = (
87+ preset: OAuth2Preset,
88+ baseUrl: string,
89+ slug: AuthTemplateSlug,
90+ scopes: readonly string[],
91+): OAuthAuthentication => ({
92+ slug,
93+ kind: "oauth2",
94+ authorizationUrl: resolveOAuthUrl(
95+ Option.getOrElse(preset.authorizationUrl, () => ""),
96+ baseUrl,
97+ ),
98+ tokenUrl: resolveOAuthUrl(preset.tokenUrl, baseUrl),
99+ scopes: [...scopes],
100+});
101+
102+// ---------------------------------------------------------------------------
103+// All spec-detected auth methods → the union of stored `Authentication`
104+// templates. Header presets become apiKey templates; each oauth2 preset becomes
105+// an oauth template (with its declared API scopes plus, for auth-code flows,
106+// the standard identity scopes). Slugs stay deterministic per method so the
107+// stored template is stable across previews of the same spec.
108+// ---------------------------------------------------------------------------
109+
110+export const detectedAuthenticationTemplates = (
111+ headerPresets: readonly HeaderPreset[],
112+ oauth2Presets: readonly OAuth2Preset[],
113+ baseUrl: string,
114+): readonly Authentication[] => {
115+ const templates: Authentication[] = [];
116+ headerPresets.forEach((preset, index) => {
117+ templates.push(
118+ apiKeyTemplateFromHeaderPreset(preset, AuthTemplateSlug.make(`apikey-${index}`)),
119+ );
120+ });
121+ for (const preset of oauth2Presets) {
122+ const scopes = resolvedOAuthScopes(Object.keys(preset.scopes), preset.identityScopes);
123+ templates.push(
124+ oauthTemplateFromPreset(
125+ preset,
126+ baseUrl,
127+ AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`),
128+ scopes,
129+ ),
130+ );
131+ }
132+ return templates;
133+};
134+
135+export const firstBaseUrlForPreview = (preview: SpecPreview): string => {
136+ const firstServer = preview.servers[0];
137+ return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : "";
138+};
139+
140+/** The fallback `addSpec` uses when no explicit template was passed: every
141+ * spec-detected method, resolved against the integration's base URL. */
142+export const deriveAuthenticationTemplateFromPreview = (
143+ preview: SpecPreview,
144+ baseUrl: string | undefined,
145+): readonly Authentication[] =>
146+ detectedAuthenticationTemplates(
147+ preview.headerPresets,
148+ preview.oauth2Presets,
149+ baseUrl ?? firstBaseUrlForPreview(preview),
150+ );
packages/plugins/openapi/src/sdk/plugin.test.ts+53−0
@@ -506,6 +506,59 @@ describe("OpenAPI Plugin", () => { · 505 unmodified lines
506506 ),
507507 );
508508
509+ it.effect("addSpec derives auth methods from the spec's security schemes by default", () =>
510+ Effect.scoped(
511+ Effect.gen(function* () {
512+ const server = yield* servePluginTestApi();
513+ const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
514+
515+ // The spec declares bearer auth; the caller passes NO template — the
516+ // agentic add path (MCP/API) does exactly this. Without server-side
517+ // derivation the integration is auth-less and its Add-connection
518+ // modal is a dead end (e2e/scenarios/connect-handoff.test.ts).
519+ // oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture surgery on the test server's own spec JSON
520+ const spec = JSON.parse(server.specJson) as Record<string, unknown>;
521+ const specWithBearer = JSON.stringify({
522+ ...spec,
523+ components: {
524+ ...(spec.components as Record<string, unknown> | undefined),
525+ securitySchemes: { auth_token: { type: "http", scheme: "bearer" } },
526+ },
527+ security: [{ auth_token: [] }],
528+ });
529+
530+ yield* executor.openapi.addSpec({
531+ spec: { kind: "blob", value: specWithBearer },
532+ slug: "derived_auth_api",
533+ baseUrl: server.baseUrl,
534+ });
535+
536+ // The derived template is persisted on the integration…
537+ const config = yield* executor.openapi.getConfig("derived_auth_api");
538+ const derived = config?.authenticationTemplate ?? [];
539+ expect(derived.map((a) => ({ slug: String(a.slug), kind: a.kind }))).toEqual([
540+ { slug: "apikey-0", kind: "apikey" },
541+ ]);
542+
543+ // …and it renders a pasted credential as a bearer Authorization header.
544+ yield* executor.connections.create({
545+ owner: "org",
546+ name: ConnectionName.make("main"),
547+ integration: IntegrationSlug.make("derived_auth_api"),
548+ template: AuthTemplateSlug.make("apikey-0"),
549+ value: "pasted-token-xyz",
550+ });
551+ const result = unwrapInvocation(
552+ yield* executor.execute(
553+ ToolAddress.make("tools.derived_auth_api.org.main.items.echoHeaders"),
554+ {},
555+ ),
556+ ).data as { authorization?: string };
557+ expect(result.authorization).toBe("Bearer pasted-token-xyz");
558+ }),
559+ ),
560+ );
561+
509562 it.effect("removeSpec cleans up the integration and its tools", () =>
510563 Effect.scoped(
511564 Effect.gen(function* () {
packages/plugins/openapi/src/sdk/plugin.ts+20−3
@@ -41,7 +41,8 @@ import { · 40 unmodified lines
4141 import { extract } from "./extract";
4242 import { compileToolDefinitions, type ToolDefinition } from "./definitions";
4343 import { annotationsForOperation, invokeWithLayer } from "./invoke";
44−import { previewSpec, type SpecPreview } from "./preview";
44+import { previewSpec, previewSpecText, type SpecPreview } from "./preview";
45+import { deriveAuthenticationTemplateFromPreview } from "./derive-auth";
4546 import { openApiPresets } from "./presets";
4647 import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store";
4748 import type { Authentication } from "./types";
@@ -713,6 +714,19 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 665 unmodified lines
713714 const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer);
714715 const compiled = yield* compileSpec(resolved.specText);
715716
717+ // No explicit template and nothing converter-derived → fall back to
718+ // the spec's own declared auth (same derivation the add page runs on
719+ // its preview). Without this, headless callers (MCP, API) silently
720+ // produce auth-less integrations whose Add-connection modal is a
721+ // dead end — see e2e/scenarios/connect-handoff.test.ts.
722+ const derivedAuthenticationTemplate =
723+ config.authenticationTemplate || resolved.authenticationTemplate
724+ ? undefined
725+ : deriveAuthenticationTemplateFromPreview(
726+ yield* previewSpecText(resolved.specText),
727+ config.baseUrl ?? resolved.baseUrl,
728+ );
729+
716730 const slug = IntegrationSlug.make(config.slug);
717731
718732 // Block re-adding an existing slug. The core `integrations.register`
@@ -742,14 +756,17 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 23 unmodified lines
742756 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
743757 // Prefer the caller's explicit template; otherwise adopt the one the
744758 // Google Discovery converter derived from the spec (the bundle add
745− // path relies on this — it has no preview to detect auth from).
759+ // path relies on this — it has no preview to detect auth from);
760+ // otherwise derive from the spec's declared security schemes.
746761 ...(config.authenticationTemplate
747762 ? {
748763 authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate),
749764 }
750765 : resolved.authenticationTemplate
751766 ? { authenticationTemplate: resolved.authenticationTemplate }
752− : {}),
767+ : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0
768+ ? { authenticationTemplate: derivedAuthenticationTemplate }
769+ : {}),
753770 };
754771
755772 // The spec blob is written OUTSIDE the transaction: it's
packages/plugins/openapi/src/sdk/preview.ts+10−4
@@ -374,10 +374,9 @@ const collectTags = (result: ExtractionResult): string[] => { · 373 unmodified lines
374374 // Public API
375375 // ---------------------------------------------------------------------------
376376
377−/** Preview an OpenAPI spec — extract metadata without registering anything.
378− * Accepts either a URL or raw JSON/YAML text. */
379−export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: string) {
380− const specText = yield* resolveSpecText(input);
377+/** Preview already-resolved spec text — extract metadata without registering
378+ * anything and without any HTTP dependency. */
379+export const previewSpecText = Effect.fn("OpenApi.previewSpecText")(function* (specText: string) {
381380 const doc: ParsedDocument = yield* parse(specText);
382381 const result = yield* extract(doc);
383382
@@ -417,3 +416,10 @@ export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: st · 33 unmodified lines
417416 oauth2Presets: buildOAuth2Presets(securitySchemes),
418417 });
419418 });
419+
420+/** Preview an OpenAPI spec — extract metadata without registering anything.
421+ * Accepts either a URL or raw JSON/YAML text. */
422+export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: string) {
423+ const specText = yield* resolveSpecText(input);
424+ return yield* previewSpecText(specText);
425+});