Disabled by default: giving an assistant read access without write access
The interesting part is not the operations the model never sees. It is the two-step human decision that put an operation on the read list at all, and the override that can undo it.

Security review will ask your assistant integration exactly one question, and it will not be about the model. It will be: what can it actually call? Read-only LLM tool access is the answer that gets the project through that meeting — and it is the answer most teams cannot state precisely, because the control lives in a system prompt and a hope.
Nearly everything written about this assumes an agent with a human at the keyboard: a coding agent, an ops agent over cloud resources, an MCP client on a laptop. Those designs fall back on an approval prompt when the stakes rise. An assistant answering a paying customer's question inside your app has no such fallback. Nobody is going to approve a tool call at 2am on behalf of a stranger checking why their bill went up.
So the control has to be structural, and it has to hold in more than one place. What follows is how the boundary is actually built when the caller is a customer — the same shape that governs the rest of what a generated screen is structurally unable to do.
The short version
Read-only LLM tool access means the model can only invoke operations a human has classified as reads, and that classification is enforced both where the tool list is built and again where the call executes. Mutating HTTP verbs are disabled by default. Nothing in a system prompt is load-bearing.
Five steps, in the order they run:
- Allowlist operations explicitly. An operation that is not named in the integration config never becomes a tool at all.
- Derive a safety class from the HTTP verb.
GETandHEADbecomeread; every other verb becomesdisabled. - Build the model's tool list by filtering on that class. A disabled operation is never serialized into the request, so it does not exist in context.
- Re-check the class at execution. The executor refuses a disabled operation regardless of who asked for it.
- Make any exception a reviewable diff. Promoting an operation is a config change a second engineer reads, not a runtime decision.
Why hiding the write tools is not the control you think it is
The version most teams build first is a system prompt: you have read-only access; never modify data. That is a preference, not a boundary. It shares a channel with untrusted input — the customer's own text, and whatever your API returned — and it fails silently, because a model that ignores the instruction produces a perfectly well-formed tool call.
The second version is better and still incomplete: filter the tool list at request time so write tools are not advertised. That is genuinely useful — the model cannot pick what it was never shown — but it is one layer, and it is the layer most likely to be bypassed by a code path added six months later. Something else in your stack will eventually call the executor directly.
Vendor documentation is unusually honest about the seam. Google Cloud's guidance on preventing read-write MCP tool use describes an IAM deny policy keyed on a tool.isReadOnly attribute. The policy blocks the call, and the same page states that read-write tools still appear in tools/list results anyway (checked 6 August 2026). Enforcement without enumeration. The inverse mistake is more common: enumeration without enforcement.
A filtered list makes the wrong call unlikely. A refusal at execution makes it impossible. You want both, because they fail in different directions.
The third control the literature leans on is approval. As the author of Why I only build read-only MCP servers put it in April 2026, "read-only won't make an agent trustworthy. But a read-only tool can't act on a bad guess" — and the same post argues approval fatigue erodes human-in-the-loop review until it is a reflex. In a customer-facing assistant that control is not weakened. It is absent.
Gate one: allowlist operations, not the spec
Your OpenAPI document is not the integration surface. It is the menu the integration surface is chosen from. The config names operations by operationId, and the compiler skips everything else before a tool object is constructed:
{
"id": "rental-app",
"type": "openapi",
"spec": "./openapi.json",
"include": [
"getOutfits",
"getOutfitById",
"getOrders",
"getOrdersByIdTimeline",
"getMe",
"postOrdersQuote"
],
"policies": {
"postOrdersQuote": "read"
}
}Two properties of this that matter more than they look. First, adding an endpoint to your API does not widen what the assistant can reach; the surface only grows when someone edits this file. Second, an operationId listed here that does not exist in the spec is a hard error when the integration loads, not a 404 in production. Spec drift fails at boot.
This is where you allowlist LLM tools from OpenAPI, and it is worth being ruthless. Every operation on the list is a paragraph of description that goes into the model's context on every turn. A short list is a safety property and a cost property at the same time.
Gate two: the default classification comes from the verb
Each allowlisted operation gets a safety class. The default is derived, not declared:
export type ToolSafety = "read" | "disabled";
function defaultSafety(method: ManifestTool["method"]): ToolSafety {
return method === "GET" || method === "HEAD" ? "read" : "disabled";
}Read that type declaration again, because it is the part that generalises. There are two classes, and neither of them is "write". There is no permission to grant, no elevated scope, no allowWrites: true waiting in a config file for someone to flip. To prevent LLM from calling POST endpoints you do not add a rule; you decline to add the only escape hatch that would make it possible.
The verb is a coarse signal and it is the right default precisely because it is coarse. POST, PUT, PATCH and DELETE all land in the same bucket without anyone reasoning about the individual endpoint. Reasoning about individual endpoints is where mistakes live, so the design pushes that work into a place where it leaves a record — which is gate five, below.
Gate three: the agent's tool list is a filter, not an instruction
The list handed to the model is computed, not curated:
const definitions = (await provider.listTools(context)).filter((definition) =>
definition.safety === "read"
&& (options.allowedToolNames === undefined || options.allowedToolNames.has(definition.name))
);
if (!definitions.length) throw new Error("No read tools are available for this turn.");A disabled operation is not described, not named, and not present in the request body sent to the inference API. This matters for injection specifically: text that instructs the model to call deleteAccount is asking for a token sequence the model has no tool definition for. The request either fails schema validation at the provider or comes back as prose.
The second clause is the per-turn narrowing. A turn can be restricted further than the integration allows — a follow-up on an existing screen only needs the operation that screen continues from — so the set the model sees is the intersection of "classified read" and "relevant right now", never a superset of either.
Note the throw. Zero read tools is treated as a misconfiguration, not as an empty list to hand the model. Failing loudly beats a turn that quietly has nothing to work with.
Gate four: the executor refuses a disabled operation anyway
The same class is checked again at the call site, after argument validation and before anything touches the network:
if (definition.safety === "disabled") {
const result: ToolResult = {
isError: true,
structuredContent: { ok: false, error: "operation_disabled" },
content: "This operation is disabled by server policy.",
};
record(context, { toolName: definition.name, argumentsValue: normalizedArguments, status: "disabled" });
return { kind: "result", definition, argumentsValue: normalizedArguments, result };
}If gate three works, this branch is unreachable. Keep it. The agent loop is one caller among several — tests, replay tooling, whatever the next feature adds — and the check that survives refactoring is the one nearest the socket. It also produces a status: "disabled" entry in the session's tool history, which is the difference between "the assistant said no" and evidence you can read afterwards.
Two smaller behaviours ride along at the same layer. Tool results are size-capped before entering model context, so an unexpectedly large read is rejected rather than pasted into the next prompt. And redirects are followed only for GET and HEAD, only to allowlisted hosts — a read tool cannot be bounced into a request it was not classified for.
The override, and the claim it stops you from making
Here is the part page one leaves out. Real integrations contain at least one operation that is a POST for protocol reasons and reads for business reasons: a quote, a search with a body too large for a query string, a batch lookup. The classification has to be overridable, and it is. The policies map in the config above promotes postRentalsQuote to read — a shipped example, not a hypothetical.
So the accurate sentence is narrower than the marketing one:
Mutating verbs are disabled by default; an operation reaches the model only if a human both allowlists it and classifies it read — an auditable two-step decision.
Two steps, both human, both in version control. Nobody grants write access at runtime; someone writes a line in a JSON file and a reviewer either accepts it or does not. That is a weaker claim than "nothing can mutate", and it is the one that survives reading the code.
It also names the real risk honestly: this is how drift starts. The audit that matters is not a runtime check, it is a diff on policies, and the question a reviewer asks is whether the promoted operation is durably read — no state changed, no side effect, safe to call twice. A quote that computes a price qualifies. A quote that reserves inventory does not, and the verb will not tell you which one you have.
What read-only LLM tool access still does not protect you from
The gates above bound one failure class. Four things they do not touch:
Reads exfiltrate. Restricting to reads converts a data-loss problem into a data-exposure problem; it does not remove it. The size cap and per-operation sensitive-field stripping help, but the real control is being deliberate about which fields the allowlisted responses contain in the first place.
Authorization is still your API's job. The gate decides whether an operation may be called. It has nothing to say about whose rows come back. The assistant runs with a credential the client supplied for this session, and if your endpoint returns another customer's data given the right identifier, read-only enforcement will pass that call through without comment. Session state carries no credentials field at all, which keeps them out of model context — it does not make your tenancy checks optional.
Injection is bounded, not solved. An attacker who gets text into a response body cannot reach a disabled operation, but they can still steer which reads happen and what the screen says about them. That is a separate problem with a separate answer, and it belongs with how invalid model output is handled on the way to the device.
Commerce fights this shape. You cannot rent a dress read-only. If your customers' hardest questions end in a purchase rather than an explanation, every session terminates in a handoff to your existing flow and the constraint is costing you something. Account-based service questions — why the bill changed, where the claim is, what the options are — are read-only by nature, which is why that is the segment where this design is an asset rather than a tax.
Frequently asked questions
Can I just tell the model in the system prompt not to call write endpoints?
No. A system prompt shares a channel with untrusted input and fails silently when ignored, producing a well-formed call to an endpoint you did not intend to expose. Prompt instructions are useful for tone and tool selection. They are not an access-control mechanism, and no security reviewer will accept one as evidence.
What about an endpoint that has to be a POST but only reads?
Override its classification deliberately. Both a per-operation allowlist entry and an explicit read classification are required, so the promotion shows up as a reviewable diff rather than a runtime decision. The test a reviewer should apply: does calling it twice change anything? A quote that computes a price passes. A quote that reserves inventory does not.
Does read-only tool access stop prompt injection?
It bounds the blast radius rather than stopping the attack. Injected text cannot invoke an operation the model was never given a definition for, so the write path is closed. It can still influence which reads are chosen and how results get summarised, which is why output validation and grounding are separate controls you also need.
Is a read-only AI assistant customer facing enough on its own?
It is necessary, not sufficient. Read-only settles what the assistant can do to your data. It says nothing about tenancy, rate limits, what leaves in a response body, or what the interface renders. Treat it as the first of several boundaries — the one that makes the security conversation short enough to have.
Where to start
You can audit your own stack for this today, without adopting anything:
- Find the list. Name the exact file that decides which operations the model can invoke. If the answer is "the spec" or "the prompt", that is the finding.
- Find the second check. Call a disabled operation directly, past the agent loop, in a test. If it executes, you have enumeration without enforcement.
- Find the exceptions. Grep for every operation classified read whose verb is not
GETorHEAD, and check each one against the twice-called test.
Uzori applies these gates in front of a customer-facing assistant that answers in a native iOS screen composed at request time, driven by your own OpenAPI spec and an operation allowlist. If your team is weighing that shape against building it, the contract is the thing to read first — start at uzori.ai.