Skip to content

Declare tools from an OpenAPI description - #35

Merged
haruotsu merged 6 commits into
mainfrom
openapi-tools
Aug 27, 2026
Merged

Declare tools from an OpenAPI description#35
haruotsu merged 6 commits into
mainfrom
openapi-tools

Conversation

@haruotsu

@haruotsu haruotsu commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Based on #33 (PUT/PATCH on ConnectionClient) — merge that first.

What

A new openapi: tool kind: a service that publishes an OpenAPI description but runs no MCP server can be declared without writing Python.

tools:
  - openapi:
      spec: ./specs/helpdesk.yaml
      connection: helpdesk
      operations: [ListSearchResults, ShowTicket, ListTicketComments]
      effect: read
      does_not: Results are the caller's own view; not found is not proof of absence.
      params:
        ListSearchResults:
          query: {prefix: "type:ticket "}
          per_page: {value: 25}
      describe:
        ListSearchResults: Search tickets. The kind is fixed to tickets.

ADK's spec parser produces the Gemini function declarations; execution stays in gete's ConnectionClient, so the destination check, the caller's token, redaction, and the never-resend rule hold exactly as for every other tool.

The decisions, and why

operations is required, never defaulted. Measured against a real published description (1.7 MB): the toolset builder happily turns all 443 paths into 640 tools in 0.4 s without failing, so nothing would ever alert an author who forgot to narrow. Forgetting to choose must not mean offering everything — same reasoning as mcp.allow refusing an empty list. Operations are picked by operationId, which also becomes the tool name (one vocabulary for declarations, policies, and logs).

Request URLs come from the connection's base_url; the description's servers are never read. Published servers entries carry variables and stale defaults (e.g. https://{subdomain}.example.com with a default subdomain nobody owns) — following them would connect nowhere, or to someone else's tenant. validate refuses a connection without a root; the client's host check still guards every request.

params keeps what replaced code used to enforce. The Python tools this replaces pinned things like a search-type prefix and a page size; a declaration that lost those pins would open a hole the code had closed:

  • value fixes a parameter and removes it from what the model sees — its value is declared, so there is nothing left for the model to say. (The draft's separate hidden: flag is folded into this: a fixed-but-visible parameter would just invite arguments that get silently overridden.)
  • prefix / suffix wrap what the model writes, declared text first — a type: qualifier written by the model cannot displace the declared one.

describe replaces vendor text; does_not rides along. Vendor descriptions are written for developers next to the docs and cite relative links a model cannot follow — leaving them in manufactures fake grounding.

The description is fixed at packing time. gete archive carries the file into the archive and the runtime reads it from there; a vendor editing the published file changes nothing until someone re-archives deliberately. Remote spec: URLs are not supported in this PR.

Read/write split. PUT, PATCH, and DELETE operations must sit in an effect: write block (validate refuses them under effect: read), ride the same confirmation policies, and are never resent. A DELETE with a request body is refused: the client sends none (RFC 9110 gives one no meaning), and the operation would lose arguments silently.

Legibility of published files. Real published descriptions fail yaml.safe_load on YAML 1.1 leftovers (a bare = key typed as tag:yaml.org,2002:value); the spec loader accepts that tag, and anything else unparsable is reported as "this description cannot be read" with the file named.

What validate checks

Connection declared by the agent and rooted (base_url); the spec file exists, parses, and actually holds every selected operationId (duplicated ids are refused as ambiguous); methods are GET/POST/PUT/PATCH/DELETE; params/describe keys name selected operations; a fix names a real parameter (with the real names listed when it does not); prefix/suffix only on string parameters; required header parameters, non-JSON bodies, and GET-with-body are refused.

At run time: nothing is offered without a usable token (as with MCP), the connection joins the shared reauthorization tool, path parameters are URL-escaped so a value cannot climb out of its segment, and results flow through the policies' redaction.

Not declared on purpose

Response shaping — count annotations, turning "not found" into a soft answer — stays out of the declaration. The raw response carries what the model needs, and anything more involved is what python: tools are for.

A service that publishes an OpenAPI description but runs no MCP server
could only be reached by writing Python. An openapi: block now turns
selected operations into tools, with the constraints such code used to
enforce surviving the move into declaration:

- operations is required, never defaulted. A published description
  holds hundreds of operations, the toolset builder does not fall over
  on any of them, and forgetting to choose must not mean offering
  everything to the model.
- Request URLs are built from the connection's base_url; the
  description's servers are never read. Published roots carry
  variables, stale defaults, or another tenant, and the client's
  destination check must keep holding.
- params keeps pinned arguments pinned: value fixes a parameter and
  takes it out of the model's declaration entirely, prefix/suffix wrap
  what the model writes with the declared text in front, so the
  model's own qualifiers cannot displace a declared scope.
- describe replaces vendor text written for developers next to the
  docs, and does_not rides on every tool as with mcp:.
- The description is read at packing time and travels in the archive,
  so a vendor editing their published file changes nothing until
  someone re-archives deliberately.
- PUT and PATCH operations must sit in an effect: write block, ride
  the same confirmation and redaction paths, and are never resent.
  DELETE cannot be declared; the client offers no delete verb.

Declarations are parsed into Gemini function declarations by ADK's
spec parser; execution stays in gete's ConnectionClient so the token
and destination rules are the same as for every other request.

🤖 Generated with Claude Code
The client offers DELETE now, so refusing it here would only push a
deleting tool off the guarded path. A DELETE operation always changes
state, so like PUT and PATCH it must sit in a block declared
effect: write, where the confirmation policies see it. One with a
request body is refused: the client sends none (RFC 9110 gives it no
meaning), and the operation would lose arguments silently.

🤖 Generated with Claude Code
The parser only expands a body's properties into arguments when the
schema spells type: object, and published descriptions often leave the
type implicit or compose the body with allOf. Everything else fell to a
single opaque body argument, and the request then carried the payload
wrapped under a key the service never declared - silently, since
validate was satisfied with any body that did not declare a non-object
type.

The description handed to the parser now carries each selected
operation with its parameters resolved and its body schema folded and
typed, so the properties always expand. allOf is folded one level, as
_resolved_body's docstring already promised without the code keeping
the promise. A body that still shows no properties is refused outright:
there is nothing to offer the model, and nothing the rules could hold a
fix against.

A fix naming both a request parameter and a body property is refused
for the same reason: the runtime applies fixes by name, and one name in
two places would fix both.

🤖 Generated with Claude Code
The client sends no absent values, so a params entry fixing a value to
null would take the parameter away from the model and then send nothing
at all - a constraint that reads as a pin but acts as a deletion. The
schema now refuses null where a fixed value is required.

🤖 Generated with Claude Code
Base automatically changed from connection-put-patch to main August 27, 2026 12:25
@haruotsu
haruotsu merged commit 4cbfafe into main Aug 27, 2026
3 checks passed
@haruotsu
haruotsu deleted the openapi-tools branch August 27, 2026 12:30
@github-actions github-actions Bot mentioned this pull request Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant