Declare tools from an OpenAPI description - #35
Merged
Merged
Conversation
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
🤖 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
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.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
operationsis 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 asmcp.allowrefusing an empty list. Operations are picked byoperationId, which also becomes the tool name (one vocabulary for declarations, policies, and logs).Request URLs come from the connection's
base_url; the description'sserversare never read. Publishedserversentries carry variables and stale defaults (e.g.https://{subdomain}.example.comwith a default subdomain nobody owns) — following them would connect nowhere, or to someone else's tenant.validaterefuses a connection without a root; the client's host check still guards every request.paramskeeps 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:valuefixes 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 separatehidden:flag is folded into this: a fixed-but-visible parameter would just invite arguments that get silently overridden.)prefix/suffixwrap what the model writes, declared text first — atype:qualifier written by the model cannot displace the declared one.describereplaces vendor text;does_notrides 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 archivecarries 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. Remotespec:URLs are not supported in this PR.Read/write split. PUT, PATCH, and DELETE operations must sit in an
effect: writeblock (validate refuses them undereffect: 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_loadon YAML 1.1 leftovers (a bare=key typed astag: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 selectedoperationId(duplicated ids are refused as ambiguous); methods are GET/POST/PUT/PATCH/DELETE;params/describekeys name selected operations; a fix names a real parameter (with the real names listed when it does not);prefix/suffixonly 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.