Georgii EmelianovEngineering

Cutting an OpenAPI spec down to what an assistant may touch

The frameworks all start from "expose everything, then trim." Start from the other end and the awkward conversation with your security reviewer becomes a config file they can read.

Torn white, cool grey-violet, and black paper framing the headline "Cutting an OpenAPI spec down to what an assistant may touch".

The spec on the table describes 173 operations. Sixty-eight of them are GETs; the rest create, update and delete things, including several that move money. Someone in the room asks which of them the assistant can call, and the honest answer decides whether this project gets approval or gets a follow-up meeting.

One version of that answer is a config file. The other is a paragraph of reassurance. The difference is whether you built an OpenAPI GET-only LLM tools allowlist as a policy with a closed default, or took the ecosystem's default and trimmed backwards. If your OpenAPI spec is the integration, then the allowlist is the integration's security boundary, and it is worth building in the direction that makes the review short.

The short version

  1. Start closed. Nothing in the spec is available until someone names it.
  2. The allowlist is a list of operationId values in a config file, reviewed like code.
  3. Classify each allowlisted operation by verb: GET and HEAD are readable, everything else is disabled.
  4. Enforce that twice — once when the tool list is built, once at the point of execution — in two places that do not depend on each other.
  5. Allow a per-operation override, require it to be written down, and make the diff obvious. The exception you cannot see is the one that hurts you.

Why "filter the spec" is the wrong frame

Most of what is written about this treats verb policy as tidying: your spec is large, the model gets confused by 173 tools, so cut it down. Real problem, real benefit, wrong default.

Look at what the most-used converters actually do. FastMCP, the canonical OpenAPI-to-MCP path, states it plainly: "By default, FastMCP converts every endpoint in your OpenAPI specification into an MCP Tool," described as "a simple, predictable starting point." POST and DELETE included. Filtering is available and good — ordered RouteMap objects, an EXCLUDE type — but it is opt-in, so the failure mode of forgetting to configure it is that everything is exposed. Checked 21 August 2026.

Google's Agent Development Kit is the same shape. Its OpenAPI toolset "identifies all valid API operations (e.g. GET, POST, PUT, DELETE) defined within the paths object," and the documentation describes no method filter or allowlist at all. Checked 21 August 2026.

There are also build-time trimmers such as openapi-filter, which take a spec and emit a smaller spec — genuinely useful, and a different thing from what this article is about. A trimmed file is an artifact somebody generated. A policy is enforced every time a turn runs, and it is the second one your reviewer is asking about.

A default of "everything, minus what you remembered to remove" is a default that fails open. Every other security control you own is built the other way round.

Step one: the allowlist is a list of operation ids

Not a pattern, not a tag filter, not a regex over paths. A literal list of operationId strings that a person wrote down.

{
  "id": "vaulted",
  "type": "openapi",
  "spec": "./openapi.json",
  "include": [
    "getListings", "getListingsById", "getListingsByIdAvailability",
    "getRentals", "getRentalsById", "getConversationsInbox", "getUsersMe"
  ]
}

An operation absent from include has no tool definition generated for it. That is a stronger property than it looks: there is no description of it in the prompt, no name for the model to guess, and therefore no phrasing, no jailbreak and no reasoning failure that reaches it. It is not refused — it does not exist.

The ratio is the thing to expect. On the shipped example above, the spec describes 173 operations and the integration names 17. A tenth is normal, because the operations that answer real customer questions are a small, obvious subset and everything else is machinery.

Patterns feel more maintainable and are worse here. ^get.* looks equivalent to a hand-written list until someone adds getUserPasswordResetToken, at which point your allowlist silently grew. Enumerate.

Step two: the OpenAPI GET-only LLM tools allowlist starts closed

Once an operation is on the list, its verb decides what the model may do with it. This is one function, and it should be boring:

function defaultSafety(method: ManifestTool["method"]): ToolSafety {
  return method === "GET" || method === "HEAD" ? "read" : "disabled";
}

Two properties matter more than the code.

It applies before anyone reviews anything. Every operation in every spec gets a classification whether or not a human thought about it. Adding an endpoint to the allowlist by mistake gets you a disabled tool, not a live one.

The default is disabled, not write. There is no third state where a mutating operation is available but marked risky. It either resolves to read or it is off, which means there is nothing to reason about at runtime and nothing to configure to make the safe path work.

The failure mode you are engineering for is the one where somebody is tired. A closed default is what makes tiredness cheap.

Step three: enforce it twice, in places that do not know about each other

One check is a bug away from zero checks. Two independent checks fail independently.

At tool-list assembly. When the agent builds the definitions for a turn, it keeps only those whose safety is read. A disabled operation is never described to the model — it is not in the tool list, so the model is not told it exists and cannot be persuaded to want it. If nothing survives the filter, the turn errors rather than proceeding with an empty tool set.

At execution. Independently, the executor refuses to invoke a definition classified disabled and returns a structured refusal:

