Skip to content

Latest commit

 

History

History
187 lines (153 loc) · 10.1 KB

openapi-tools.md

File metadata and controls

187 lines (153 loc) · 10.1 KB

OpenAPI tools

Most services publish an OpenAPI document and no MCP server. openApiTools() turns that document into one typed tool per API operation, so an agent can call the API without a hand-written defineTool() per endpoint and without the raw http tool. Operations that change data ask for approval by default.

openApiTools is exported from @lousho/build-ai-agent and @lousho/build-ai-agent/tools. It needs no extra package. The vendor/ model prefix chooses the provider; with OpenRouter use openrouter/<vendor>/<model> (e.g. openrouter/openai/gpt-4o-mini).

Quick start

import { createAgent, openApiTools } from '@lousho/build-ai-agent';

const tools = await openApiTools('https://api.example.com/openapi.json', {
  include: ['getOrder', 'refundOrder'],
  bearerToken: () => process.env.ORDERS_API_TOKEN ?? '',
});

const agent = createAgent({ model: 'openai/gpt-4o-mini', tools });
const paused = await agent.send('Refund order 1042');
// getOrder ran. refundOrder (a POST) is waiting for approval:
// agent.approvals.resolve({ id: paused.approvalId!, approved: true })

openApiTools() is always async, because the document may be a URL. It returns an array of tools you pass to createAgent({ tools }) like any other.

document can be a parsed OpenAPI 3.0 or 3.1 object, a JSON or YAML string, or an https URL (a string or a URL). Parsing happens once, inside openApiTools(); a tool's execute only builds and sends a request.

What each tool looks like

Part Value
Name The operationId, with characters outside A-Za-z0-9_- replaced by _. Without an operationId: <method>_<path> (GET /orders/{orderId} becomes get_orders_orderId). With prefix: <prefix>__<name>. At most 64 characters. Two operations with the same name throw a ConfigurationError that lists both.
Description The operation's summary, then its description (cut at 1,000 characters), then the method and path (GET /orders/{orderId}).
Input An object with every path, query, header and cookie parameter as a top-level property (required as the document declares), plus body for an application/json request body. Schemas follow $refs into components, including parameters and requestBodies. OpenAPI 3.0 nullable works. A parameter whose name is already taken by another location is named <location>_<name>.
Result { status, statusText, body } for every HTTP response, error statuses too, so the model can read a 404 and react. body is parsed JSON when the response says JSON, otherwise text, empty as null.
Errors A network failure, a timeout and a refused redirect are tool errors.

Accept, Content-Type and Authorization header parameters are ignored, as the OpenAPI specification says; use bearerToken or headers for credentials.

Options

Option Default Meaning
baseUrl first servers entry Where requests go. Overrides the document. For a document fetched from a URL, a missing servers entry means that URL's origin.
include all operations Operation names (operationId or derived name), or a function (op) => boolean. Naming an operation the document does not have throws.
exclude none Operation names to leave out.
approval 'mutating' 'mutating', 'always', 'never', or (op) => boolean.
prefix none Prefix for tool names.
headers none Headers sent on every request, or a function called per request.
bearerToken none Shorthand for Authorization: Bearer <token>; a function is called per request.
providedArguments none Values your application supplies; see below.
timeoutMs 30000 Per request.
maxResponseChars 50000 The response body is cut at this length, with a note saying how much was left out.
fetch global fetch Replaces fetch (tests, proxies). Cannot be combined with privateAddresses: 'refuse'.
privateAddresses 'allow' 'refuse' makes requests and the document fetch fail for loopback, link-local and private addresses; see Security rules. Node only.
allowPrivate none With 'refuse': hosts (intranet.example, *.corp.example, an IP address) that may be or resolve to private addresses.

op is { name, operationId?, method, path, summary?, tags }.

Approval defaults

With approval: 'mutating', GET, HEAD and OPTIONS run, and every other method asks. Each tool also carries MCP-style annotations: readOnlyHint: true, destructiveHint: false on GET, HEAD and OPTIONS; readOnlyHint: false on the rest, with destructiveHint: true for DELETE. Anything that reads annotations treats these tools the way it treats MCP tools.

import { openApiTools } from '@lousho/build-ai-agent';

declare const spec: object;

