Skip to content

Commit 757cc96

Browse files
Support per-call server selection for OpenAPI tools (#952)
* Support per-call server selection for OpenAPI tools Resolve OpenAPI servers per tool call instead of baking a single base URL at add time. Operations can declare multiple servers (document, path, or operation level) and templated URLs with {variables}; each is now selectable when invoking the tool. - Each tool exposes an optional `server` input ({ url?, variables? }), shown only when there is something to choose: `url` becomes an enum when more than one server applies, and `variables` are drawn from the applicable servers. - Host resolution: a connection base URL override wins when set; otherwise the call's chosen server (server.url or the first applicable) is resolved with its {variables} (call values, else spec defaults); otherwise no host is prepended. - The connection base URL becomes an optional override (off by default), required only when the spec declares no servers. The form shows a combobox for multiple top-level servers and a plain input otherwise, with the first server's resolved URL as the placeholder. - Drop connection-level serverVariables in favor of per-call variables. - Bindings persisted before this change keep working: a missing `servers` field is treated as empty and falls back to the connection base URL. * Add an e2e scenario for per-call OpenAPI server selection A multi-server spec, added through the typed product API, must turn into a tool that advertises the per-call `server` selector: a `url` enum over the declared servers plus the `{variables}` drawn from the templated one. The scenario also pins the base URL as an optional override — addSpec succeeds with no baseUrl, the host being resolved per call from the spec's servers. Cross-target (cloud + selfhost), driven entirely through the typed client. * Add a browser e2e scenario for the multi-server add form Records the UI side of per-call server selection: pasting a spec that declares more than one server turns the Add OpenAPI source form's Base URL field into a picker over those servers and relabels it an optional override, with a hint that the host is otherwise chosen per tool call. The session video and step screenshots are the artifact; the scenario skips on targets without a browser surface. --------- Co-authored-by: Rhys Sullivan <rhys@rhyssullivan.com>
1 parent 9b8baa1 commit 757cc96

16 files changed

Lines changed: 786 additions & 125 deletions
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Cross-target (browser): the UI side of per-call server selection. When a
2+
// pasted spec declares more than one server, the Add OpenAPI source form turns
3+
// the Base URL field into a picker over those servers and relabels it an
4+
// optional override — the host is otherwise resolved per tool call. The session
5+
// video + per-step screenshots are the artifact; this scenario skips on targets
6+
// without a browser surface (selfhost today).
7+
import { expect } from "@effect/vitest";
8+
import { Effect } from "effect";
9+
10+
import { scenario } from "../src/scenario";
11+
import { Browser, Target } from "../src/services";
12+
13+
// Two servers — production and staging — so the form offers a base-URL picker
14+
// instead of the single locked input a one-server spec gets.
15+
const multiServerSpec = JSON.stringify({
16+
openapi: "3.0.3",
17+
info: { title: "Regions API", version: "1.0.0" },
18+
servers: [
19+
{ url: "https://api.example.com", description: "Production" },
20+
{ url: "https://staging.example.com", description: "Staging" },
21+
],
22+
paths: {
23+
"/ping": { get: { operationId: "ping", responses: { "200": { description: "pong" } } } },
24+
},
25+
});
26+
27+
scenario(
28+
"OpenAPI · the add form offers a server picker and an optional base URL for a multi-server spec",
29+
{},
30+
Effect.scoped(
31+
Effect.gen(function* () {
32+
const target = yield* Target;
33+
const browser = yield* Browser;
34+
const identity = yield* target.newIdentity();
35+
36+
yield* browser.session(identity, async ({ page, step }) => {
37+
await step("Open the Add OpenAPI source form", async () => {
38+
await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" });
39+
await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor();
40+
});
41+
42+
await step("Paste a spec that declares two servers", async () => {
43+
await page.getByPlaceholder("https://api.example.com/openapi.json").fill(multiServerSpec);
44+
// The form auto-analyzes (debounced) and renders the preview details,
45+
// where the base URL is now an OPTIONAL override.
46+
await page.getByText("Base URL override (optional)").waitFor({ timeout: 20_000 });
47+
});
48+
49+
await step("The base URL is optional — the server is chosen per call", async () => {
50+
// The hint spells out that leaving it empty defers the host (and its
51+
// variables) to each tool call: the heart of per-call selection.
52+
await page.getByText(/leave empty to choose the server.*per tool call/i).waitFor();
53+
});
54+
55+
await step("The field is a picker over the spec's two servers", async () => {
56+
// Opening the combobox reveals both declared servers as choices.
57+
await page.getByPlaceholder("https://api.example.com", { exact: true }).click();
58+
const options = page.getByRole("option");
59+
await options.filter({ hasText: "staging.example.com" }).waitFor({ timeout: 10_000 });
60+
const labels = await options.allInnerTexts();
61+
expect(
62+
labels.join(" | "),
63+
"both declared servers are offered as base-URL choices",
64+
).toEqual(expect.stringContaining("https://staging.example.com"));
65+
expect(labels.join(" | "), "production is offered too").toEqual(
66+
expect.stringContaining("https://api.example.com"),
67+
);
68+
});
69+
});
70+
}),
71+
),
72+
);
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Cross-target: when a spec declares more than one server, per-call server
2+
// selection becomes a first-class tool input. The generated tool advertises an
3+
// optional `server` selector — a `url` enum over the declared servers plus the
4+
// `{variables}` drawn from the templated one — so an agent can choose where each
5+
// call goes. Adding the spec with NO baseUrl also proves the base URL is now an
6+
// optional override: the host is resolved per call from the spec's servers.
7+
//
8+
// Entirely through the typed client: addSpec → connection (via a `from` provider
9+
// reference, so no vault round-trip — works against the cloud stub) → read the
10+
// tool's schema and assert the `server` input this feature introduces.
11+
import { randomBytes, randomUUID } from "node:crypto";
12+
13+
import { expect } from "@effect/vitest";
14+
import { Effect } from "effect";
15+
import { composePluginApi } from "@executor-js/api/server";
16+
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
17+
import {
18+
AuthTemplateSlug,
19+
ConnectionName,
20+
IntegrationSlug,
21+
ProviderItemId,
22+
} from "@executor-js/sdk/shared";
23+
24+
import { scenario } from "../src/scenario";
25+
import { Api, Target } from "../src/services";
26+
27+
const api = composePluginApi([openApiHttpPlugin()] as const);
28+
29+
// The operation inherits two document-level servers: a fixed production URL and
30+
// a templated regional sandbox whose `{region}` is a per-call variable.
31+
const PROD_SERVER = "https://api.example.test/v1";
32+
const SANDBOX_SERVER = "https://{region}.sandbox.example.test/v1";
33+
34+
const multiServerSpec = (): string =>
35+
JSON.stringify({
36+
openapi: "3.0.3",
37+
info: { title: "Regions API", version: "1.0.0" },
38+
servers: [
39+
{ url: PROD_SERVER, description: "Production" },
40+
{
41+
url: SANDBOX_SERVER,
42+
description: "Regional sandbox",
43+
variables: {
44+
region: { default: "us", enum: ["us", "eu", "ap"], description: "Sandbox region" },
45+
},
46+
},
47+
],
48+
paths: {
49+
"/ping": {
50+
get: {
51+
operationId: "ping",
52+
summary: "Health check",
53+
responses: { "200": { description: "pong" } },
54+
},
55+
},
56+
},
57+
});
58+
59+
// Minimal structural view of the JSON Schema we assert against.
60+
type ServerInputSchema = {
61+
readonly properties?: {
62+
readonly server?: {
63+
readonly properties?: {
64+
readonly url?: { readonly enum?: readonly unknown[]; readonly default?: unknown };
65+
readonly variables?: {
66+
readonly properties?: Record<
67+
string,
68+
{ readonly enum?: readonly unknown[]; readonly default?: unknown }
69+
>;
70+
};
71+
};
72+
};
73+
};
74+
};
75+
76+
scenario(
77+
"OpenAPI · a multi-server spec advertises a per-call server selector on its tools",
78+
{},
79+
Effect.gen(function* () {
80+
const target = yield* Target;
81+
const { client } = yield* Api;
82+
const identity = yield* target.newIdentity();
83+
const apiClient = yield* client(api, identity);
84+
85+
// Unique slug per run: selfhost shares the bootstrap-admin identity, so the
86+
// prefix keeps parallel/repeated runs out of each other's catalogs.
87+
const slug = `openapi-scn-servers-${randomBytes(4).toString("hex")}`;
88+
89+
yield* Effect.ensuring(
90+
Effect.gen(function* () {
91+
// Add the spec with NO baseUrl — the host is resolved per call from the
92+
// spec's servers, so a base URL is purely an optional override now.
93+
const added = yield* apiClient.openapi.addSpec({
94+
payload: {
95+
spec: { kind: "blob", value: multiServerSpec() },
96+
slug,
97+
authenticationTemplate: [
98+
{
99+
slug: "apiKey",
100+
type: "apiKey",
101+
headers: { "x-api-key": [{ type: "variable", name: "token" }] },
102+
},
103+
],
104+
},
105+
});
106+
expect(added.toolCount, "the spec's operation became a tool").toBeGreaterThan(0);
107+
108+
// The catalog stamps tools once a connection exists; a `from` provider
109+
// reference avoids any vault round-trip.
110+
const providers = yield* apiClient.providers.list();
111+
expect(providers.length, "a credential provider is available").toBeGreaterThan(0);
112+
yield* apiClient.connections.create({
113+
payload: {
114+
owner: "org",
115+
name: ConnectionName.make("main"),
116+
integration: IntegrationSlug.make(slug),
117+
template: AuthTemplateSlug.make("apiKey"),
118+
from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) },
119+
},
120+
});
121+
122+
// Locate the generated tool and read its full input schema.
123+
const tools = yield* apiClient.tools.list({ query: {} });
124+
const ping = tools.find(
125+
(tool) => String(tool.integration) === slug && String(tool.name).includes("ping"),
126+
);
127+
expect(ping?.address, "the ping tool is in the catalog").toBeDefined();
128+
129+
const view = yield* apiClient.tools.schema({ query: { address: ping!.address } });
130+
const input = view.inputSchema as ServerInputSchema;
131+
const serverInput = input.properties?.server?.properties;
132+
133+
// The per-call `server` input exists: `url` is an enum over BOTH declared
134+
// servers (raw templates), defaulting to the first.
135+
expect(serverInput?.url?.enum, "server.url enumerates the declared servers").toEqual([
136+
PROD_SERVER,
137+
SANDBOX_SERVER,
138+
]);
139+
expect(serverInput?.url?.default, "server.url defaults to the first server").toBe(
140+
PROD_SERVER,
141+
);
142+
143+
// …and the templated server's `{region}` surfaces as a per-call variable
144+
// carrying its spec enum and default.
145+
const region = serverInput?.variables?.properties?.region;
146+
expect(region?.enum, "server.variables.region carries the spec enum").toEqual([
147+
"us",
148+
"eu",
149+
"ap",
150+
]);
151+
expect(region?.default, "server.variables.region keeps the spec default").toBe("us");
152+
}),
153+
// Selfhost shares one bootstrap admin, so this scenario must not leak its
154+
// connection or integration into other scenarios' zero-state assertions.
155+
Effect.gen(function* () {
156+
yield* apiClient.connections
157+
.remove({
158+
params: {
159+
owner: "org",
160+
integration: IntegrationSlug.make(slug),
161+
name: ConnectionName.make("main"),
162+
},
163+
})
164+
.pipe(Effect.ignore);
165+
yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
166+
}),
167+
);
168+
}),
169+
);

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

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,10 @@ import {
4545
type GoogleOpenApiPreset,
4646
} from "../sdk/google-presets";
4747
import type { SpecPreview } from "../sdk/preview";
48-
import { type Authentication, type ServerInfo } from "../sdk/types";
49-
import { expandServerUrlOptions } from "../sdk/openapi-utils";
50-
import { detectedAuthenticationTemplates, firstBaseUrlForPreview } from "../sdk/derive-auth";
48+
import { type Authentication } from "../sdk/types";
49+
import { resolveServerUrl } from "../sdk/openapi-utils";
50+
import { detectedAuthenticationTemplates } from "../sdk/derive-auth";
5151

