History

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. ---------

Alexis Rico committed 5 months ago

Commit 757cc961 parent 9b8baa116 files · +786 −125Browse files

Unified · Split

16 files
  1. e2e/scenarios/openapi-server-selection-ui.test.ts +72 −0
  2. e2e/scenarios/openapi-server-selection.test.ts +169 −0
  3. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +37 −17
  4. packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx +11 −7
  5. packages/plugins/openapi/src/sdk/config.ts +1 −1
  6. packages/plugins/openapi/src/sdk/derive-auth.ts +4 −2
  7. packages/plugins/openapi/src/sdk/extract.ts +68 −13
  8. packages/plugins/openapi/src/sdk/google-discovery.test.ts +2 −2
  9. packages/plugins/openapi/src/sdk/index.ts +1 −1
  10. packages/plugins/openapi/src/sdk/invoke.ts +30 −3
  11. packages/plugins/openapi/src/sdk/openapi-utils.ts +12 −42
  12. packages/plugins/openapi/src/sdk/plugin.test.ts +18 −14
  13. packages/plugins/openapi/src/sdk/plugin.ts +9 −4
  14. packages/plugins/openapi/src/sdk/query-serialization.test.ts +83 −3
  15. packages/plugins/openapi/src/sdk/server-url-resolution.test.ts +253 −0
  16. packages/plugins/openapi/src/sdk/types.ts +16 −16
