Skip to content

Commit 36f9bd4

Browse files
committed
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.
1 parent 04a26cc commit 36f9bd4

12 files changed

Lines changed: 541 additions & 130 deletions

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

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,11 @@ import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
4545
import {
4646
type APIKeyAuthentication,
4747
type Authentication,
48-
type ServerInfo,
4948
TOKEN_VARIABLE,
5049
variable,
5150
} from "../sdk/types";
52-
import { expandServerUrlOptions } from "../sdk/openapi-utils";
51+
import { resolveServerUrl } from "../sdk/openapi-utils";
5352

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

5755
// The bundle picker opens with the featured Google APIs pre-checked.
@@ -212,14 +210,6 @@ const oauthTemplateFromPreset = (
212210
scopes: [...scopes],
213211
});
214212

215-
const expandServerOptions = (server: ServerInfo) =>
216-
expandServerUrlOptions(server).map((value) => ({ value, label: value }));
217-
218-
const firstBaseUrlForPreview = (preview: SpecPreview): string => {
219-
const firstServer = preview.servers[0];
220-
return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : "";
221-
};
222-
223213
// ---------------------------------------------------------------------------
224214
// All spec-detected auth methods → the union of stored `Authentication`
225215
// templates. Header presets become apiKey templates; each oauth2 preset becomes
@@ -285,7 +275,7 @@ export default function AddOpenApiSource(props: {
285275

286276
// After analysis
287277
const [preview, setPreview] = useState<SpecPreview | null>(null);
288-
const [baseUrl, setBaseUrl] = useState(isGoogleBundlePreset ? GOOGLE_BUNDLE_BASE_URL : "");
278+
const [baseUrl, setBaseUrl] = useState("");
289279
const identityFallbackName = isGoogleBundlePreset
290280
? "Google"
291281
: preview
@@ -347,10 +337,20 @@ export default function AddOpenApiSource(props: {
347337

348338
// ---- Derived state ----
349339

350-
const servers: readonly ServerInfo[] = preview?.servers ?? [];
351-
const baseUrlOptions = Array.from(
352-
new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(),
353-
);
340+
const previewHasNoServers = preview !== null && preview.servers.length === 0;
341+
// Offer the spec's servers (resolved with defaults) as base-URL choices when
342+
// there's more than one; a single/no server uses a plain input.
343+
const baseUrlOptions =
344+
preview && preview.servers.length > 1
345+
? preview.servers.map((server) => {
346+
const url = resolveServerUrl(server.url, Option.getOrUndefined(server.variables), {});
347+
return { value: url, label: url };
348+
})
349+
: undefined;
350+
const firstServer = preview?.servers[0];
351+
const firstServerUrl = firstServer
352+
? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {})
353+
: "";
354354
const previewPresetIcon =
355355
openApiPresets.find(
356356
(preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl),
@@ -466,7 +466,12 @@ export default function AddOpenApiSource(props: {
466466
const hasPreviewOrBundle = isGoogleBundlePreset
467467
? bundleDiscoveryUrls.length > 0
468468
: preview !== null;
469-
const canAdd = hasPreviewOrBundle && resolvedBaseUrl.length > 0 && !slugAlreadyExists;
469+
// The base URL is optional when the spec declares servers (resolved per call);
470+
// required only when it doesn't.
471+
const canAdd =
472+
hasPreviewOrBundle &&
473+
!slugAlreadyExists &&
474+
(!previewHasNoServers || resolvedBaseUrl.length > 0);
470475

471476
// ---- Handlers ----
472477

@@ -482,7 +487,7 @@ export default function AddOpenApiSource(props: {
482487
}
483488
const result = exit.value;
484489
setPreview(result);
485-
setBaseUrl(firstBaseUrlForPreview(result));
490+
setBaseUrl("");
486491
setAnalyzing(false);
487492
};
488493

@@ -614,9 +619,9 @@ export default function AddOpenApiSource(props: {
614619
identity={identity}
615620
baseUrl={resolvedBaseUrl}
616621
onBaseUrlChange={setBaseUrl}
622+
baseUrlLabel="Base URL override (optional)"
617623
faviconIcon={GOOGLE_BUNDLE_FAVICON}
618624
faviconUrl={resolvedBaseUrl}
619-
baseUrlMissingMessage="A base URL is required to make requests."
620625
/>
621626
) : preview ? (
622627
<OpenApiSourceDetailsFields
@@ -632,15 +637,24 @@ export default function AddOpenApiSource(props: {
632637
baseUrl={resolvedBaseUrl}
633638
onBaseUrlChange={setBaseUrl}
634639
baseUrlOptions={baseUrlOptions}
640+
baseUrlLabel={previewHasNoServers ? "Base URL" : "Base URL override (optional)"}
641+
baseUrlPlaceholder={firstServerUrl || "https://api.example.com"}
642+
baseUrlHint={
643+
previewHasNoServers
644+
? undefined
645+
: "Overrides the spec's servers; leave empty to choose the server (and variables) per tool call."
646+
}
647+
baseUrlMissingMessage={
648+
previewHasNoServers ? "This spec declares no servers — enter a base URL." : undefined
649+
}
635650
specUrl={specUrl}
636651
onSpecUrlChange={(value) => {
637652
setSpecUrl(value);
638653
setPreview(null);
639654
setBaseUrl("");
640655
}}
641656
faviconIcon={previewPresetIcon}
642-
faviconUrl={resolvedBaseUrl}
643-
baseUrlMissingMessage="A base URL is required to make requests."
657+
faviconUrl={resolvedBaseUrl || firstServerUrl}
644658
/>
645659
) : null}
646660

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export function OpenApiSourceDetailsFields(props: {
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: {
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,21 +62,21 @@ export function OpenApiSourceDetailsFields(props: {
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
/>
7475
) : (
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: {
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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({
5252
sourceUrl: Schema.optional(Schema.String),
5353
/** Google Discovery bundle URLs, when the spec came from a Google bundle. */
5454
googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)),
55-
/** Base URL override; falls back to the spec's first server. */
55+
/** Optional base URL override (proxy, or the host for specs with no servers). */
5656
baseUrl: Schema.optional(Schema.String),
5757
/** Static headers applied to every request (no secret material). */
5858
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),

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

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
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
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 = (
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
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
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
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
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
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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -436,9 +436,9 @@ it.effect("bundles Google Discovery documents into one Google OpenAPI source", (
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.type === "oauth");
444444
expect(oauthTemplate?.type === "oauth" ? oauthTemplate.scopes : undefined).toEqual([

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export {
4040
} from "./preview";
4141
export {
4242
DocResolver,
43-
resolveBaseUrl,
43+
resolveServerUrl,
4444
substituteUrlVariables,
4545
preferredContent,
4646
} from "./openapi-utils";

0 commit comments

Comments
 (0)