{ "ok": false, "error": "operation_disabled" }

That second check should be unreachable. Build it anyway. The case it covers is not "the model got clever" — it is "someone changed how the tool list is assembled and did not notice." The same instinct produces a client that re-validates what the server already validated.

The override, and why hiding it would be dishonest

There is a per-operation override, and any article that leaves it out is selling rather than explaining.

{
  "policies": { "postRentalsQuote": "read" }
}

That is from a shipped integration. It promotes a single POST to read, because the operation computes a quote and returns it without persisting anything — which is a GET wearing the wrong verb, a common and forgivable API-design outcome.

So the accurate claim is not that writes are unreachable. It is narrower: an operation reaches the model only if a human both allowlists it and classifies it read. Two deliberate decisions, both in a config file, both visible in a diff, both reviewable by someone who never opens the application code.

The risk it creates is drift, and it is worth naming as the main one. Six months in, policies has four entries, two of the people who added them have moved teams, and nobody re-reads a config file that has not broken. The mitigations are unglamorous:

  • Keep the overrides in one block, not scattered as per-operation flags. A four-line policies object is something a reviewer can read in ten seconds.
  • Require the reason in the pull request, not in a comment that will outlive its accuracy.
  • Re-read the block on a schedule, at the same cadence you re-read IAM policies — which is to say, on a calendar rather than on an incident.
  • Treat a promotion as a security change. It is one. The mechanical difficulty is nil, which is exactly why the process has to supply the friction.

What this still does not protect you from

GETs that are not read-only. A verb is a convention, not a guarantee. GET /session/logout exists in more APIs than anyone would like, and a classification derived from the method will call it readable. The allowlist review is where this gets caught, and it is a genuine reason not to automate the list.

Reading things that should not be read. Read access is still access. An allowlisted getUsersById that accepts any id is a data exposure problem the safety classification has no opinion about; scoping belongs to your existing auth, and the assistant should be running as a principal with the same limits as the customer whose question it is answering.

Prompt injection through the data itself. Content returned by an allowlisted read can carry instructions. That is a separate control surface, and the useful defence is on the output side — what the model is structurally unable to emit matters more here than what it can be told.

A spec that is not true. Every mechanism above reads the document. If the document and the live API disagree, your policy describes an API that does not exist. That is the prerequisite to the whole approach and the most common reason a team is not ready.

How to audit yours in ten minutes

  1. Print the tool list your assistant is actually given this turn, not the one you configured. If those two differ, stop and find out why before reading further.
  2. Group it by HTTP method. Anything that is not GET or HEAD needs a name, an owner and a sentence.
  3. Read the override block out loud. If it takes more than thirty seconds, it is too long. If you cannot say why an entry is there, that is the finding.
  4. Pick three allowlisted GETs and ask whether they mutate. Logout, token refresh, anything with a side effect in a name that sounds passive.
  5. Delete an operation id from the spec and restart. A missing allowlisted operation should be a startup failure — the implementation described here throws OpenAPI operations not found: … — not a silently shorter tool list. Silence here is how a config quietly stops matching reality.

Frequently asked questions

How do you expose only GET endpoints to an LLM?

Classify by method and default the rest to disabled, rather than enumerating what to block. GET and HEAD become readable; POST, PUT, PATCH and DELETE produce no tool definition, so the model is never told those operations exist. Then check it a second time at execution, so a call arriving by another path is refused rather than run.

Is a read-only allowlist enough security for an AI assistant?

No, and it is not meant to be. It bounds what the assistant can invoke; it says nothing about whether the data those reads return should be visible to this user, or about instructions embedded in that data. Scope still belongs to your existing authorization, and output still needs its own contract.

Should I filter the OpenAPI spec file itself?

You can, and tools exist for it, but a trimmed file is an artifact somebody has to regenerate. A runtime allowlist is checked on every turn and lives in the config a reviewer already reads. Doing both is reasonable; doing only the file version means your policy is as current as the last time someone ran the script.

What happens if an allowlisted operation disappears from the spec?

It should be a hard failure at startup. An allowlist entry naming an operation the document no longer contains means the config and the API have diverged, and continuing with a quietly shorter tool list turns that into a behaviour change nobody sees. Fail, name the missing ids, and make someone look.

Where to start

Open the config that decides your assistant's tool list and answer three questions.

  1. If someone adds an endpoint tomorrow, is it available by default? If yes, that is the change worth making first, and it is smaller than any of the others.
  2. How many places enforce the verb rule? One is common and is a single refactor away from none.
  3. Where are the exceptions, and can a reviewer find them without you? If the answer involves explaining, the exceptions are in the wrong shape.

The policy argument behind all of this — why mutating verbs stay disabled by default rather than gated behind a confirmation — is the part worth reading next. If you would rather look at a shipped example than a description of one, the integration format is a single JSON file and we will happily walk through it against your spec: hello@uzori.ai.

← All posts