BlogEngineering

ENGINEERING

Read-only by construction: making a dangerous tool unrepresentable

A mutating tool is not blocked on this MCP surface. It cannot be produced. The derivation still ends in a hand-typed array, and the header does not say so.

SAGARISEngineering8 min
Read-only by construction: making a dangerous tool unrepresentable

One line decides whether an AI assistant connected to this system can change anything in it. It filters the census of public API operations down to those whose method equals a single constant, and that constant's value is "get".

Every REST tool offered over MCP is produced by mapping over the result of that filter. There is no branch below it that adds a tool for any other reason.

The consequence is worth stating precisely, because the precise version is the interesting one. A mutating tool on this surface is not forbidden. It is not blocked by a check that runs at call time. It is not caught by a reviewer noticing it in a diff. It is unrepresentable: there is no input to the derivation that causes one to be emitted, because the only thing that becomes a tool is a member of a set that was filtered on method.

Allowlists describe. Derivations produce.

The usual way to build this is a hand-written list of permitted tools. It works, right up until it does not, and the way it stops working is always the same: somebody adds a capability somewhere else, and the list is not updated, or is updated by copying a nearby line.

The failure is silent in both directions. Forget to add a safe tool and a feature is quietly missing. Add a dangerous one by pattern-matching on the line above it and you have shipped a write path to an external agent with a diff that looks like configuration.

The reason an allowlist cannot defend against that is structural, not cultural. An allowlist is a statement about a set. It sits in one file, the thing it describes lives in another, and nothing binds them. So the statement can be wrong without anything failing.

A derivation inverts the dependency. The list is not a description of the surface; the list is computed from the surface. Adding a write route to the public API adds a POST entry to the census, the filter drops it, and nothing appears as a tool. Not because anyone remembered. Because the filter reads the method field and POST is not GET.

The floors, which are the part people leave out

A derivation has a failure mode an allowlist does not: it can compute an empty set and report success. Zero tools is a perfectly clean-looking answer that means the derivation is broken.

So there are three floors in that module, and they all throw rather than return.

If the filter produces zero read operations, the function throws with a message saying an empty read surface is an error and not a result. If the Brain view registry is empty, same. And the list of Brain subject types the surface may read is checked at module import: if it computes to empty, the module throws on load, with the reasoning written in the comment above it, that an empty readable set would advertise an empty schema and reject every call while looking like a configuration choice.

There are three more throws inside the mapping itself. If an operation is in the census but missing from the specification document, it throws. If it has no operation identifier, it throws. If it declares no recognised API key scope, it throws. The doc comment says the design rule out loud: fail loud, never silently narrow.

That is the whole discipline in one phrase. A surface that silently gets smaller looks identical to a surface that was always that size.

The compiler as the second half of the gate

The Brain half of the tool list is not filtered, because there is nothing to filter it on. It is bound instead.

The three Brain read tools are declared as an exhaustive Record over the claim store's closed union of view names. That makes the compiler enforce both directions: adding a new read view to the store breaks the build until somebody deliberately writes a description for it, and removing a view breaks the build until it is dropped here. Exposing an internal read view to an external agent stops being a default and becomes an edit somebody has to make on purpose.

The best beat in the module is what happened when that control actually fired. Two new subject types were added to the store for an internal handoff feature, and the compiler failed the tool surface, exactly as designed. The comment records the temptation and refuses it: the lazy repair was to set the new entry to true, and doing that would have widened an external, key-authenticated API surface to internal delivery obligations as a silent side effect of an internal type growing.

So the map now carries decisions with reasons rather than booleans. Commitments and requirements are false because they are what staff promised a customer, and the internal ledger is their audience. Competitor intelligence is false with a longer reason: it is machine-derived from public pages and is only safe to read next to its source and its age, and an external client cannot be obliged to render either. The comment ends by saying it is false on purpose and not pending wiring, which is a distinction most codebases never bother to write down.

One more detail in the same module is a genuine language trap, and it is worth carrying away even if you never touch MCP. The runtime permission check is a membership test against the derived array, not a property test against the map, because in JavaScript "commitment" in obj is true whatever the value is. A gate written the obvious way would have permitted every subject type in the map, including the three deliberately set to false.

Now the crack, because an article about unrepresentable bad states cannot hide its own

The derivation chain is: route files, to a census of operations, to an OpenAPI document, to a GET filter, to a tool list. Four of those five links are computed, checked, or compiler-enforced.

The first link is not. The operation census is a hand-typed array of nine path and method pairs, sitting in the public API specification module. A person wrote those nine lines, and a person has to add the tenth.

Worse, the prose around it overclaims. The specification module's header describes the specification as derived by reading the actual request and response shapes of the route files. The tool surface module's header describes the drift-guard test as binding the operation objects to the real route files. Both sentences read as though something walks the filesystem.

Nothing does. Grepping for readdirSync across both of those directories at this commit returns no files at all, while the same grep form over the whole source tree returns five files elsewhere, so the search itself works. The drift guard binds the specification document to the hand-typed array in both directions, which is real and useful, and it never reads a directory. The array and the route tree can diverge with nothing to catch it.

Here is the precise cost, and it is smaller than the overclaim but larger than nothing.

The read-only property survives. The filter operates on the method field of whatever is in the census, so no entry anybody adds to that array can produce a mutating tool unless they type get next to a path that is not a GET, which is a different and much louder kind of mistake.

Completeness does not survive. A new route added under the public API without a matching census entry is invisible to the OpenAPI document and to the tool derivation. In the safe direction that means a read capability quietly missing. It also means the published documentation can describe a surface smaller than the one that exists, and nothing fails.

The repair has a known shape, and it is the same one the codebase applies elsewhere: derive the population from the filesystem, assert the derived set equals the typed array, and require every deliberate exclusion to carry a written reason. Until that lands, the accurate sentence is that the tool surface is read-only by construction and complete by hand, and the two module headers should be corrected to say so.

The honest bound

Five tools. Two of them derived from the two GET operations in a nine-operation public API, one listing contacts and one listing sequences. Three of them Brain read views. All annotated as read-only. The OpenAPI document itself is served unauthenticated, because it is documentation, so the operation list is checkable from outside the source tree.

Stated carelessly, five tools reads as an unfinished integration. Stated deliberately, it is the point: the surface an external model can reach is small, it is enumerable, and every addition to it costs somebody a compile error and a written justification.

The question worth asking any vendor offering an MCP or agent integration is not whether their tools are read-only today. It is what would have to happen for a write tool to appear on that surface: an edit to a list, or a change to a filter that runs against everything the product exposes.

SAGARIS

Written by the SAGARIS team.

See the engine run on your pipeline.

Thirty minutes, your own data, no setup.

Book a demo

Get the next one in your inbox.

SAGARIS opens fully in October 2026. Join the waitlist and we will be in touch before launch.

We use these details to contact you about SAGARIS. See our privacy policy.

Book a demo