Skip to content

Commit 98c87fd

Browse files
committed
Default addSpec baseUrl from the spec's servers; explicit [] means no auth
Refinements from review of the derivation fallback: - baseUrl now also derives from the spec's first server when the caller passes none (same default the add page applies). Without it, a headless add produced tools with no host to call — every invocation failed with a bare 'HTTP request failed'. - An explicit empty authenticationTemplate now means 'no auth methods' and suppresses derivation. The add page sends the user's edited method list even when emptied, so deleting every detected method in the inspect step survives instead of being silently re-derived. - The addSpec tool description documents both defaults and the override semantics. - The e2e scenario now adds the integration purely by spec URL — the Resend emulator serves its own /openapi.json now — with no baseUrl and no template, so both derivations are covered by the live paste-key flow and the emulator ledger check.
1 parent 22c831f commit 98c87fd

4 files changed

Lines changed: 100 additions & 58 deletions

File tree

e2e/scenarios/connect-handoff.test.ts

Lines changed: 7 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -32,51 +32,17 @@ const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`
3232

3333
const EMULATOR_BASE = "https://resend.emulators.dev";
3434

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;
35+
// The emulator serves its own OpenAPI document (bearer auth, same shape as
36+
// real Resend — and as the Sentry spec that failed in prod). Adding it by URL
37+
// with no authenticationTemplate exercises exactly the agentic path: the
38+
// add-account modal must render a paste-a-token flow derived from the spec's
39+
// bare `http`/`bearer` security scheme.
40+
const EMULATOR_SPEC_URL = `${EMULATOR_BASE}/openapi.json`;
7441

7542
const addSpecCode = (slug: string) => `
7643
const added = await tools.executor.openapi.addSpec({
77-
spec: { kind: "blob", value: ${JSON.stringify(JSON.stringify(resendSpec))} },
44+
spec: { kind: "url", url: ${JSON.stringify(EMULATOR_SPEC_URL)} },
7845
slug: ${JSON.stringify(slug)},
79-
baseUrl: ${JSON.stringify(EMULATOR_BASE)},
8046
});
8147
return added.ok ? { ok: true, slug: added.data.slug, toolCount: added.data.toolCount } : { ok: false, error: added.error };
8248
`;

packages/plugins/openapi/src/react/AddOpenApiSource.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,13 @@ export default function AddOpenApiSource(props: {
318318
slug: resolvedSourceId,
319319
description: resolvedDisplayName,
320320
baseUrl: resolvedBaseUrl,
321-
...(!isGoogleBundlePreset && editedAuthenticationTemplate.length > 0
321+
// Always send the edited method list (even empty) when the user has
322+
// inspected a preview: an explicit [] means "no auth methods", while
323+
// OMITTING the field tells the server to derive defaults from the
324+
// spec — which would silently resurrect methods the user deleted.
325+
// The Google bundle path stays omitted; its auth is converter-derived
326+
// server-side.
327+
...(!isGoogleBundlePreset
322328
? {
323329
// Serialize to the wire input dialect (apikey → request-shaped).
324330
authenticationTemplate: editedAuthenticationTemplate.map(openApiWireAuthInput),

packages/plugins/openapi/src/sdk/plugin.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,65 @@ describe("OpenAPI Plugin", () => {
557557
),
558558
);
559559

560+
it.effect("addSpec defaults baseUrl to the spec's first server when omitted", () =>
561+
Effect.scoped(
562+
Effect.gen(function* () {
563+
const server = yield* servePluginTestApi();
564+
const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
565+
566+
// No baseUrl passed — the spec's own servers entry must fill it, or
567+
// every invocation on the integration fails with no host to call.
568+
// oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture surgery on the test server's own spec JSON
569+
const spec = JSON.parse(server.specJson) as Record<string, unknown>;
570+
const specWithServer = JSON.stringify({
571+
...spec,
572+
servers: [{ url: server.baseUrl }],
573+
});
574+
575+
yield* executor.openapi.addSpec({
576+
spec: { kind: "blob", value: specWithServer },
577+
slug: "derived_base_api",
578+
});
579+
580+
const config = yield* executor.openapi.getConfig("derived_base_api");
581+
expect(config?.baseUrl).toBe(server.baseUrl);
582+
}),
583+
),
584+
);
585+
586+
it.effect("addSpec treats an explicit empty authenticationTemplate as no auth", () =>
587+
Effect.scoped(
588+
Effect.gen(function* () {
589+
const server = yield* servePluginTestApi();
590+
const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
591+
592+
// The add page sends [] when the user deletes every detected method.
593+
// That intent must survive — deriving methods back from the spec here
594+
// would silently override the user's choice.
595+
// oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture surgery on the test server's own spec JSON
596+
const spec = JSON.parse(server.specJson) as Record<string, unknown>;
597+
const specWithBearer = JSON.stringify({
598+
...spec,
599+
components: {
600+
...(spec.components as Record<string, unknown> | undefined),
601+
securitySchemes: { auth_token: { type: "http", scheme: "bearer" } },
602+
},
603+
security: [{ auth_token: [] }],
604+
});
605+
606+
yield* executor.openapi.addSpec({
607+
spec: { kind: "blob", value: specWithBearer },
608+
slug: "no_auth_api",
609+
baseUrl: server.baseUrl,
610+
authenticationTemplate: [],
611+
});
612+
613+
const config = yield* executor.openapi.getConfig("no_auth_api");
614+
expect(config?.authenticationTemplate ?? []).toEqual([]);
615+
}),
616+
),
617+
);
618+
560619
it.effect("removeSpec cleans up the integration and its tools", () =>
561620
Effect.scoped(
562621
Effect.gen(function* () {

packages/plugins/openapi/src/sdk/plugin.ts

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import { extract } from "./extract";
4141
import { compileToolDefinitions, type ToolDefinition } from "./definitions";
4242
import { annotationsForOperation, invokeWithLayer } from "./invoke";
4343
import { previewSpec, previewSpecText, type SpecPreview } from "./preview";
44-
import { deriveAuthenticationTemplateFromPreview } from "./derive-auth";
44+
import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";
4545
import { openApiPresets } from "./presets";
4646
import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store";
4747
import type { Authentication } from "./types";
@@ -710,18 +710,31 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {
710710
const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer);
711711
const compiled = yield* compileSpec(resolved.specText);
712712

713-
// No explicit template and nothing converter-derived → fall back to
714-
// the spec's own declared auth (same derivation the add page runs on
715-
// its preview). Without this, headless callers (MCP, API) silently
716-
// produce auth-less integrations whose Add-connection modal is a
717-
// dead end — see e2e/scenarios/connect-handoff.test.ts.
713+
// Defaults the add page derives from its preview, applied here so
714+
// headless callers (MCP, API) get the same integration the UI's
715+
// add flow would produce — see e2e/scenarios/connect-handoff.test.ts:
716+
// - baseUrl: the spec's first server (else tools have no host to
717+
// call and every invocation fails with "HTTP request failed")
718+
// - authenticationTemplate: the spec's declared security schemes
719+
// (else the Add-connection modal is a dead "No authentication"
720+
// end with nowhere to paste a credential)
721+
// An explicit input always wins; for auth, an explicit EMPTY array
722+
// means "no auth methods" and suppresses the derivation.
723+
const explicitBaseUrl = config.baseUrl ?? resolved.baseUrl;
724+
const needsDerivedBaseUrl = explicitBaseUrl == null;
725+
const needsDerivedAuth =
726+
config.authenticationTemplate == null && resolved.authenticationTemplate == null;
727+
const preview =
728+
needsDerivedBaseUrl || needsDerivedAuth
729+
? yield* previewSpecText(resolved.specText)
730+
: undefined;
731+
const derivedBaseUrl =
732+
needsDerivedBaseUrl && preview ? firstBaseUrlForPreview(preview) : undefined;
733+
const effectiveBaseUrl = explicitBaseUrl ?? (derivedBaseUrl || undefined);
718734
const derivedAuthenticationTemplate =
719-
config.authenticationTemplate || resolved.authenticationTemplate
720-
? undefined
721-
: deriveAuthenticationTemplateFromPreview(
722-
yield* previewSpecText(resolved.specText),
723-
config.baseUrl ?? resolved.baseUrl,
724-
);
735+
needsDerivedAuth && preview
736+
? deriveAuthenticationTemplateFromPreview(preview, effectiveBaseUrl)
737+
: undefined;
725738

726739
const slug = IntegrationSlug.make(config.slug);
727740

@@ -743,9 +756,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {
743756
...(specInputToGoogleBundle(config.spec) !== undefined
744757
? { googleDiscoveryUrls: specInputToGoogleBundle(config.spec) }
745758
: {}),
746-
...((config.baseUrl ?? resolved.baseUrl)
747-
? { baseUrl: config.baseUrl ?? resolved.baseUrl }
748-
: {}),
759+
...(effectiveBaseUrl ? { baseUrl: effectiveBaseUrl } : {}),
749760
...(config.headers ? { headers: config.headers } : {}),
750761
...(config.queryParams ? { queryParams: config.queryParams } : {}),
751762
// Prefer the caller's explicit template; otherwise adopt the one the
@@ -899,7 +910,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {
899910
tool({
900911
name: "addSpec",
901912
description:
902-
"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`.",
913+
"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).",
903914
annotations: {
904915
requiresApproval: true,
905916
approvalDescription: "Add an OpenAPI integration",

0 commit comments

Comments
 (0)