Skip to content

Commit 1ab4043

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 0613102 commit 1ab4043

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
@@ -559,6 +559,65 @@ describe("OpenAPI Plugin", () => {
559559
),
560560
);
561561

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

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

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

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.
717+
// Defaults the add page derives from its preview, applied here so
718+
// headless callers (MCP, API) get the same integration the UI's
719+
// add flow would produce — see e2e/scenarios/connect-handoff.test.ts:
720+
// - baseUrl: the spec's first server (else tools have no host to
721+
// call and every invocation fails with "HTTP request failed")
722+
// - authenticationTemplate: the spec's declared security schemes
723+
// (else the Add-connection modal is a dead "No authentication"
724+
// end with nowhere to paste a credential)
725+
// An explicit input always wins; for auth, an explicit EMPTY array
726+
// means "no auth methods" and suppresses the derivation.
727+
const explicitBaseUrl = config.baseUrl ?? resolved.baseUrl;
728+
const needsDerivedBaseUrl = explicitBaseUrl == null;
729+
const needsDerivedAuth =
730+
config.authenticationTemplate == null && resolved.authenticationTemplate == null;
731+
const preview =
732+
needsDerivedBaseUrl || needsDerivedAuth
733+
? yield* previewSpecText(resolved.specText)
734+
: undefined;
735+
const derivedBaseUrl =
736+
needsDerivedBaseUrl && preview ? firstBaseUrlForPreview(preview) : undefined;
737+
const effectiveBaseUrl = explicitBaseUrl ?? (derivedBaseUrl || undefined);
722738
const derivedAuthenticationTemplate =
723-
config.authenticationTemplate || resolved.authenticationTemplate
724-
? undefined
725-
: deriveAuthenticationTemplateFromPreview(
726-
yield* previewSpecText(resolved.specText),
727-
config.baseUrl ?? resolved.baseUrl,
728-
);
739+
needsDerivedAuth && preview
740+
? deriveAuthenticationTemplateFromPreview(preview, effectiveBaseUrl)
741+
: undefined;
729742

730743
const slug = IntegrationSlug.make(config.slug);
731744

@@ -749,9 +762,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {
749762
...(specInputToGoogleBundle(config.spec) !== undefined
750763
? { googleDiscoveryUrls: specInputToGoogleBundle(config.spec) }
751764
: {}),
752-
...((config.baseUrl ?? resolved.baseUrl)
753-
? { baseUrl: config.baseUrl ?? resolved.baseUrl }
754-
: {}),
765+
...(effectiveBaseUrl ? { baseUrl: effectiveBaseUrl } : {}),
755766
...(config.headers ? { headers: config.headers } : {}),
756767
...(config.queryParams ? { queryParams: config.queryParams } : {}),
757768
// Prefer the caller's explicit template; otherwise adopt the one the
@@ -911,7 +922,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {
911922
tool({
912923
name: "addSpec",
913924
description:
914-
"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`.",
925+
"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).",
915926
annotations: {
916927
requiresApproval: true,
917928
approvalDescription: "Add an OpenAPI integration",

0 commit comments

Comments
 (0)