52-
const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";
5352
const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";
5453

5554
// The bundle picker opens with the featured Google APIs pre-checked.
@@ -103,9 +102,6 @@ const specInputForAdd = (input: string) => {
103102
: { kind: "blob" as const, value };
104103
};
105104

106-
const expandServerOptions = (server: ServerInfo) =>
107-
expandServerUrlOptions(server).map((value) => ({ value, label: value }));
108-
109105
// ---------------------------------------------------------------------------
110106
// Component — single progressive form. Post-redesign: preview → addSpec
111107
// (register the integration catalog entry with ALL detected auth methods) →
@@ -136,7 +132,7 @@ export default function AddOpenApiSource(props: {
136132

137133
// After analysis
138134
const [preview, setPreview] = useState<SpecPreview | null>(null);
139-
const [baseUrl, setBaseUrl] = useState(isGoogleBundlePreset ? GOOGLE_BUNDLE_BASE_URL : "");
135+
const [baseUrl, setBaseUrl] = useState("");
140136
const identityFallbackName = isGoogleBundlePreset
141137
? "Google"
142138
: preview
@@ -198,10 +194,20 @@ export default function AddOpenApiSource(props: {
198194

199195
// ---- Derived state ----
200196

201-
const servers: readonly ServerInfo[] = preview?.servers ?? [];
202-
const baseUrlOptions = Array.from(
203-
new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(),
204-
);
197+
const previewHasNoServers = preview !== null && preview.servers.length === 0;
198+
// Offer the spec's servers (resolved with defaults) as base-URL choices when
199+
// there's more than one; a single/no server uses a plain input.
200+
const baseUrlOptions =
201+
preview && preview.servers.length > 1
202+
? preview.servers.map((server) => {
203+
const url = resolveServerUrl(server.url, Option.getOrUndefined(server.variables), {});
204+
return { value: url, label: url };
205+
})
206+
: undefined;
207+
const firstServer = preview?.servers[0];
208+
const firstServerUrl = firstServer
209+
? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {})
210+
: "";
205211
const previewPresetIcon =
206212
openApiPresets.find(
207213
(preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl),
@@ -275,7 +281,12 @@ export default function AddOpenApiSource(props: {
275281
const hasPreviewOrBundle = isGoogleBundlePreset
276282
? bundleDiscoveryUrls.length > 0
277283
: preview !== null;
278-
const canAdd = hasPreviewOrBundle && resolvedBaseUrl.length > 0 && !slugAlreadyExists;
284+
// The base URL is optional when the spec declares servers (resolved per call);
285+
// required only when it doesn't.
286+
const canAdd =
287+
hasPreviewOrBundle &&
288+
!slugAlreadyExists &&
289+
(!previewHasNoServers || resolvedBaseUrl.length > 0);
279290

280291
// ---- Handlers ----
281292

@@ -291,7 +302,7 @@ export default function AddOpenApiSource(props: {
291302
}
292303
const result = exit.value;
293304
setPreview(result);
294-
setBaseUrl(firstBaseUrlForPreview(result));
305+
setBaseUrl("");
295306
setAnalyzing(false);
296307
};
297308

@@ -423,9 +434,9 @@ export default function AddOpenApiSource(props: {
423434
identity={identity}
424435
baseUrl={resolvedBaseUrl}
425436
onBaseUrlChange={setBaseUrl}
437+
baseUrlLabel="Base URL override (optional)"
426438
faviconIcon={GOOGLE_BUNDLE_FAVICON}
427439
faviconUrl={resolvedBaseUrl}
428-
baseUrlMissingMessage="A base URL is required to make requests."
429440
/>
430441
) : preview ? (
431442
<OpenApiSourceDetailsFields
@@ -441,15 +452,24 @@ export default function AddOpenApiSource(props: {
441452
baseUrl={resolvedBaseUrl}
442453
onBaseUrlChange={setBaseUrl}
443454
baseUrlOptions={baseUrlOptions}
455+
baseUrlLabel={previewHasNoServers ? "Base URL" : "Base URL override (optional)"}
456+
baseUrlPlaceholder={firstServerUrl || "https://api.example.com"}
457+
baseUrlHint={
458+
previewHasNoServers
459+
? undefined
460+
: "Overrides the spec's servers; leave empty to choose the server (and variables) per tool call."
461+
}
462+
baseUrlMissingMessage={
463+
previewHasNoServers ? "This spec declares no servers — enter a base URL." : undefined
464+
}
444465
specUrl={specUrl}
445466
onSpecUrlChange={(value) => {
446467
setSpecUrl(value);
447468
setPreview(null);
448469
setBaseUrl("");
449470
}}
450471
faviconIcon={previewPresetIcon}
451-
faviconUrl={resolvedBaseUrl}
452-
baseUrlMissingMessage="A base URL is required to make requests."
472+
faviconUrl={resolvedBaseUrl || firstServerUrl}
453473
/>
454474
) : null}
455475

0 commit comments

Comments
 (0)