From 6713032d7c5e10455c726200eafa0d6ededd5f4b Mon Sep 17 00:00:00 2001 From: JacobSampson Date: Mon, 8 Jun 2026 15:31:57 -0500 Subject: [PATCH] feat: add OpenAPI direct discovery path, remove UTCP Tool[] from core pipeline Introduces `DiscoveredOperation` as a unified pointer type that replaces UTCP's `Tool[]` across `buildClientToolMap`, `buildPublicTypeMap`, and `toClientTools`. All downstream schema resolution and type generation continues to read directly from the OpenAPI document. Key changes: - `openapi-discovery.ts` (new): walks `document.paths` and enumerates operations as `DiscoveredOperation[]` without UTCP. - `client-api.ts`: `buildClientToolMap` now accepts `DiscoveredOperation[]`; `getOperationContext` resolves by `op.path` directly instead of URL parsing. - `openapi.ts`: `buildPublicTypeMap` now accepts `DiscoveredOperation[]`. - `render.ts`: `toClientTools`, `renderProviderTypes`, `renderProviderGroupTypes`, `renderProviderTypesIndex` all accept `DiscoveredOperation[]`. - `docs/augment.ts`: `AugmentProviderDocsOptions` switches `tools` to `operations`, adds `publicTypeMap`; `buildOperationLookup` updated. - `utcp.ts`: exports `toolsToDiscoveredOperations` to convert UTCP output for the UTCP-success path. - `index.ts`: `generateRegistryTypes` and `augmentRegistryProviderDocs` try UTCP first; fall back to `discoverOperationsFromOpenApi` when UTCP returns empty. All 185 tests pass; lint clean. Co-Authored-By: Claude Sonnet 4.6 --- packages/bundler/src/client-api.test.ts | 62 ++-- packages/bundler/src/client-api.ts | 58 +-- packages/bundler/src/docs/augment.ts | 23 +- packages/bundler/src/index.ts | 30 +- .../bundler/src/openapi-discovery.test.ts | 337 ++++++++++++++++++ packages/bundler/src/openapi-discovery.ts | 138 +++++++ packages/bundler/src/openapi.test.ts | 37 +- packages/bundler/src/openapi.ts | 35 +- packages/bundler/src/render.test.ts | 70 ++-- packages/bundler/src/render.ts | 37 +- packages/bundler/src/utcp.ts | 52 +++ 11 files changed, 683 insertions(+), 196 deletions(-) create mode 100644 packages/bundler/src/openapi-discovery.test.ts create mode 100644 packages/bundler/src/openapi-discovery.ts diff --git a/packages/bundler/src/client-api.test.ts b/packages/bundler/src/client-api.test.ts index 77a403d..f9736c8 100644 --- a/packages/bundler/src/client-api.test.ts +++ b/packages/bundler/src/client-api.test.ts @@ -1,34 +1,33 @@ import { describe, expect, it } from "vitest"; import { buildClientToolMap } from "./client-api.js"; -import type { Tool } from "@utcp/sdk"; +import type { DiscoveredOperation } from "./openapi-discovery.js"; import type { OpenAPIV3 } from "openapi-types"; - -function createTool(name: string, httpMethod: string, url: string, contentType = "application/json"): Tool { +/** + * `path` must match the key used in `document.paths` for the test. + * For documents with a server base path (e.g. `/v1`), pass the OpenAPI path + * key WITHOUT the base (e.g. `"/responses"` not `"/v1/responses"`). + */ +function createOp( + name: string, + method: string, + path: string, + contentType = "application/json", +): DiscoveredOperation { return { name, + method: method.toUpperCase(), + path, + routeTemplate: path, + contentType, description: name, tags: [], - tool_call_template: { - call_template_type: "http", - http_method: httpMethod, - url, - content_type: contentType, - }, - inputs: { - type: "object", - properties: {}, - }, - outputs: { - type: "object", - properties: {}, - }, - } as unknown as Tool; + }; } describe("buildClientToolMap", () => { it("renames duplicate nested method leaves to call", () => { - const tool = createTool("example.CreateLogsMetric/CreateLogsMetric", "POST", "https://api.example.com/v1/logs/metrics"); + const tool = createOp("example.CreateLogsMetric/CreateLogsMetric", "POST", "/v1/logs/metrics"); const openApiDocument = { openapi: "3.0.0", info: { title: "Example", version: "1.0.0" }, @@ -71,7 +70,7 @@ describe("buildClientToolMap", () => { }); it("keeps body fields in input and moves conflicting transport fields to overrides", () => { - const tool = createTool("example.fooBar", "POST", "https://api.example.com/v1/{id}/example"); + const tool = createOp("example.fooBar", "POST", "/v1/{id}/example"); const openApiDocument = { openapi: "3.0.0", info: { title: "Example", version: "1.0.0" }, @@ -122,7 +121,8 @@ describe("buildClientToolMap", () => { }); it("matches operations when the server url contributes a base path", () => { - const tool = createTool("openai.createResponse", "POST", "https://api.openai.com/v1/responses"); + // path = "/responses" matches directly in document.paths (server adds /v1 at runtime) + const tool = createOp("openai.createResponse", "POST", "/responses"); const openApiDocument = { openapi: "3.0.0", info: { title: "OpenAI", version: "1.0.0" }, @@ -171,7 +171,7 @@ describe("buildClientToolMap", () => { }); it("strips the full provider prefix for dotted provider names", () => { - const tool = createTool("google.books.volumes/list", "GET", "https://www.googleapis.com/books/v1/volumes"); + const tool = createOp("google.books.volumes/list", "GET", "/books/v1/volumes"); const openApiDocument = { openapi: "3.0.0", info: { title: "Google Books", version: "1.0.0" }, @@ -201,7 +201,7 @@ describe("buildClientToolMap", () => { }); it("keeps primitive request bodies as raw body input", () => { - const tool = createTool("spotify.uploadPlaylistCover", "PUT", "https://api.spotify.com/playlists/{playlist_id}/images", "image/jpeg"); + const tool = createOp("spotify.uploadPlaylistCover", "PUT", "/playlists/{playlist_id}/images", "image/jpeg"); const openApiDocument = { openapi: "3.0.0", info: { title: "Spotify", version: "1.0.0" }, @@ -237,7 +237,7 @@ describe("buildClientToolMap", () => { }); it("promotes object bodies with additionalProperties to top-level input", () => { - const tool = createTool("spotify.createPlaylist", "POST", "https://api.spotify.com/users/{user_id}/playlists"); + const tool = createOp("spotify.createPlaylist", "POST", "/users/{user_id}/playlists"); const openApiDocument = { openapi: "3.0.0", info: { title: "Spotify", version: "1.0.0" }, @@ -282,7 +282,7 @@ describe("buildClientToolMap", () => { }); it("populates description from operation summary", () => { - const tool = createTool("example.getFoo", "GET", "https://api.example.com/v1/foo"); + const tool = createOp("example.getFoo", "GET", "/v1/foo"); const openApiDocument = { openapi: "3.0.0", info: { title: "Example", version: "1.0.0" }, @@ -304,7 +304,7 @@ describe("buildClientToolMap", () => { }); it("falls back to operation description when summary is absent", () => { - const tool = createTool("example.getFoo", "GET", "https://api.example.com/v1/foo"); + const tool = createOp("example.getFoo", "GET", "/v1/foo"); const openApiDocument = { openapi: "3.0.0", info: { title: "Example", version: "1.0.0" }, @@ -326,7 +326,7 @@ describe("buildClientToolMap", () => { }); it("falls back to tool description when operation has neither summary nor description", () => { - const tool = createTool("example.getFoo", "GET", "https://api.example.com/v1/foo"); + const tool = createOp("example.getFoo", "GET", "/v1/foo"); const openApiDocument = { openapi: "3.0.0", info: { title: "Example", version: "1.0.0" }, @@ -347,7 +347,7 @@ describe("buildClientToolMap", () => { }); it("populates parameterDescriptions from operation parameters", () => { - const tool = createTool("example.getFoo", "GET", "https://api.example.com/v1/foo"); + const tool = createOp("example.getFoo", "GET", "/v1/foo"); const openApiDocument = { openapi: "3.0.0", info: { title: "Example", version: "1.0.0" }, @@ -390,7 +390,7 @@ describe("buildClientToolMap", () => { }); it("leaves description and parameterDescriptions undefined when absent", () => { - const tool = createTool("example.getBar", "GET", "https://api.example.com/v1/bar"); + const tool = createOp("example.getBar", "GET", "/v1/bar"); const openApiDocument = { openapi: "3.0.0", info: { title: "Example", version: "1.0.0" }, @@ -412,7 +412,7 @@ describe("buildClientToolMap", () => { }); it("includes parameter descriptions in inputSchema property schemas", () => { - const tool = createTool("example.getRepo", "GET", "https://api.example.com/v1/{owner}/{repo}"); + const tool = createOp("example.getRepo", "GET", "/v1/{owner}/{repo}"); const openApiDocument = { openapi: "3.0.0", info: { title: "Example", version: "1.0.0" }, @@ -466,7 +466,7 @@ describe("buildClientToolMap", () => { }); it("omits inputSchema when tool has no input properties", () => { - const tool = createTool("example.ping", "GET", "https://api.example.com/v1/ping"); + const tool = createOp("example.ping", "GET", "/v1/ping"); const openApiDocument = { openapi: "3.0.0", info: { title: "Example", version: "1.0.0" }, diff --git a/packages/bundler/src/client-api.ts b/packages/bundler/src/client-api.ts index 274dfd7..22836bb 100644 --- a/packages/bundler/src/client-api.ts +++ b/packages/bundler/src/client-api.ts @@ -1,7 +1,6 @@ -import { getDocumentPathItem } from "./openapi-path.js"; import { stripProviderToolName, type RegistryProvider } from "./provider.js"; import { schemaToTypeScriptType } from "./schema.js"; -import type { Tool } from "@utcp/sdk"; +import type { DiscoveredOperation } from "./openapi-discovery.js"; import type { OpenAPIV3 } from "openapi-types"; export type ToolRuntimeMetadata = { @@ -139,20 +138,10 @@ function resolveSchema( function getOperationContext( document: OpenAPIV3.Document, - tool: Tool, + op: DiscoveredOperation, ): { operation?: OpenAPIV3.OperationObject; pathItem?: OpenAPIV3.PathItemObject } { - const method = - tool.tool_call_template && typeof tool.tool_call_template.http_method === "string" - ? tool.tool_call_template.http_method.toLowerCase() - : undefined; - const rawUrl = - tool.tool_call_template && typeof tool.tool_call_template.url === "string" ? tool.tool_call_template.url : undefined; - - if (!method || !rawUrl) { - return {}; - } - - const { pathItem } = getDocumentPathItem(document, rawUrl); + const method = op.method.toLowerCase(); + const pathItem = document.paths?.[op.path]; if (!pathItem || !(method in pathItem)) { return {}; @@ -286,8 +275,8 @@ function createUniqueAccessPath(rawToolName: string, usedPaths: Set): st return accessPath; } -function buildRequestDefinition(document: OpenAPIV3.Document, tool: Tool): RequestDefinition { - const { operation, pathItem } = getOperationContext(document, tool); +function buildRequestDefinition(document: OpenAPIV3.Document, op: DiscoveredOperation): RequestDefinition { + const { operation, pathItem } = getOperationContext(document, op); const parameterMap = new Map(); const parameterValues = [...(pathItem?.parameters ?? []), ...(operation?.parameters ?? [])]; @@ -521,9 +510,9 @@ function buildOptionsSchema(request: RequestDefinition, runtimeMetadata: ToolRun function collectParameterDescriptions( document: OpenAPIV3.Document, - tool: Tool, + op: DiscoveredOperation, ): Record { - const { operation } = getOperationContext(document, tool); + const { operation } = getOperationContext(document, op); const descriptions: Record = {}; const parameterValues = operation?.parameters ?? []; @@ -540,24 +529,24 @@ function collectParameterDescriptions( export function buildClientToolMap( document: OpenAPIV3.Document, - tools: Tool[], + operations: DiscoveredOperation[], provider: Pick, ): Map { const usedPaths = new Set(); return new Map( - tools.map((tool) => { - const rawToolName = stripProviderToolName(tool.name, provider); - const request = buildRequestDefinition(document, tool); + operations.map((op) => { + const rawToolName = stripProviderToolName(op.name, provider); + const request = buildRequestDefinition(document, op); const { hasInput, schema: inputSchema, runtimeMetadata } = buildInputSchema(request); const { schema: optionsSchema, optional: optionsOptional, hasOptions } = buildOptionsSchema(request, runtimeMetadata); const accessPath = createUniqueAccessPath(rawToolName, usedPaths); - const parameterDescriptions = collectParameterDescriptions(document, tool); - const { operation } = getOperationContext(document, tool); - const operationDescription = operation?.summary ?? operation?.description ?? tool.description; + const parameterDescriptions = collectParameterDescriptions(document, op); + const { operation } = getOperationContext(document, op); + const operationDescription = operation?.summary ?? operation?.description ?? op.description; return [ - tool.name, + op.name, { accessPath, hasInput, @@ -569,20 +558,11 @@ export function buildClientToolMap( runtimeMetadata: { ...runtimeMetadata, accessPath, - contentType: - tool.tool_call_template && typeof tool.tool_call_template.content_type === "string" - ? tool.tool_call_template.content_type - : undefined, + contentType: op.contentType, description: operationDescription || undefined, - method: - tool.tool_call_template && typeof tool.tool_call_template.http_method === "string" - ? tool.tool_call_template.http_method - : "GET", + method: op.method, parameterDescriptions: Object.keys(parameterDescriptions).length > 0 ? parameterDescriptions : undefined, - routeTemplate: - tool.tool_call_template && typeof tool.tool_call_template.url === "string" - ? tool.tool_call_template.url.replace(/^https?:\/\/[^/]+/u, "") - : "/", + routeTemplate: op.routeTemplate, }, }, ]; diff --git a/packages/bundler/src/docs/augment.ts b/packages/bundler/src/docs/augment.ts index cf6b0e0..64b41bd 100644 --- a/packages/bundler/src/docs/augment.ts +++ b/packages/bundler/src/docs/augment.ts @@ -2,22 +2,24 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { resolveProviderDocsIndexPath, resolveProviderDocsManifestPath, resolveProviderOutputDir } from "../provider.js"; import { stripProviderToolName, type RegistryProvider } from "../provider.js"; -import { schemaToTypeScriptType } from "../schema.js"; import { groupOpenApiOperations } from "./grouping.js"; import { getDocsStaleCheckResult } from "./hash.js"; import { readDocsManifest } from "./manifest.js"; import { loadDocsPromptAssets } from "./prompt.js"; import { DocsPipelineError, type ProviderPackageDocsMetadata } from "./types.js"; import type { ClientToolDefinition } from "../client-api.js"; -import type { Tool } from "@utcp/sdk"; +import type { DiscoveredOperation } from "../openapi-discovery.js"; import type { OpenAPIV3 } from "openapi-types"; +type PublicTypeMap = Map; + export type AugmentProviderDocsOptions = { provider: string; providerOptions?: RegistryProvider["options"]; openApiDocument: OpenAPIV3.Document; - tools?: Tool[]; + operations?: DiscoveredOperation[]; clientToolMap?: Map; + publicTypeMap?: PublicTypeMap; docsCacheRoot: string; outputRoot: string; overwriteDocs?: boolean; @@ -120,8 +122,9 @@ function isParameterObject( function buildOperationLookup( provider: string, providerOptions: RegistryProvider["options"] | undefined, - tools: Tool[] | undefined, + operations: DiscoveredOperation[] | undefined, clientToolMap: Map | undefined, + publicTypeMap: PublicTypeMap | undefined, ): { byMethodAndPath: Map; byOperationId: Map; @@ -129,26 +132,26 @@ function buildOperationLookup( const byMethodAndPath = new Map(); const byOperationId = new Map(); - for (const tool of tools ?? []) { - const mapped = clientToolMap?.get(tool.name); + for (const op of operations ?? []) { + const mapped = clientToolMap?.get(op.name); if (!mapped) { continue; } - const strippedToolName = stripProviderToolName(tool.name, { + const strippedToolName = stripProviderToolName(op.name, { name: provider, options: providerOptions, }); const operation: AugmentedOperation = { accessPath: mapped.accessPath, - description: tool.description, + description: op.description, hasInput: mapped.hasInput, hasOptions: mapped.hasOptions, inputType: mapped.inputType, optionsOptional: mapped.optionsOptional, optionsType: mapped.optionsType, - outputType: schemaToTypeScriptType(tool.outputs), + outputType: publicTypeMap?.get(op.name)?.outputType ?? "{ [key: string]: unknown }", toolName: strippedToolName, }; @@ -407,7 +410,7 @@ export async function augmentProviderDocs(options: AugmentProviderDocsOptions): } const groups = groupOpenApiOperations(options.openApiDocument); - const operationLookup = buildOperationLookup(options.provider, options.providerOptions, options.tools, options.clientToolMap); + const operationLookup = buildOperationLookup(options.provider, options.providerOptions, options.operations, options.clientToolMap, options.publicTypeMap); const packageSpecifier = getClientPackageSpecifier(options.provider); const clientVariable = toCamelCase(options.provider.split(/[./]/u).at(-1) ?? options.provider); const groupDocOpts: GroupDocOptions = { diff --git a/packages/bundler/src/index.ts b/packages/bundler/src/index.ts index d29f2dc..48004fa 100644 --- a/packages/bundler/src/index.ts +++ b/packages/bundler/src/index.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { buildClientToolMap } from "./client-api.js"; import { augmentProviderDocs, type AugmentProviderDocsResult } from "./docs/augment.js"; import { loadProviderDocs, type LoadProviderDocsOptions, type LoadProviderDocsResult } from "./docs/load.js"; +import { discoverOperationsFromOpenApi } from "./openapi-discovery.js"; import { applyProviderOpenApiOptions, buildPublicTypeMap, loadOpenApiDocument } from "./openapi.js"; import { runEnrichPhase, type RunEnrichPhaseOptions, type RunEnrichPhaseResult } from "./phases/enrich.js"; import { runResearchPhase, type RunResearchPhaseOptions, type RunResearchPhaseResult } from "./phases/research.js"; @@ -30,13 +31,12 @@ import { renderProviderMetadata, renderProviderPackageJson, renderProviderReadme, - renderProviderTypes, renderProviderTypesIndex, renderRootPackageEntry, renderRootPackageJson, renderRootTsconfig, } from "./render.js"; -import { loadProviderTools } from "./utcp.js"; +import { addMissingOperationIds, loadProviderTools, toolsToDiscoveredOperations } from "./utcp.js"; export { runResearchPhase, @@ -116,14 +116,21 @@ export async function augmentRegistryProviderDocs( const provider = resolveProvider(providers, options.provider); const rawOpenApiDocument = await loadOpenApiDocument(provider); const openApiDocument = applyProviderOpenApiOptions(rawOpenApiDocument, provider); + const specWithIds = addMissingOperationIds(rawOpenApiDocument); const { tools } = await loadProviderTools(provider, rawOpenApiDocument); - const clientToolMap = buildClientToolMap(openApiDocument, tools, provider); + const operations = + tools.length > 0 + ? toolsToDiscoveredOperations(tools, specWithIds) + : discoverOperationsFromOpenApi(specWithIds, provider.name); + const publicTypeMap = buildPublicTypeMap(openApiDocument, operations); + const clientToolMap = buildClientToolMap(openApiDocument, operations, provider); const augmented = await augmentProviderDocs({ provider: provider.name, providerOptions: provider.options, openApiDocument, - tools, + operations, clientToolMap, + publicTypeMap, docsCacheRoot, outputRoot, overwriteDocs: options.overwriteDocs, @@ -172,9 +179,14 @@ export async function generateRegistryTypes( const provider = resolveProvider(providers, options.provider); const rawOpenApiDocument = await loadOpenApiDocument(provider); const openApiDocument = applyProviderOpenApiOptions(rawOpenApiDocument, provider); + const specWithIds = addMissingOperationIds(rawOpenApiDocument); const { tools } = await loadProviderTools(provider, rawOpenApiDocument); - const publicTypeMap = buildPublicTypeMap(openApiDocument, tools); - const clientToolMap = buildClientToolMap(openApiDocument, tools, provider); + const operations = + tools.length > 0 + ? toolsToDiscoveredOperations(tools, specWithIds) + : discoverOperationsFromOpenApi(specWithIds, provider.name); + const publicTypeMap = buildPublicTypeMap(openApiDocument, operations); + const clientToolMap = buildClientToolMap(openApiDocument, operations, provider); const outputRoot = options.outputRoot ?? DEFAULT_OUTPUT_ROOT; const providerDir = resolveProviderOutputDir(provider.name, outputRoot); @@ -232,8 +244,8 @@ export async function generateRegistryTypes( writeTextFile(path.join(providerDir, "index.ts"), renderProviderEntry(provider.name, providerClientImportPath)), ...(await (async () => { const typesDir = path.join(providerDir, "types"); - const groupTypeFiles = renderProviderGroupTypes(provider, openApiDocument, tools, publicTypeMap, clientToolMap); - const indexContent = renderProviderTypesIndex(provider, openApiDocument, tools, publicTypeMap, clientToolMap); + const groupTypeFiles = renderProviderGroupTypes(provider, openApiDocument, operations, publicTypeMap, clientToolMap); + const indexContent = renderProviderTypesIndex(provider, openApiDocument, operations, publicTypeMap, clientToolMap); const typePaths = await Promise.all( [...groupTypeFiles.entries()].map(([fileName, content]) => writeTextFile(path.join(typesDir, fileName), content) @@ -253,7 +265,7 @@ export async function generateRegistryTypes( return { provider: provider.name, outputPaths, - toolCount: tools.length, + toolCount: operations.length, }; } diff --git a/packages/bundler/src/openapi-discovery.test.ts b/packages/bundler/src/openapi-discovery.test.ts new file mode 100644 index 0000000..3d0d583 --- /dev/null +++ b/packages/bundler/src/openapi-discovery.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it } from "vitest"; +import { discoverOperationsFromOpenApi } from "./openapi-discovery.js"; +import type { OpenAPIV3 } from "openapi-types"; + +describe("discoverOperationsFromOpenApi", () => { + it("enumerates all operations in a simple document", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/users": { + get: { operationId: "listUsers", responses: { "200": { description: "ok" } } }, + post: { operationId: "createUser", responses: { "200": { description: "ok" } } }, + }, + "/users/{id}": { + get: { operationId: "getUser", responses: { "200": { description: "ok" } } }, + delete: { operationId: "deleteUser", responses: { "200": { description: "ok" } } }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "example"); + + expect(ops).toHaveLength(4); + expect(ops.map((op) => op.name)).toEqual([ + "example.listUsers", + "example.createUser", + "example.getUser", + "example.deleteUser", + ]); + }); + + it("skips operations without an operationId", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/items": { + get: { operationId: "listItems", responses: { "200": { description: "ok" } } }, + post: { responses: { "200": { description: "ok" } } }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "example"); + + expect(ops).toHaveLength(1); + expect(ops[0]?.name).toBe("example.listItems"); + }); + + it("uses operationId to build the name with provider prefix", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/widgets": { + get: { operationId: "widgets_list", responses: { "200": { description: "ok" } } }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "my.api"); + + expect(ops[0]?.name).toBe("my_api.widgets_list"); + }); + + it("sanitizes non-word characters in provider name", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/foo": { + get: { operationId: "getFoo", responses: { "200": { description: "ok" } } }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "google/books"); + + expect(ops[0]?.name).toBe("google_books.getFoo"); + }); + + it("sets method to uppercase", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/foo": { + post: { operationId: "createFoo", responses: { "200": { description: "ok" } } }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "example"); + + expect(ops[0]?.method).toBe("POST"); + }); + + it("sets path to the OpenAPI path key", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/repos/{owner}/{repo}/issues": { + get: { operationId: "listIssues", responses: { "200": { description: "ok" } } }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "github"); + + expect(ops[0]?.path).toBe("/repos/{owner}/{repo}/issues"); + }); + + it("includes server base path in routeTemplate", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "OpenAI", version: "1.0.0" }, + servers: [{ url: "https://api.openai.com/v1" }], + paths: { + "/responses": { + post: { operationId: "createResponse", responses: { "200": { description: "ok" } } }, + }, + "/models": { + get: { operationId: "listModels", responses: { "200": { description: "ok" } } }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "openai"); + + expect(ops[0]?.routeTemplate).toBe("/v1/responses"); + expect(ops[1]?.routeTemplate).toBe("/v1/models"); + }); + + it("uses path as routeTemplate when there is no server base path", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + servers: [{ url: "https://api.example.com" }], + paths: { + "/items": { + get: { operationId: "listItems", responses: { "200": { description: "ok" } } }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "example"); + + expect(ops[0]?.routeTemplate).toBe("/items"); + }); + + it("uses path as routeTemplate when no servers are defined", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/things": { + get: { operationId: "listThings", responses: { "200": { description: "ok" } } }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "example"); + + expect(ops[0]?.routeTemplate).toBe("/things"); + }); + + it("prefers application/json content type from requestBody", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/upload": { + post: { + operationId: "uploadFile", + requestBody: { + content: { + "application/octet-stream": {}, + "application/json": {}, + }, + }, + responses: { "200": { description: "ok" } }, + }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "example"); + + expect(ops[0]?.contentType).toBe("application/json"); + }); + + it("falls back to first content type when no json type is present", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/upload": { + put: { + operationId: "uploadImage", + requestBody: { + content: { + "image/jpeg": {}, + }, + }, + responses: { "200": { description: "ok" } }, + }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "example"); + + expect(ops[0]?.contentType).toBe("image/jpeg"); + }); + + it("leaves contentType undefined when there is no requestBody", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/items": { + get: { operationId: "listItems", responses: { "200": { description: "ok" } } }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "example"); + + expect(ops[0]?.contentType).toBeUndefined(); + }); + + it("reads description from operation.summary", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/items": { + get: { + operationId: "listItems", + summary: "List all items", + description: "Returns a paginated list of items", + responses: { "200": { description: "ok" } }, + }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "example"); + + expect(ops[0]?.description).toBe("List all items"); + }); + + it("falls back to operation.description when summary is absent", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/items": { + get: { + operationId: "listItems", + description: "Returns a paginated list of items", + responses: { "200": { description: "ok" } }, + }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "example"); + + expect(ops[0]?.description).toBe("Returns a paginated list of items"); + }); + + it("leaves description undefined when neither summary nor description is present", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/items": { + get: { operationId: "listItems", responses: { "200": { description: "ok" } } }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "example"); + + expect(ops[0]?.description).toBeUndefined(); + }); + + it("populates tags from the operation", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/items": { + get: { + operationId: "listItems", + tags: ["Items", "Read"], + responses: { "200": { description: "ok" } }, + }, + }, + }, + }; + + const ops = discoverOperationsFromOpenApi(document, "example"); + + expect(ops[0]?.tags).toEqual(["Items", "Read"]); + }); + + it("returns empty array for documents without paths", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: {}, + }; + + expect(discoverOperationsFromOpenApi(document, "example")).toHaveLength(0); + }); + + it("handles Swagger 2.0 basePath for routeTemplate", () => { + const document = { + openapi: "3.0.0", + basePath: "/v2", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/pets": { + get: { operationId: "listPets", responses: { "200": { description: "ok" } } }, + }, + }, + } as unknown as OpenAPIV3.Document; + + const ops = discoverOperationsFromOpenApi(document, "petstore"); + + expect(ops[0]?.routeTemplate).toBe("/v2/pets"); + }); +}); diff --git a/packages/bundler/src/openapi-discovery.ts b/packages/bundler/src/openapi-discovery.ts new file mode 100644 index 0000000..b2df488 --- /dev/null +++ b/packages/bundler/src/openapi-discovery.ts @@ -0,0 +1,138 @@ +import type { OpenAPIV3 } from "openapi-types"; + +/** + * A single HTTP operation discovered from an OpenAPI document. + * + * This type replaces UTCP's `Tool` as the pointer passed to `buildClientToolMap`. + * It carries just enough information to locate the operation in the OpenAPI document + * and derive runtime metadata; all schema resolution still happens from the document. + */ +export type DiscoveredOperation = { + /** Full tool name including provider prefix, e.g. "provider_name.operationId" */ + name: string; + /** Uppercase HTTP method, e.g. "GET", "POST" */ + method: string; + /** OpenAPI path key, e.g. "/repos/{owner}/{repo}/issues" */ + path: string; + /** Route template for the runtime, e.g. "/v1/repos/{owner}/{repo}/issues" (includes server base path) */ + routeTemplate: string; + /** Preferred content type for the request body, if any */ + contentType?: string; + /** Human-readable description from operation.summary or operation.description */ + description?: string; + /** OpenAPI operation tags */ + tags: string[]; +}; + +const OPERATION_METHODS = ["get", "post", "put", "delete", "patch"] as const; +type OperationMethod = (typeof OPERATION_METHODS)[number]; + +/** + * Returns the path component of the first server URL that has a non-root path. + * Used to compute routeTemplate = serverBasePath + openApiPath. + * + * Example: servers[0].url = "https://api.openai.com/v1" → "/v1" + */ +function getServerBasePath(document: OpenAPIV3.Document): string { + const openApiV2BasePath = (document as { basePath?: unknown }).basePath; + + if (typeof openApiV2BasePath === "string" && openApiV2BasePath !== "/") { + return openApiV2BasePath.replace(/\/+$/, ""); + } + + for (const server of document.servers ?? []) { + if (typeof server.url !== "string") { + continue; + } + + try { + const sanitized = server.url.replace(/\{[^}]+\}/gu, "placeholder"); + const pathname = new URL(sanitized, "https://example.invalid").pathname; + + if (pathname && pathname !== "/") { + return pathname.replace(/\/+$/u, ""); + } + } catch { + continue; + } + } + + return ""; +} + +/** + * Returns the preferred content type for an operation's request body. + * Prefers application/json, then any JSON-compatible type, then the first available. + */ +function getOperationContentType(operation: OpenAPIV3.OperationObject): string | undefined { + const rb = operation.requestBody; + + if (!rb || typeof rb !== "object" || "$ref" in rb) { + return undefined; + } + + const content = (rb as OpenAPIV3.RequestBodyObject).content; + + if (!content) { + return undefined; + } + + const keys = Object.keys(content); + + return ( + keys.find((k) => k === "application/json") ?? + keys.find((k) => k.includes("json")) ?? + keys[0] + ); +} + +/** + * Enumerates all HTTP operations from an OpenAPI 3.x document without using UTCP. + * + * The caller is responsible for pre-processing the document with `addMissingOperationIds` + * before calling this function so that every operation has an `operationId`. + * Operations without an `operationId` are silently skipped. + * + * @param document - The OpenAPI document to walk. + * @param providerName - The registry provider name (used to build the `name` prefix). + */ +export function discoverOperationsFromOpenApi( + document: OpenAPIV3.Document, + providerName: string, +): DiscoveredOperation[] { + const manualName = providerName.replace(/[^\w]/gu, "_"); + const serverBasePath = getServerBasePath(document); + const operations: DiscoveredOperation[] = []; + + for (const [path, pathItem] of Object.entries(document.paths ?? {})) { + if (!pathItem) { + continue; + } + + for (const method of OPERATION_METHODS) { + const operation = pathItem[method as OperationMethod] as OpenAPIV3.OperationObject | undefined; + + if (!operation) { + continue; + } + + const operationId = operation.operationId; + + if (!operationId) { + continue; + } + + operations.push({ + name: `${manualName}.${operationId}`, + method: method.toUpperCase(), + path, + routeTemplate: serverBasePath ? `${serverBasePath}${path}` : path, + contentType: getOperationContentType(operation), + description: operation.summary ?? operation.description, + tags: operation.tags ?? [], + }); + } + } + + return operations; +} diff --git a/packages/bundler/src/openapi.test.ts b/packages/bundler/src/openapi.test.ts index 2099d83..4a9f7e3 100644 --- a/packages/bundler/src/openapi.test.ts +++ b/packages/bundler/src/openapi.test.ts @@ -1,29 +1,23 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { applyProviderOpenApiOptions, buildPublicTypeMap, loadOpenApiDocument } from "./openapi.js"; +import type { DiscoveredOperation } from "./openapi-discovery.js"; import type { RegistryProvider } from "./provider.js"; -import type { Tool } from "@utcp/sdk"; import type { OpenAPIV3 } from "openapi-types"; -function createTool(name: string, httpMethod: string, url: string): Tool { +function createDiscoveredOperation( + name: string, + method: string, + path: string, +): DiscoveredOperation { return { name, + method, + path, + routeTemplate: path, + contentType: "application/json", description: name, tags: [], - tool_call_template: { - call_template_type: "http", - http_method: httpMethod, - url, - content_type: "application/json", - }, - inputs: { - type: "object", - properties: {}, - }, - outputs: { - type: "object", - properties: {}, - }, - } as unknown as Tool; + }; } describe("loadOpenApiDocument", () => { @@ -81,8 +75,9 @@ describe("loadOpenApiDocument", () => { }); describe("buildPublicTypeMap", () => { - it("matches operations when the server url contributes a base path", () => { - const tool = createTool("openai.createResponse", "POST", "https://api.openai.com/v1/responses"); + it("derives output type from the OpenAPI response schema", () => { + // path = "/responses" matches directly in document.paths (server base handled at runtime) + const op = createDiscoveredOperation("openai.createResponse", "POST", "/responses"); const openApiDocument = { openapi: "3.0.0", info: { title: "OpenAI", version: "1.0.0" }, @@ -125,9 +120,9 @@ describe("buildPublicTypeMap", () => { }, } as const; - const typeMap = buildPublicTypeMap(openApiDocument as never, [tool]); + const typeMap = buildPublicTypeMap(openApiDocument as never, [op]); - expect(typeMap.get(tool.name)?.outputType).toBe("{ id: string }"); + expect(typeMap.get(op.name)?.outputType).toBe("{ id: string }"); }); }); diff --git a/packages/bundler/src/openapi.ts b/packages/bundler/src/openapi.ts index c49ba04..83e4998 100644 --- a/packages/bundler/src/openapi.ts +++ b/packages/bundler/src/openapi.ts @@ -1,11 +1,10 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { parse as parseYaml } from "yaml"; -import { getDocumentPathItem } from "./openapi-path.js"; import { resolveRepoPath } from "./provider.js"; import { schemaToTypeScriptType } from "./schema.js"; +import type { DiscoveredOperation } from "./openapi-discovery.js"; import type { RegistryProvider } from "./provider.js"; -import type { Tool } from "@utcp/sdk"; import type { OpenAPIV3 } from "openapi-types"; type PublicToolTypes = { @@ -72,9 +71,9 @@ function resolveSchema( const arraySchema = schema as OpenAPIV3.ArraySchemaObject; if (arraySchema.items) { - (resolved as any).items = Array.isArray(arraySchema.items) + (resolved as OpenAPIV3.ArraySchemaObject).items = (Array.isArray(arraySchema.items) ? arraySchema.items.map((item: OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject) => resolveSchema(document, item, seen) ?? { type: "object" }) - : (resolveSchema(document, arraySchema.items, seen) ?? { type: "object" }); + : (resolveSchema(document, arraySchema.items, seen) ?? { type: "object" })) as OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject; } if (schema.additionalProperties && typeof schema.additionalProperties === "object") { @@ -104,20 +103,10 @@ function resolveSchema( function getOperation( document: OpenAPIV3.Document, - tool: Tool, + op: DiscoveredOperation, ): OpenAPIV3.OperationObject | undefined { - const method = - tool.tool_call_template && typeof tool.tool_call_template.http_method === "string" - ? tool.tool_call_template.http_method.toLowerCase() - : undefined; - const rawUrl = - tool.tool_call_template && typeof tool.tool_call_template.url === "string" ? tool.tool_call_template.url : undefined; - - if (!method || !rawUrl) { - return undefined; - } - - const { pathItem } = getDocumentPathItem(document, rawUrl); + const method = op.method.toLowerCase(); + const pathItem = document.paths?.[op.path]; if (!pathItem || !(method in pathItem)) { return undefined; @@ -250,17 +239,17 @@ export function applyProviderOpenApiOptions( }; } -export function buildPublicTypeMap(document: OpenAPIV3.Document, tools: Tool[]): Map { +export function buildPublicTypeMap(document: OpenAPIV3.Document, operations: DiscoveredOperation[]): Map { return new Map( - tools.map((tool) => { - const operation = getOperation(document, tool); + operations.map((op) => { + const operation = getOperation(document, op); const responseSchema = getResponseSchema(document, operation); return [ - tool.name, + op.name, { - inputType: schemaToTypeScriptType(tool.inputs), - outputType: responseSchema ? schemaToTypeScriptType(responseSchema) : schemaToTypeScriptType(tool.outputs), + inputType: "{}", + outputType: responseSchema ? schemaToTypeScriptType(responseSchema) : "{ [key: string]: unknown }", }, ]; }), diff --git a/packages/bundler/src/render.test.ts b/packages/bundler/src/render.test.ts index a54b9c8..a0900cf 100644 --- a/packages/bundler/src/render.test.ts +++ b/packages/bundler/src/render.test.ts @@ -9,29 +9,19 @@ import { renderRootPackageJson, } from "./render.js"; import type { ClientToolDefinition } from "./client-api.js"; +import type { DiscoveredOperation } from "./openapi-discovery.js"; import type { RegistryProvider } from "./provider.js"; -import type { Tool } from "@utcp/sdk"; -function createTool(name: string): Tool { +function createOp(name: string): DiscoveredOperation { return { name, + method: "GET", + path: "/", + routeTemplate: "/", + contentType: "application/json", description: `Tool ${name}`, tags: [], - tool_call_template: { - call_template_type: "http", - http_method: "GET", - url: "https://example.com", - content_type: "application/json", - }, - inputs: { - type: "object", - properties: {}, - }, - outputs: { - type: "object", - properties: {}, - }, - } as unknown as Tool; + }; } function createClientToolDefinition(accessPath: string[]): ClientToolDefinition { @@ -61,7 +51,7 @@ describe("renderProviderTypes", () => { it("emits JSDoc comments and multi-line format for object input schemas with descriptions", () => { const rendered = renderProviderTypes( { name: "github" }, - [createTool("github.repos/get")], + [createOp("github.repos/get")], new Map(), new Map([ [ @@ -93,37 +83,29 @@ describe("renderProviderTypes", () => { expect(rendered).not.toContain("input: { owner: string"); }); - it("emits JSDoc comments and multi-line format for object output schemas with descriptions", () => { + it("renders output type from publicTypeMap", () => { const rendered = renderProviderTypes( { name: "github" }, - [ - { - ...createTool("github.users/get"), - outputs: { - type: "object", - properties: { - login: { type: "string", description: "The user's login handle." }, - id: { type: "number", description: "Unique identifier for the user." }, - }, - required: ["login", "id"], + [createOp("github.users/get")], + new Map([ + [ + "github.users/get", + { + inputType: "{}", + outputType: "{ login: string; id: number }", }, - }, - ], - new Map(), + ], + ]), new Map([["github.users/get", createClientToolDefinition(["users", "get"])]]), ); - expect(rendered).toContain("/** The user's login handle. */"); - expect(rendered).toContain("login: string;"); - expect(rendered).toContain("/** Unique identifier for the user. */"); - expect(rendered).toContain("id: number;"); - expect(rendered).toMatch(/Promise<\{\n/); + expect(rendered).toContain("Promise<{ login: string; id: number }>"); }); it("keeps compact inline format when inputSchema has no properties with descriptions", () => { const rendered = renderProviderTypes( { name: "example" }, - [createTool("example.ping")], + [createOp("example.ping")], new Map(), new Map([ [ @@ -143,7 +125,7 @@ describe("renderProviderTypes", () => { it("preserves pre-rendered types from publicTypeMap without expansion", () => { const rendered = renderProviderTypes( { name: "github" }, - [createTool("github.users/get-by-username")], + [createOp("github.users/get-by-username")], new Map([ [ "github.users/get-by-username", @@ -173,7 +155,7 @@ describe("renderProviderTypes", () => { it("preserves operationId word boundaries in accessor names", () => { const rendered = renderProviderTypes( { name: "datadog" }, - [createTool("datadog.AddMemberTeam")], + [createOp("datadog.AddMemberTeam")], new Map(), new Map([["datadog.AddMemberTeam", createClientToolDefinition(["addMemberTeam"])]]), ); @@ -186,7 +168,7 @@ describe("renderProviderTypes", () => { it("normalizes mixed naming styles consistently", () => { const rendered = renderProviderTypes( { name: "example" }, - [createTool("example.api_keys/ListAPIKeys"), createTool("example.events/list-events")], + [createOp("example.api_keys/ListAPIKeys"), createOp("example.events/list-events")], new Map(), new Map([ ["example.api_keys/ListAPIKeys", createClientToolDefinition(["apiKeys", "listApiKeys"])], @@ -203,7 +185,7 @@ describe("renderProviderTypes", () => { it("inlines method types and labels the second argument as options", () => { const rendered = renderProviderTypes( { name: "github" }, - [createTool("github.users/get-by-username")], + [createOp("github.users/get-by-username")], new Map([ [ "github.users/get-by-username", @@ -239,7 +221,7 @@ describe("renderProviderTypes", () => { it("omits the input argument for tools with no input or options", () => { const rendered = renderProviderTypes( { name: "example" }, - [createTool("example.get-queue")], + [createOp("example.get-queue")], new Map(), new Map([ [ @@ -260,7 +242,7 @@ describe("renderProviderTypes", () => { it("uses options as the only argument for input-less tools with overrides", () => { const rendered = renderProviderTypes( { name: "example" }, - [createTool("example.inspect")], + [createOp("example.inspect")], new Map(), new Map([ [ diff --git a/packages/bundler/src/render.ts b/packages/bundler/src/render.ts index 4de4c88..9bf4ca1 100644 --- a/packages/bundler/src/render.ts +++ b/packages/bundler/src/render.ts @@ -9,11 +9,11 @@ import { splitProviderName, stripProviderToolName, } from "./provider.js"; -import { escapeComment, quotePropertyName, schemaToObjectContent, schemaToTypeScriptType } from "./schema.js"; +import { escapeComment, quotePropertyName, schemaToObjectContent } from "./schema.js"; import type { ClientToolDefinition, ToolRuntimeMetadata } from "./client-api.js"; import type { ProviderPackageDocsMetadata } from "./docs/types.js"; +import type { DiscoveredOperation } from "./openapi-discovery.js"; import type { RegistryProvider } from "./provider.js"; -import type { Tool } from "@utcp/sdk"; import type { OpenAPIV3 } from "openapi-types"; type PublicToolTypes = { @@ -168,28 +168,27 @@ function toCamelCase(name: string): string { function toClientTools( provider: Pick, - tools: Tool[], + operations: DiscoveredOperation[], publicTypeMap: Map, clientToolMap: Map, ): ClientTool[] { - return tools.map((tool) => { - const rawToolName = stripProviderToolName(tool.name, provider); - const generatedMetadata = clientToolMap.get(tool.name); - const hasPublicType = publicTypeMap.has(tool.name); - const inputType = generatedMetadata?.inputType ?? publicTypeMap.get(tool.name)?.inputType ?? schemaToTypeScriptType(tool.inputs); + return operations.map((op) => { + const rawToolName = stripProviderToolName(op.name, provider); + const generatedMetadata = clientToolMap.get(op.name); + const inputType = generatedMetadata?.inputType ?? publicTypeMap.get(op.name)?.inputType ?? "{}"; return { accessPath: generatedMetadata?.accessPath ?? [sanitizeIdentifier(rawToolName)], - description: tool.description ?? "", + description: op.description ?? "", hasInput: generatedMetadata?.hasInput ?? inputType !== "{}", hasOptions: generatedMetadata?.hasOptions ?? false, - inputSchema: generatedMetadata?.inputSchema ?? (hasPublicType ? undefined : tool.inputs), + inputSchema: generatedMetadata?.inputSchema, inputType, optionsOptional: generatedMetadata?.optionsOptional ?? true, optionsType: generatedMetadata?.optionsType ?? "{}", - outputSchema: hasPublicType ? undefined : tool.outputs, - outputType: publicTypeMap.get(tool.name)?.outputType ?? schemaToTypeScriptType(tool.outputs), - tags: tool.tags ?? [], + outputSchema: undefined, + outputType: publicTypeMap.get(op.name)?.outputType ?? "{ [key: string]: unknown }", + tags: op.tags, }; }); } @@ -300,11 +299,11 @@ function renderProviderTypesFromClientTools( export function renderProviderTypes( provider: Pick, - tools: Tool[], + operations: DiscoveredOperation[], publicTypeMap: Map, clientToolMap: Map, ): string { - const clientTools = toClientTools(provider, tools, publicTypeMap, clientToolMap); + const clientTools = toClientTools(provider, operations, publicTypeMap, clientToolMap); return renderProviderTypesFromClientTools(provider, clientTools); } @@ -394,11 +393,11 @@ function renderGroupTypes( export function renderProviderGroupTypes( provider: Pick, openApiDocument: OpenAPIV3.Document, - tools: Tool[], + operations: DiscoveredOperation[], publicTypeMap: Map, clientToolMap: Map, ): Map { - const clientTools = toClientTools(provider, tools, publicTypeMap, clientToolMap); + const clientTools = toClientTools(provider, operations, publicTypeMap, clientToolMap); const groups = groupClientToolsByTag(clientTools, openApiDocument); const output = new Map(); @@ -415,11 +414,11 @@ export function renderProviderGroupTypes( export function renderProviderTypesIndex( provider: Pick, openApiDocument: OpenAPIV3.Document, - tools: Tool[], + operations: DiscoveredOperation[], publicTypeMap: Map, clientToolMap: Map, ): string { - const clientTools = toClientTools(provider, tools, publicTypeMap, clientToolMap); + const clientTools = toClientTools(provider, operations, publicTypeMap, clientToolMap); const groups = groupClientToolsByTag(clientTools, openApiDocument); const providerTypeName = toPascalCase(provider.name); diff --git a/packages/bundler/src/utcp.ts b/packages/bundler/src/utcp.ts index 1795887..2a2bb2c 100644 --- a/packages/bundler/src/utcp.ts +++ b/packages/bundler/src/utcp.ts @@ -1,6 +1,8 @@ import { OpenApiConverter } from "@utcp/http"; +import { getDocumentPathItem } from "./openapi-path.js"; import { loadOpenApiDocument } from "./openapi.js"; import { getPrimaryProviderAuthOption, type RegistryProvider } from "./provider.js"; +import type { DiscoveredOperation } from "./openapi-discovery.js"; import type { Auth, Tool } from "@utcp/sdk"; import type { OpenAPIV3 } from "openapi-types"; @@ -160,3 +162,53 @@ export async function loadProviderTools( })), }; } + +/** + * Converts UTCP `Tool[]` to `DiscoveredOperation[]` so that all downstream + * type-generation functions can accept a single unified type. + * + * UTCP tools store the full URL in `tool_call_template.url`. This function + * resolves that URL back to its OpenAPI path key using the document's path map. + */ +export function toolsToDiscoveredOperations( + tools: Tool[], + document: OpenAPIV3.Document, +): DiscoveredOperation[] { + return tools.flatMap((tool) => { + const method = + tool.tool_call_template && typeof tool.tool_call_template.http_method === "string" + ? tool.tool_call_template.http_method + : undefined; + const rawUrl = + tool.tool_call_template && typeof tool.tool_call_template.url === "string" + ? tool.tool_call_template.url + : undefined; + + if (!method || !rawUrl) { + return []; + } + + const { pathname } = getDocumentPathItem(document, rawUrl); + const contentType = + tool.tool_call_template && typeof tool.tool_call_template.content_type === "string" + ? tool.tool_call_template.content_type + : undefined; + + const pathItem = document.paths?.[pathname]; + const operation = pathItem?.[method.toLowerCase() as keyof OpenAPIV3.PathItemObject] as + | OpenAPIV3.OperationObject + | undefined; + + return [ + { + name: tool.name, + method: method.toUpperCase(), + path: pathname, + routeTemplate: rawUrl.replace(/^https?:\/\/[^/]+/u, ""), + contentType, + description: operation?.summary ?? operation?.description ?? tool.description, + tags: operation?.tags ?? (Array.isArray(tool.tags) ? (tool.tags as string[]) : []), + } satisfies DiscoveredOperation, + ]; + }); +}