e2e/scenarios/openapi-server-selection-ui.test.ts+72−0
@@ -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+);
e2e/scenarios/openapi-server-selection.test.ts+169−0
@@ -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+37−17
@@ -45,11 +45,10 @@ import { · 44 unmodified lines
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) => { · 47 unmodified lines
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: { · 24 unmodified lines
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: { · 55 unmodified lines
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: { · 67 unmodified lines
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: { · 9 unmodified lines
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: { · 125 unmodified lines
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,6 +452,16 @@ export default function AddOpenApiSource(props: { · 9 unmodified lines
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);
@@ -448,8 +469,7 @@ export default function AddOpenApiSource(props: { · 1 unmodified line
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
packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx+11−7
@@ -25,6 +25,9 @@ export function OpenApiSourceDetailsFields(props: { · 24 unmodified lines
2525 readonly baseUrl: string;
2626 readonly onBaseUrlChange: (value: string) => void;
2727 readonly baseUrlOptions?: readonly FreeformComboboxOption[];
28+ readonly baseUrlLabel?: string;
29+ readonly baseUrlPlaceholder?: string;
30+ readonly baseUrlHint?: string;
2831 readonly specUrl?: string;
2932 readonly onSpecUrlChange?: (value: string) => void;
3033 readonly faviconIcon?: string | null;
@@ -35,8 +38,6 @@ export function OpenApiSourceDetailsFields(props: { · 4 unmodified lines
3538 readonly baseUrlMissingMessage?: string;
3639 readonly footer?: string;
3740 }) {
38− const baseUrlOptions = props.baseUrlOptions ?? [];
39−
4041 return (
4142 <CardStack>
4243 <CardStackContent className="border-t-0">
@@ -61,13 +62,13 @@ export function OpenApiSourceDetailsFields(props: { · 18 unmodified lines
6162 namespaceReadOnly={props.namespaceReadOnly}
6263 />
6364 <div className="grid grid-cols-1 md:grid-cols-2">
64− <CardStackEntryField label="Base URL">
65− {baseUrlOptions.length > 0 ? (
65+ <CardStackEntryField label={props.baseUrlLabel ?? "Base URL"}>
66+ {props.baseUrlOptions && props.baseUrlOptions.length > 0 ? (
6667 <FreeformCombobox
6768 value={props.baseUrl}
6869 onValueChange={props.onBaseUrlChange}
69− options={baseUrlOptions}
70− placeholder="https://api.example.com"
70+ options={props.baseUrlOptions}
71+ placeholder={props.baseUrlPlaceholder ?? "https://api.example.com"}
7172 className="w-full"
7273 inputClassName="font-mono text-sm"
7374 />
@@ -75,7 +76,7 @@ export function OpenApiSourceDetailsFields(props: { · 1 unmodified line
7576 <Input
7677 value={props.baseUrl}
7778 onChange={(e) => props.onBaseUrlChange((e.target as HTMLInputElement).value)}
78− placeholder="https://api.example.com"
79+ placeholder={props.baseUrlPlaceholder ?? "https://api.example.com"}
7980 className="font-mono text-sm"
8081 />
8182 )}
@@ -85,6 +86,9 @@ export function OpenApiSourceDetailsFields(props: { · 3 unmodified lines
8586 {props.baseUrlMissingMessage}
8687 </p>
8788 )}
89+ {props.baseUrlHint && (
90+ <p className="text-[11px] text-muted-foreground">{props.baseUrlHint}</p>
91+ )}
8892 </CardStackEntryField>
8993 {props.specUrl !== undefined && props.onSpecUrlChange && (
9094 <CardStackEntryField label="Spec URL">
packages/plugins/openapi/src/sdk/config.ts+1−1
@@ -45,7 +45,7 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ · 44 unmodified lines
4545 sourceUrl: Schema.optional(Schema.String),
4646 /** Google Discovery bundle URLs, when the spec came from a Google bundle. */
4747 googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)),
48− /** Base URL override; falls back to the spec's first server. */
48+ /** Optional base URL override. */
4949 baseUrl: Schema.optional(Schema.String),
5050 /** Static headers applied to every request (no secret material). */
5151 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
packages/plugins/openapi/src/sdk/derive-auth.ts+4−2
@@ -11,7 +11,7 @@ import { AuthTemplateSlug, type OAuthAuthentication } from "@executor-js/sdk/sha · 10 unmodified lines
1111
1212 import type { HeaderPreset, OAuth2Preset, SpecPreview } from "./preview";
1313 import type { APIKeyAuthentication, Authentication } from "./types";
14−import { expandServerUrlOptions } from "./openapi-utils";
14+import { resolveServerUrl } from "./openapi-utils";
1515
1616 // ---------------------------------------------------------------------------
1717 // OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve
@@ -134,7 +134,9 @@ export const detectedAuthenticationTemplates = ( · 116 unmodified lines
134134
135135 export const firstBaseUrlForPreview = (preview: SpecPreview): string => {
136136 const firstServer = preview.servers[0];
137− return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : "";
137+ return firstServer
138+ ? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {})
139+ : "";
138140 };
139141
140142 /** The fallback `addSpec` uses when no explicit template was passed: every
packages/plugins/openapi/src/sdk/extract.ts+68−13
@@ -6,7 +6,6 @@ import { · 5 unmodified lines
66 declaredContents,
77 DocResolver,
88 preferredResponseContent,
9− resolveBaseUrl,
109 type OperationObject,
1110 type ParameterObject,
1211 type PathItemObject,
@@ -169,9 +168,60 @@ const extractOutputSchema = (operation: OperationObject, r: DocResolver): unknow · 156 unmodified lines
169168 // Input schema builder
170169 // ---------------------------------------------------------------------------
171170
171+// Optional `server` input — host selection + server-URL variables. Undefined
172+// when there's nothing to configure (a single server with no variables).
173+const buildServerInputProperty = (
174+ servers: readonly ServerInfo[],
175+): Record<string, unknown> | undefined => {
176+ const variableDefs: Record<string, ServerVariable> = {};
177+ for (const server of servers) {
178+ for (const [name, v] of Object.entries(Option.getOrUndefined(server.variables) ?? {})) {
179+ if (!(name in variableDefs)) variableDefs[name] = v;
180+ }
181+ }
182+ const hasMultiple = servers.length > 1;
183+ const variableNames = Object.keys(variableDefs);
184+ if (!hasMultiple && variableNames.length === 0) return undefined;
185+
186+ const properties: Record<string, unknown> = {};
187+ if (hasMultiple) {
188+ properties.url = {
189+ type: "string",
190+ enum: servers.map((server) => server.url),
191+ default: servers[0]!.url,
192+ description: "Which of the spec's servers to send the request to.",
193+ };
194+ }
195+ if (variableNames.length > 0) {
196+ properties.variables = {
197+ type: "object",
198+ additionalProperties: false,
199+ properties: Object.fromEntries(
200+ Object.entries(variableDefs).map(([name, v]) => [
201+ name,
202+ {
203+ type: "string",
204+ default: v.default,
205+ ...(Option.isSome(v.enum) ? { enum: v.enum.value } : {}),
206+ ...(Option.isSome(v.description) ? { description: v.description.value } : {}),
207+ },
208+ ]),
209+ ),
210+ description: "Values for the server URL `{variables}`; spec defaults apply when omitted.",
211+ };
212+ }
213+ return {
214+ type: "object",
215+ additionalProperties: false,
216+ properties,
217+ description: "Optional host selection and server-URL variables for this request.",
218+ };
219+};
220+
172221 const buildInputSchema = (
173222 parameters: readonly OperationParameter[],
174223 requestBody: OperationRequestBody | undefined,
224+ servers: readonly ServerInfo[],
175225 ): Record<string, unknown> | undefined => {
176226 const properties: Record<string, unknown> = {};
177227 const required: string[] = [];
@@ -181,6 +231,10 @@ const buildInputSchema = ( · 3 unmodified lines
181231 if (param.required) required.push(param.name);
182232 }
183233
234+ // A path/query parameter named `server` takes precedence over the host input.
235+ const serverProperty = buildServerInputProperty(servers);
236+ if (serverProperty && !("server" in properties)) properties.server = serverProperty;
237+
184238 if (requestBody) {
185239 properties.body = Option.getOrElse(requestBody.schema, () => ({ type: "object" }));
186240 if (requestBody.required) required.push("body");
@@ -283,17 +337,16 @@ const extractServerList = (servers: readonly ServerObject[] | undefined): Server · 96 unmodified lines
283337
284338 const extractServers = (doc: ParsedDocument): ServerInfo[] => extractServerList(doc.servers);
285339
286−const extractOperationBaseUrl = (
340+const operationServers = (
287341 pathItem: PathItemObject,
288342 operation: OperationObject,
289−): string | undefined => {
290− const operationServers = extractServerList(operation.servers);
291− if (operationServers.length > 0) return resolveBaseUrl(operationServers);
292−
293− const pathServers = extractServerList(pathItem.servers);
294− if (pathServers.length > 0) return resolveBaseUrl(pathServers);
295−
296− return undefined;
343+ docServers: readonly ServerInfo[],
344+): readonly ServerInfo[] => {
345+ const operationLevel = extractServerList(operation.servers);
346+ if (operationLevel.length > 0) return operationLevel;
347+ const pathLevel = extractServerList(pathItem.servers);
348+ if (pathLevel.length > 0) return pathLevel;
349+ return docServers;
297350 };
298351
299352 // ---------------------------------------------------------------------------
@@ -310,6 +363,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume · 10 unmodified lines
310363 }
311364
312365 const r = new DocResolver(doc);
366+ const docServers = extractServers(doc);
313367 const operations: ExtractedOperation[] = [];
314368
315369 for (const [pathTemplate, pathItem] of Object.entries(paths).sort(([a], [b]) =>
@@ -323,7 +377,8 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume · 7 unmodified lines
323377
324378 const parameters = extractParameters(pathItem, operation, r);
325379 const requestBody = extractRequestBody(operation, r);
326− const inputSchema = buildInputSchema(parameters, requestBody);
380+ const servers = operationServers(pathItem, operation, docServers);
381+ const inputSchema = buildInputSchema(parameters, requestBody, servers);
327382 const outputSchema = extractOutputSchema(operation, r);
328383 const tags = (operation.tags ?? []).filter((t) => t.trim().length > 0);
329384 const operationPathTemplate = explicitPathTemplate(operation) ?? pathTemplate;
@@ -333,7 +388,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume · 3 unmodified lines
333388 operationId: OperationId.make(deriveOperationId(method, pathTemplate, operation)),
334389 toolPath: Option.fromNullishOr(explicitToolPath(operation)),
335390 method,
336− baseUrl: extractOperationBaseUrl(pathItem, operation),
391+ servers,
337392 pathTemplate: operationPathTemplate,
338393 summary: Option.fromNullishOr(operation.summary),
339394 description: Option.fromNullishOr(operation.description),
@@ -351,7 +406,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume · 11 unmodified lines
351406 return ExtractionResult.make({
352407 title: Option.fromNullishOr(doc.info?.title),
353408 version: Option.fromNullishOr(doc.info?.version),
354− servers: extractServers(doc),
409+ servers: docServers,
355410 operations,
356411 });
357412 });
packages/plugins/openapi/src/sdk/google-discovery.test.ts+2−2
@@ -436,9 +436,9 @@ it.effect("bundles Google Discovery documents into one Google OpenAPI source", ( · 435 unmodified lines
436436 const extractedChatMessage = extracted.operations.find(
437437 (candidate) => candidate.operationId === "chat.spaces.messages.get",
438438 );
439− expect(extractedGmail?.baseUrl).toBe("https://gmail.googleapis.com/");
439+ expect(extractedGmail?.servers[0]?.url).toBe("https://gmail.googleapis.com/");
440440 expect(extractedChatMessage?.pathTemplate).toBe("/v1/{+name}");
441− expect(extractedChatMessage?.baseUrl).toBe("https://chat.googleapis.com/");
441+ expect(extractedChatMessage?.servers[0]?.url).toBe("https://chat.googleapis.com/");
442442 // v2: the bundled oauth scopes are carried on the oauth auth template.
443443 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
444444 expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([
packages/plugins/openapi/src/sdk/index.ts+1−1
@@ -40,7 +40,7 @@ export { · 39 unmodified lines
4040 } from "./preview";
4141 export {
4242 DocResolver,
43− resolveBaseUrl,
43+ resolveServerUrl,
4444 substituteUrlVariables,
4545 preferredContent,
4646 } from "./openapi-utils";
packages/plugins/openapi/src/sdk/invoke.ts+30−3
@@ -2,12 +2,14 @@ import { Effect, Layer, Option } from "effect"; · 1 unmodified line
22 import { HttpClient, HttpClientRequest } from "effect/unstable/http";
33
44 import { OpenApiInvocationError } from "./errors";
5+import { resolveServerUrl } from "./openapi-utils";
56 import {
67 type EncodingObject,
78 type OperationBinding,
89 InvocationResult,
910 type MediaBinding,
1011 type OperationParameter,
12+ type ServerInfo,
1113 } from "./types";
1214
1315 // ---------------------------------------------------------------------------
@@ -572,8 +574,34 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 558 unmodified lines
572574 });
573575 });
574576
577+// Connection `baseUrl` wins; otherwise the call's chosen server (`server.url`, or
578+// the first) resolved with its `{variables}` (call values, else spec defaults).
579+const resolveRequestHost = (
580+ servers: readonly ServerInfo[],
581+ serverArg: unknown,
582+ baseUrl: string,
583+): string => {
584+ if (baseUrl) return baseUrl;
585+ if (servers.length === 0) return "";
586+
587+ const arg = (
588+ typeof serverArg === "object" && serverArg !== null && !Array.isArray(serverArg)
589+ ? serverArg
590+ : {}
591+ ) as { url?: unknown; variables?: unknown };
592+ const chosen = servers.find((server) => server.url === arg.url) ?? servers[0]!;
593+
594+ const overrides: Record<string, string> = {};
595+ if (typeof arg.variables === "object" && arg.variables !== null) {
596+ for (const [name, value] of Object.entries(arg.variables as Record<string, unknown>)) {
597+ if (value != null && value !== "") overrides[name] = String(value);
598+ }
599+ }
600+ return resolveServerUrl(chosen.url, Option.getOrUndefined(chosen.variables), overrides);
601+};
602+
575603 // ---------------------------------------------------------------------------
576−// Invoke with a provided HttpClient layer + optional baseUrl prefix
604+// Invoke with a provided HttpClient layer + per-call host resolution
577605 // ---------------------------------------------------------------------------
578606
579607 export const invokeWithLayer = (
@@ -584,8 +612,7 @@ export const invokeWithLayer = ( · 4 unmodified lines
584612 sourceQueryParams: Record<string, string>,
585613 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
586614 ) => {
587− const operationBaseUrl = operation.baseUrl;
588− const effectiveBaseUrl = operationBaseUrl ?? baseUrl;
615+ const effectiveBaseUrl = resolveRequestHost(operation.servers ?? [], args.server, baseUrl);
589616 const clientWithBaseUrl = effectiveBaseUrl
590617 ? Layer.effect(
591618 HttpClient.HttpClient,
packages/plugins/openapi/src/sdk/openapi-utils.ts+12−42
@@ -4,7 +4,6 @@ · 3 unmodified lines
44 // Wraps the openapi-types V3/V3_1 union mess and provides clean ref resolution.
55 // ---------------------------------------------------------------------------
66
7−import { Option } from "effect";
87 import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
98 import type { ParsedDocument } from "./parse";
109 import type { ServerVariable } from "./types";
@@ -65,49 +64,20 @@ export const substituteUrlVariables = (url: string, values: Record<string, strin · 54 unmodified lines
6564 return out;
6665 };
6766
68−export const OPENAPI_MAX_SERVER_VARIABLE_OPTIONS = 64;
69−
70−type ServerLike = {
71− url: string;
72− variables: import("effect/Option").Option<Record<string, ServerVariable | string>>;
73−};
74−
75−export const expandServerUrlOptions = (
76− server: ServerLike,
77− limit = OPENAPI_MAX_SERVER_VARIABLE_OPTIONS,
78−): readonly string[] => {
79− if (!Option.isSome(server.variables)) return [server.url];
80− let urls: readonly string[] = [server.url];
81− for (const [name, variable] of Object.entries(server.variables.value)) {
82− const enumValues =
83− typeof variable === "string" ? [] : Option.getOrElse(variable.enum, () => []);
84− const values =
85− enumValues.length > 0
86− ? enumValues
87− : [typeof variable === "string" ? variable : variable.default];
88− const next: string[] = [];
89− for (const url of urls) {
90− for (const value of values) {
91− next.push(url.replaceAll(`{${name}}`, value));
92− if (next.length >= limit) return next;
93− }
94− }
95− urls = next;
96− }
97− return urls;
98−};
99−
100−export const resolveBaseUrl = (servers: readonly ServerLike[]): string => {
101− const server = servers[0];
102− if (!server) return "";
103−
104− if (!Option.isSome(server.variables)) return server.url;
105−
67+/** Resolve a templated server URL, filling each `{var}` from `overrides` when
68+ * non-empty, otherwise the variable's spec default. URLs without placeholders
69+ * pass through unchanged. */
70+export const resolveServerUrl = (
71+ templateUrl: string,
72+ variables: Record<string, ServerVariable> | undefined,
73+ overrides: Record<string, string>,
74+): string => {
10675 const values: Record<string, string> = {};
107− for (const [name, v] of Object.entries(server.variables.value)) {
108− values[name] = typeof v === "string" ? v : v.default;
76+ for (const [name, v] of Object.entries(variables ?? {})) values[name] = v.default;
77+ for (const [name, value] of Object.entries(overrides)) {
78+ if (value) values[name] = value;
10979 }
110− return substituteUrlVariables(server.url, values);
80+ return substituteUrlVariables(templateUrl, values);
11181 };
11282
11383 // ---------------------------------------------------------------------------
packages/plugins/openapi/src/sdk/plugin.test.ts+18−14
@@ -559,28 +559,32 @@ describe("OpenAPI Plugin", () => { · 558 unmodified lines
559559 ),
560560 );
561561
562− it.effect("addSpec defaults baseUrl to the spec's first server when omitted", () =>
562+ it.effect("addSpec omits baseUrl and resolves the host per call from the spec's servers", () =>
563563 Effect.scoped(
564564 Effect.gen(function* () {
565565 const server = yield* servePluginTestApi();
566566 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
567567
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 }],
568+ // No baseUrl override: the spec declares `servers`, so the host is
569+ // resolved per call from the operation's servers rather than baked into
570+ // the connection config. `baseUrl: null` suppresses the test helper's
571+ // default connection-level override.
572+ const conn = yield* addOpenApiTestConnection(executor, server, {
573+ slug: "per_call_host",
574+ baseUrl: null,
575575 });
576576
577− yield* executor.openapi.addSpec({
578− spec: { kind: "blob", value: specWithServer },
579− slug: "derived_base_api",
580− });
577+ // The override is absent…
578+ const config = yield* executor.openapi.getConfig("per_call_host");
579+ expect(config?.baseUrl).toBeUndefined();
581580
582− const config = yield* executor.openapi.getConfig("derived_base_api");
583− expect(config?.baseUrl).toBe(server.baseUrl);
581+ // …yet the integration is still invocable: the request reaches the
582+ // spec's server host with no baked baseUrl.
583+ const result = unwrapInvocation(
584+ yield* executor.execute(conn.address("items.listItems"), {}),
585+ );
586+ expect(result.error).toBeNull();
587+ expect(result.data).toEqual(ITEMS);
584588 }),
585589 ),
586590 );
packages/plugins/openapi/src/sdk/plugin.ts+9−4
@@ -481,7 +481,7 @@ const normalizeOpenApiRefs = (node: unknown): unknown => { · 480 unmodified lines
481481 const toBinding = (def: ToolDefinition): OperationBinding =>
482482 OperationBinding.make({
483483 method: def.operation.method,
484− baseUrl: def.operation.baseUrl,
484+ servers: def.operation.servers,
485485 pathTemplate: def.operation.pathTemplate,
486486 parameters: [...def.operation.parameters],
487487 requestBody: def.operation.requestBody,
@@ -713,8 +713,10 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 225 unmodified lines
713713 // Defaults the add page derives from its preview, applied here so
714714 // headless callers (MCP, API) get the same integration the UI's
715715 // 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")
716+ // - effectiveBaseUrl: the spec's first server, used to anchor the
717+ // derived auth template's absolute URLs. It is NOT stored as the
718+ // connection baseUrl — the request host is resolved per call from
719+ // the operation's extracted `servers`.
718720 // - authenticationTemplate: the spec's declared security schemes
719721 // (else the Add-connection modal is a dead "No authentication"
720722 // end with nowhere to paste a credential)
@@ -758,7 +760,10 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 37 unmodified lines
758760 ...(specInputToGoogleBundle(config.spec) !== undefined
759761 ? { googleDiscoveryUrls: specInputToGoogleBundle(config.spec) }
760762 : {}),
761− ...(effectiveBaseUrl ? { baseUrl: effectiveBaseUrl } : {}),
763+ // baseUrl is an optional override only. The host is otherwise
764+ // resolved per call from the operation's `servers` (extracted from
765+ // the spec), so we never bake a derived base URL into the config.
766+ ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
762767 ...(config.headers ? { headers: config.headers } : {}),
763768 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
764769 // Prefer the caller's explicit template; otherwise adopt the one the
packages/plugins/openapi/src/sdk/query-serialization.test.ts+83−3
@@ -4,7 +4,7 @@ import { FetchHttpClient } from "effect/unstable/http"; · 3 unmodified lines
44 import { createServer, type Server } from "node:http";
55
66 import { invokeWithLayer } from "./invoke";
7−import { OperationBinding, OperationParameter } from "./types";
7+import { OperationBinding, OperationParameter, ServerInfo } from "./types";
88
99 const withServer = <A>(
1010 f: (input: { readonly baseUrl: string; readonly requests: string[] }) => Promise<A>,
@@ -37,6 +37,7 @@ it.effect("serializes form-exploded query arrays as repeated parameters", () => · 26 unmodified lines
3737 withServer(async ({ baseUrl, requests }) => {
3838 const operation = OperationBinding.make({
3939 method: "get",
40+ servers: [],
4041 pathTemplate: "/messages/{id}",
4142 requestBody: Option.none(),
4243 parameters: [
@@ -101,7 +102,9 @@ it.effect("uses operation base URL and preserves reserved path expansion when al · 58 unmodified lines
101102 withServer(async ({ baseUrl, requests }) => {
102103 const operation = OperationBinding.make({
103104 method: "get",
104− baseUrl,
105+ servers: [
106+ ServerInfo.make({ url: baseUrl, description: Option.none(), variables: Option.none() }),
107+ ],
105108 pathTemplate: "/v1/{+name}",
106109 requestBody: Option.none(),
107110 parameters: [
@@ -124,7 +127,8 @@ it.effect("uses operation base URL and preserves reserved path expansion when al · 16 unmodified lines
124127 {
125128 name: "spaces/AAA/messages/BBB",
126129 },
127− "https://unused.example",
130+ // No connection override → the request targets the operation's server.
131+ "",
128132 {},
129133 {},
130134 FetchHttpClient.layer,
@@ -136,3 +140,79 @@ it.effect("uses operation base URL and preserves reserved path expansion when al · 5 unmodified lines
136140 }),
137141 ),
138142 );
143+
144+it.effect("targets the server chosen by the call's `server.url`", () =>
145+ Effect.promise(() =>
146+ withServer(async ({ baseUrl, requests }) => {
147+ const operation = OperationBinding.make({
148+ method: "get",
149+ // First server is a dead host; the call must select the live one.
150+ servers: [
151+ ServerInfo.make({
152+ url: "https://unused.example",
153+ description: Option.none(),
154+ variables: Option.none(),
155+ }),
156+ ServerInfo.make({ url: baseUrl, description: Option.none(), variables: Option.none() }),
157+ ],
158+ pathTemplate: "/ping",
159+ requestBody: Option.none(),
160+ parameters: [],
161+ });
162+
163+ await Effect.runPromise(
164+ invokeWithLayer(operation, { server: { url: baseUrl } }, "", {}, {}, FetchHttpClient.layer),
165+ );
166+
167+ const url = new URL(requests[0]!, "http://executor.test");
168+ expect(url.pathname).toBe("/ping");
169+ }),
170+ ),
171+);
172+
173+it.effect("a connection base URL overrides the operation's servers", () =>
174+ Effect.promise(() =>
175+ withServer(async ({ baseUrl, requests }) => {
176+ const operation = OperationBinding.make({
177+ method: "get",
178+ servers: [
179+ ServerInfo.make({
180+ url: "https://unused.example",
181+ description: Option.none(),
182+ variables: Option.none(),
183+ }),
184+ ],
185+ pathTemplate: "/ping",
186+ requestBody: Option.none(),
187+ parameters: [],
188+ });
189+
190+ // The live server is the connection override; it wins over the spec server.
191+ await Effect.runPromise(
192+ invokeWithLayer(operation, {}, baseUrl, {}, {}, FetchHttpClient.layer),
193+ );
194+
195+ expect(new URL(requests[0]!, "http://executor.test").pathname).toBe("/ping");
196+ }),
197+ ),
198+);
199+
200+it.effect("falls back to the base URL for bindings persisted without servers", () =>
201+ Effect.promise(() =>
202+ withServer(async ({ baseUrl, requests }) => {
203+ // Old binding shape: no `servers` field at all.
204+ const operation = OperationBinding.make({
205+ method: "get",
206+ pathTemplate: "/ping",
207+ requestBody: Option.none(),
208+ parameters: [],
209+ });
210+
211+ await Effect.runPromise(
212+ invokeWithLayer(operation, {}, baseUrl, {}, {}, FetchHttpClient.layer),
213+ );
214+
215+ expect(new URL(requests[0]!, "http://executor.test").pathname).toBe("/ping");
216+ }),
217+ ),
218+);
packages/plugins/openapi/src/sdk/server-url-resolution.test.ts+253−0
@@ -0,0 +1,253 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Option } from "effect";
3+import { FetchHttpClient } from "effect/unstable/http";
4+
5+import { extract } from "./extract";
6+import { resolveServerUrl } from "./openapi-utils";
7+import { parse } from "./parse";
8+import { previewSpec as previewSpecRaw } from "./preview";
9+import { ServerVariable } from "./types";
10+
11+const previewSpec = (input: string) =>
12+ previewSpecRaw(input).pipe(Effect.provide(FetchHttpClient.layer));
13+
14+const serverVariable = (defaultValue: string): ServerVariable =>
15+ ServerVariable.make({ default: defaultValue, enum: Option.none(), description: Option.none() });
16+
17+// ---------------------------------------------------------------------------
18+// resolveServerUrl — invoke-time template resolution
19+// ---------------------------------------------------------------------------
20+
21+describe("resolveServerUrl", () => {
22+ const vars = {
23+ tenant: serverVariable("default-tenant"),
24+ region: serverVariable("us-east-1"),
25+ };
26+
27+ it("fills placeholders from variable defaults when no override is given", () => {
28+ expect(resolveServerUrl("https://{tenant}.{region}.api.example.com", vars, {})).toBe(
29+ "https://default-tenant.us-east-1.api.example.com",
30+ );
31+ });
32+
33+ it("prefers connection overrides over defaults", () => {
34+ expect(
35+ resolveServerUrl("https://{tenant}.{region}.api.example.com", vars, {
36+ tenant: "acme",
37+ region: "eu-west-1",
38+ }),
39+ ).toBe("https://acme.eu-west-1.api.example.com");
40+ });
41+
42+ it("ignores empty overrides and keeps the default", () => {
43+ expect(
44+ resolveServerUrl("https://{tenant}.{region}.api.example.com", vars, { tenant: "" }),
45+ ).toBe("https://default-tenant.us-east-1.api.example.com");
46+ });
47+
48+ it("returns a URL with no placeholders unchanged", () => {
49+ expect(resolveServerUrl("https://api.example.com", undefined, { tenant: "acme" })).toBe(
50+ "https://api.example.com",
51+ );
52+ });
53+});
54+
55+// ---------------------------------------------------------------------------
56+// Operation applicable servers — operation/path override else document servers
57+// ---------------------------------------------------------------------------
58+
59+// Raw JSON so the path-level `servers` override is easy to express.
60+const specWithPathOverride = {
61+ openapi: "3.0.0",
62+ info: { title: "Example", version: "1.0" },
63+ servers: [{ url: "https://api.example.com" }],
64+ paths: {
65+ "/items": {
66+ get: { operationId: "listItems", responses: { "200": { description: "ok" } } },
67+ },
68+ "/query": {
69+ servers: [
70+ {
71+ url: "https://{tenant}.{region}.api.example.com",
72+ variables: { tenant: { default: "default-tenant" }, region: { default: "us-east-1" } },
73+ },
74+ ],
75+ post: { operationId: "runQuery", responses: { "200": { description: "ok" } } },
76+ },
77+ },
78+};
79+
80+describe("extract — operation applicable servers", () => {
81+ it.effect("inherits the document servers when there is no override", () =>
82+ Effect.gen(function* () {
83+ // @effect-diagnostics-next-line preferSchemaOverJson:off
84+ const doc = yield* parse(JSON.stringify(specWithPathOverride));
85+ const result = yield* extract(doc);
86+
87+ const listItems = result.operations.find((op) => op.operationId === "listItems")!;
88+ expect(listItems.servers.map((s) => s.url)).toEqual(["https://api.example.com"]);
89+ }),
90+ );
91+
92+ it.effect("carries a path-level override's servers as templates with variables", () =>
93+ Effect.gen(function* () {
94+ // @effect-diagnostics-next-line preferSchemaOverJson:off
95+ const doc = yield* parse(JSON.stringify(specWithPathOverride));
96+ const result = yield* extract(doc);
97+
98+ const runQuery = result.operations.find((op) => op.operationId === "runQuery")!;
99+ expect(runQuery.servers.map((s) => s.url)).toEqual([
100+ "https://{tenant}.{region}.api.example.com",
101+ ]);
102+ const vars = Option.getOrThrow(runQuery.servers[0]!.variables);
103+ expect(vars.tenant?.default).toBe("default-tenant");
104+ expect(vars.region?.default).toBe("us-east-1");
105+
106+ // The host resolves per call: defaults, or call-supplied overrides.
107+ const server = runQuery.servers[0]!;
108+ expect(resolveServerUrl(server.url, Option.getOrUndefined(server.variables), {})).toBe(
109+ "https://default-tenant.us-east-1.api.example.com",
110+ );
111+ expect(
112+ resolveServerUrl(server.url, Option.getOrUndefined(server.variables), {
113+ tenant: "acme",
114+ region: "eu-west-1",
115+ }),
116+ ).toBe("https://acme.eu-west-1.api.example.com");
117+ }),
118+ );
119+});
120+
121+// ---------------------------------------------------------------------------
122+// Preview surfaces each top-level server with its own variables
123+// ---------------------------------------------------------------------------
124+
125+describe("previewSpec — server variables", () => {
126+ it.effect("carries variables per top-level server, excluding operation overrides", () =>
127+ Effect.gen(function* () {
128+ const spec = {
129+ openapi: "3.0.0",
130+ info: { title: "Example", version: "1.0" },
131+ servers: [
132+ { url: "https://api.example.com", description: "Control plane" },
133+ {
134+ url: "https://{branch}.{region}.example.com",
135+ variables: { branch: { default: "main" }, region: { default: "us-east-1" } },
136+ },
137+ ],
138+ paths: {
139+ "/query": {
140+ servers: [
141+ {
142+ url: "https://{tenant}.gw.example.com",
143+ variables: { tenant: { default: "default-tenant" } },
144+ },
145+ ],
146+ post: { operationId: "runQuery", responses: { "200": { description: "ok" } } },
147+ },
148+ },
149+ };
150+ // @effect-diagnostics-next-line preferSchemaOverJson:off
151+ const preview = yield* previewSpec(JSON.stringify(spec));
152+
153+ // The plain control-plane server has no variables.
154+ const control = preview.servers.find((s) => s.url === "https://api.example.com")!;
155+ expect(Option.getOrNull(control.variables)).toBeNull();
156+
157+ // The gateway server carries only its own branch/region.
158+ const gateway = preview.servers.find((s) => s.url.startsWith("https://{branch}"))!;
159+ expect(Object.keys(Option.getOrThrow(gateway.variables))).toEqual(["branch", "region"]);
160+
161+ // The operation override's `tenant` never appears in the top-level servers.
162+ const names = preview.servers.flatMap((s) =>
163+ Object.keys(Option.getOrElse(s.variables, () => ({}))),
164+ );
165+ expect(names).not.toContain("tenant");
166+ }),
167+ );
168+});
169+
170+// ---------------------------------------------------------------------------
171+// The `server` input property exposes per-call host selection + variables
172+// ---------------------------------------------------------------------------
173+
174+type ServerInputSchema = {
175+ readonly required?: readonly string[];
176+ readonly properties: {
177+ readonly server?: {
178+ readonly properties: {
179+ readonly url?: { readonly enum: readonly string[]; readonly default: string };
180+ readonly variables?: {
181+ readonly properties: Record<
182+ string,
183+ { readonly default?: string; readonly enum?: readonly string[] }
184+ >;
185+ };
186+ };
187+ };
188+ };
189+};
190+
191+describe("buildInputSchema — server property", () => {
192+ it.effect("exposes a server picker and variables for multiple/templated servers", () =>
193+ Effect.gen(function* () {
194+ const spec = {
195+ openapi: "3.0.0",
196+ info: { title: "Example", version: "1.0" },
197+ servers: [
198+ { url: "https://api.example.com" },
199+ {
200+ url: "https://{branch}.{region}.example.com",
201+ variables: {
202+ branch: { default: "main" },
203+ region: { default: "us", enum: ["us", "eu"] },
204+ },
205+ },
206+ ],
207+ paths: {
208+ "/items": {
209+ get: { operationId: "listItems", responses: { "200": { description: "ok" } } },
210+ },
211+ },
212+ };
213+ // @effect-diagnostics-next-line preferSchemaOverJson:off
214+ const result = yield* extract(yield* parse(JSON.stringify(spec)));
215+ const op = result.operations.find((o) => o.operationId === "listItems")!;
216+ const schema = Option.getOrThrow(op.inputSchema) as ServerInputSchema;
217+
218+ expect(schema.properties.server?.properties.url?.enum).toEqual([
219+ "https://api.example.com",
220+ "https://{branch}.{region}.example.com",
221+ ]);
222+ const vars = schema.properties.server?.properties.variables?.properties;
223+ expect(vars?.branch?.default).toBe("main");
224+ expect(vars?.region?.enum).toEqual(["us", "eu"]);
225+ // The host stays optional — defaults apply when the call omits `server`.
226+ expect(schema.required ?? []).not.toContain("server");
227+ }),
228+ );
229+
230+ it.effect("omits the server property for a single concrete server", () =>
231+ Effect.gen(function* () {
232+ const spec = {
233+ openapi: "3.0.0",
234+ info: { title: "Example", version: "1.0" },
235+ servers: [{ url: "https://api.example.com" }],
236+ paths: {
237+ "/items/{id}": {
238+ get: {
239+ operationId: "getItem",
240+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
241+ responses: { "200": { description: "ok" } },
242+ },
243+ },
244+ },
245+ };
246+ // @effect-diagnostics-next-line preferSchemaOverJson:off
247+ const result = yield* extract(yield* parse(JSON.stringify(spec)));
248+ const op = result.operations.find((o) => o.operationId === "getItem")!;
249+ const schema = Option.getOrThrow(op.inputSchema) as ServerInputSchema;
250+ expect(schema.properties.server).toBeUndefined();
251+ }),
252+ );
253+});
packages/plugins/openapi/src/sdk/types.ts+16−16
@@ -135,11 +135,25 @@ export const OperationRequestBody = Schema.Struct({ · 134 unmodified lines
135135 });
136136 export type OperationRequestBody = typeof OperationRequestBody.Type;
137137
138+export const ServerVariable = Schema.Struct({
139+ default: Schema.String,
140+ enum: Schema.OptionFromOptional(Schema.Array(Schema.String)),
141+ description: Schema.OptionFromOptional(Schema.String),
142+});
143+export type ServerVariable = typeof ServerVariable.Type;
144+
145+export const ServerInfo = Schema.Struct({
146+ url: Schema.String,
147+ description: Schema.OptionFromOptional(Schema.String),
148+ variables: Schema.OptionFromOptional(Schema.Record(Schema.String, ServerVariable)),
149+});
150+export type ServerInfo = typeof ServerInfo.Type;
151+
138152 export const ExtractedOperation = Schema.Struct({
139153 operationId: OperationId,
140154 toolPath: Schema.OptionFromOptional(Schema.String),
141155 method: HttpMethod,
142− baseUrl: Schema.optional(Schema.String),
156+ servers: Schema.Array(ServerInfo),
143157 pathTemplate: Schema.String,
144158 summary: Schema.OptionFromOptional(Schema.String),
145159 description: Schema.OptionFromOptional(Schema.String),
@@ -152,20 +166,6 @@ export const ExtractedOperation = Schema.Struct({ · 6 unmodified lines
152166 });
153167 export type ExtractedOperation = typeof ExtractedOperation.Type;
154168
155−export const ServerVariable = Schema.Struct({
156− default: Schema.String,
157− enum: Schema.OptionFromOptional(Schema.Array(Schema.String)),
158− description: Schema.OptionFromOptional(Schema.String),
159−});
160−export type ServerVariable = typeof ServerVariable.Type;
161−
162−export const ServerInfo = Schema.Struct({
163− url: Schema.String,
164− description: Schema.OptionFromOptional(Schema.String),
165− variables: Schema.OptionFromOptional(Schema.Record(Schema.String, ServerVariable)),
166−});
167−export type ServerInfo = typeof ServerInfo.Type;
168−
169169 export const ExtractionResult = Schema.Struct({
170170 title: Schema.OptionFromOptional(Schema.String),
171171 version: Schema.OptionFromOptional(Schema.String),
@@ -180,7 +180,7 @@ export type ExtractionResult = typeof ExtractionResult.Type; · 8 unmodified lines
180180
181181 export const OperationBinding = Schema.Struct({
182182 method: HttpMethod,
183− baseUrl: Schema.optional(Schema.String),
183+ servers: Schema.optional(Schema.Array(ServerInfo)),
184184 pathTemplate: Schema.String,
185185 parameters: Schema.Array(OperationParameter),
186186 requestBody: Schema.OptionFromOptional(OperationRequestBody),