// Ask for everything except two reads that are known to be harmless.
const tools = await openApiTools(spec, {
  approval: (op) => !['getOrder', 'listOrders'].includes(op.name),
});

Values your application supplies

providedArguments fills parameters the model should not choose, such as a tenant id. A provided key is removed from the tool's input schema (the model never sees it) and added to the request. The value can be a function, called per request with the operation and the tool call id.

import { openApiTools } from '@lousho/build-ai-agent';

declare const spec: object;
declare const currentTenant: () => string;

const tools = await openApiTools(spec, {
  providedArguments: {
    'X-Tenant-Id': () => currentTenant(),
    accountId: 'acct_123',
  },
});

The key is the parameter's input key: its name, or <in>_<name> (e.g. query_id) when two parameters of an operation share a name; the request body's key is body (requestBody when a parameter already took body). A key that no selected operation has throws, so a typo does not silently expose the parameter to the model.

Security rules

  • The base URL is yours, not the model's. The model chooses parameter and body values; it never chooses the host. The base URL must be https. Plain http is accepted only for localhost, 127.0.0.1 and [::1], and a URL with a user name or password is refused. The same rule applies to a document URL.
  • Credentials stay out of what the model and your logs see. Headers and tokens are never part of a tool's input schema, its tool.start and tool.done events or the transcript; only the model's own input and the response are. A header the model supplies for a declared header parameter never replaces headers or bearerToken.
  • Redirects stay on the base origin. Requests use manual redirects: at most 3 are followed, and only to the same origin as the base URL. A redirect to another origin is a tool error, so a token never leaves the configured origin.
  • Path values stay in their segment. Path parameters are percent-encoded (../admin cannot climb out of its segment), and a value of exactly . or .. is refused.
  • Private addresses: off by default, privateAddresses: 'refuse' to check. By default the tools call the base URL you configured, wherever it resolves: base URLs are yours and often internal. When the base URL or the document URL is not fully under your control (a name someone else's DNS answers, a document you fetch from elsewhere), pass privateAddresses: 'refuse'. Then every operation request and the document fetch, each redirect hop included, is refused when its host is or resolves to a loopback, link-local, private, shared, multicast or reserved address (the list http_request uses, NAT64 and 6to4 included). Host names are checked with the same pinned DNS lookup as http_request and web_fetch: the host is resolved once per connection, every address is checked, and the socket connects to the address that was checked, so a DNS-rebinding name (public to the check, 127.0.0.1 to the connection) never reaches a private address. IP literals in any form URL accepts (2130706433, 0x7f000001, 0177.0.0.1, [::ffff:127.0.0.1]) and localhost / *.localhost names are refused when the tools are made, as a ConfigurationError. The model cannot move a request to another host: path values are percent-encoded into their segment, server URL variables take only the document's defaults, and redirects stay on the base origin. List your own internal hosts in allowPrivate (allowPrivate: ['localhost'] for a local API). A refused request is a tool error that names the host, never the addresses it resolved to. 'refuse' cannot be combined with fetch: a custom fetch resolves names itself, so the check could not pin the address it connects to, and making the tools throws instead of checking only half. With your own fetch (or a proxy), check destinations there. 'refuse' needs Node.js (undici and node:dns); on a runtime without them, making the tools throws a ConfigurationError rather than send unchecked requests. Cloudflare Worker builds do not offer openApiTools.

Large APIs

An API with hundreds of operations is hundreds of tools. Give the agent the ones it needs with include or exclude, or with a predicate on op.tags:

import { openApiTools } from '@lousho/build-ai-agent';

declare const spec: object;

const tools = await openApiTools(spec, { include: (op) => op.tags.includes('orders') });

Limits

  • OpenAPI 3.0 and 3.1 only. A Swagger 2.0 document throws a ConfigurationError that asks you to convert it first.
  • Request bodies must be JSON (application/json or +json). An operation with only a multipart, form or XML body is left out; naming it in include throws.
  • Only the first servers entry is used, with variables at their defaults. Path-level and operation-level servers are ignored.
  • Every $ref must be local (#/components/...); a reference to another file or URL throws. Bundle the document first.
  • No OAuth flows. bearerToken and headers cover static and per-request credentials.
  • Response bodies are read as text, and responses are not streamed.