All articles

Data access

Read-only MCP access: why the tool name is not enough

Tier Two8 min read

Read-only sounds like the safe default, and for the question it answers, it is. An agent that can only read can't drop the table, close the ticket or force-push over main. The trouble is that the two most instructive agent security incidents of 2025 involved no unauthorized write at all. In both, the harm was a read: private repositories, a table of integration tokens. The writes that carried the data out were ordinary, permitted actions.

So the question to ask about a read tool isn't 'can it change anything' but 'what's the worst thing it can return, and where can that go next'. This article works through what read-only means in four systems you're likely to connect, where each one's guarantee ends, and how to decide which reads should run without anyone looking.

The short answer

Read-only limits what an agent can change, not what it can see or where what it sees ends up. Treat every read tool as a disclosure decision: find its broadest possible result, check which other tools in the same session can send data out, and narrow the credential before you touch the policy. Then put an Ask on the reads whose worst result you wouldn't paste into a public channel.

Two incidents where nothing was written that shouldn't have been

On May 26, 2025, Invariant Labs published a demonstration against the official GitHub MCP server. A public repository contained an issue with instructions planted in its text. A developer asked their agent to look at the open issues; the agent read the planted instructions, used its GitHub access to read the developer's private repositories, and published what it found in a pull request on the public repository. Every tool call was one the agent was allowed to make. Reading a private repo was in scope for the token. Opening a PR was in scope. The exploit was the combination, steered by text the agent should never have treated as a command.

Six weeks later, on July 8, General Analysis described the same shape against a Supabase-backed support tool. A customer filed a ticket whose body contained instructions. A developer later used Cursor with the Supabase MCP server, connected with a privileged service role, to review tickets. The agent read the ticket, followed the instructions, queried the integration_tokens table, and wrote the contents into a reply on the ticket, where the attacker could read them. Again no privilege was escalated. The agent read what its credential could read and wrote where its credential could write.

Simon Willison gave the pattern a name that month: the lethal trifecta, access to private data, exposure to untrusted content, and a way to communicate externally. Read-only access removes exactly none of the three. It can sometimes close one communication channel, which is why General Analysis listed Supabase's read-only mode among the mitigations, because in that case the exfiltration was a database write. It does nothing about a comment tool, a PR tool, or a web fetch sitting in the same session.

Integrity is not confidentiality

Two promises get bundled under one word. Integrity: the agent can't alter or destroy data. Confidentiality: the agent can't reveal data to anyone who shouldn't have it. Read-only, implemented well, delivers the first. It says nothing about the second, because every tool result flows into the model's context, and from there it can flow into any tool that can send. The MCP specification's security guidance puts consent and data-access control on the implementation; nothing in the protocol treats a non-mutating call as safe.

That framing changes which tools you worry about. Under an integrity lens, save_comment is the risky tool and list_issues is fine. Under a confidentiality lens, list_issues is the tool that gathers the sensitive result and save_comment is merely the exit, and one of several. Both lenses are right; a policy needs both, which is why 'reads Allow, writes Ask' works as a first draft and fails as a final answer.

What read-only means in four systems

Each system enforces read-only differently, and the difference is exactly the part that matters when you decide what to Allow.

SystemHow you get read-onlyWhat it preventsWhat it still exposes
GitHub MCP serverThe --read-only flag, GITHUB_READ_ONLY=1, or the X-MCP-Readonly header on the remote server; write tools are skipped even if requested by nameCommits, issues, PRs, any write toolEvery file, issue and discussion in every repository the token can read, including secrets committed by mistake
Linear MCP serverThe mcp.linear.app/mcp/readonly endpoint, which exposes only read tools, or the standard endpoint with the read OAuth scope, or a read-only API keyCreating or editing issues, comments, projectsEvery issue, comment and document the account can see, across every team it belongs to
Supabase MCP serverread_only=true on the hosted server URL runs queries as a read-only Postgres user; project_ref pins one project and disables account-level toolsWrites through the database tools; account-wide toolsEvery table the read-only role can select, which is usually all of them
Postgres directlyA role granted only SELECT on specific tables. Session settings such as default_transaction_read_only are user-changeable and don't countINSERT, UPDATE, DELETE, MERGE, COPY FROM, DDL, when the transaction is read-onlyEvery column of every table the role can SELECT

The Postgres row has a trap worth spelling out. A read-only transaction, per the SET TRANSACTION documentation, disallows the write statements above, but default_transaction_read_only is a parameter any session may set, so a query tool that relies on it is one SET statement away from write access. The enforced version is a role that lacks the privileges, and reads are then governed only by SELECT grants. A 'read-only' SQL tool therefore returns whatever the role can select, which is why the permissions matrix treats any tool that takes a SQL string as unclassifiable by name.

Three questions before a read gets Allow

Ask these of each read tool, and answer for the worst case rather than the typical one.

  • What's the broadest result it can return? Not 'the issue I asked about' but 'any issue this account can see'. For search_code on a token with private access, the answer is your source tree.
  • Where can the output go next? List the tools in the same session that can send: comment and issue tools, pull requests, email, a web fetch that takes a URL. Each is an exit for whatever the read pulled in. If none exist, a sensitive read is far less dangerous; if several do, it's the trifecta.
  • What does the credential reach on its own? A personal sign-in returns what that person may see, which is at least a known boundary. A shared workspace key returns what the key may see, and only the tool's mode limits it.

Then decide. Allow when the worst result is something you'd be comfortable seeing in a shared channel. Ask when the requester or the resource changes that answer, so a person sees who's asking and what for. Block when no workflow needs the tool. And where the tool carries a resource argument, a project or database id, scope the decision to it: Allow on the support project, Ask on finance, refused outright when the argument is missing.

How Tier Two's Recommended preset treats reads, and why to look anyway

When you add a connection, Tier Two's Recommended preset allows the tools it guesses are reads: anything whose server marks readOnlyHint true, or whose name starts with get, list, search, read, describe, fetch, find, show, view, query, construct, count, check, lookup or browse. That guess is right about integrity most of the time and says nothing about confidentiality, which is the point of this article. The policy documentation calls it a starting point, and search_code and list_issues are the two rows where you should overrule it most often.

Putting a read on Ask is a normal choice, not a workaround, and it doesn't have to mean a prompt on every call. Remembered approvals run for one hour, eight hours or seven days, and belong to the person, so an engineer doing a day-long investigation gets one Ask in the morning and quiet afterwards, while the same tool still stops for someone else. Resource scoping keeps the routine project on Allow. The reviewer sees the person, the agent, the host and a truncated argument preview, and either forwards the original call or refuses it; on a refusal the agent is told to stop rather than try another route.

Shrink the trifecta first, then police what's left

Policy is the last layer, not the first. Start by narrowing what the credential can reach: the read-only endpoint, the SELECT-only role, a project_ref, a toolset instead of the whole server. Then look for the untrusted-content path and ask whether the agent needs to read it at all; an agent that triages public issues has to, an agent that summarizes your own sprint doesn't. Then treat every send-capable tool as an exit and keep the ones you can't remove on Ask, because that is precisely where read-only stops helping.

Finally, record everything. A read that ran on Allow should still leave a row naming the person, the agent, the host, the tool and the outcome, so that when a ticket like the Supabase one shows up you can answer 'what did the agent read that afternoon' from a ledger rather than from memory. Tier Two's activity trail keeps that row for every call and refusal, without storing argument contents, and exports it to a SIEM on the Enterprise plan. OWASP's 2025 list for LLM applications files this pair of failures under prompt injection and excessive agency, and its agentic top ten calls the second one tool misuse; the names are new, the shape is the oldest one in security: a confused deputy with a broad credential.

Treat every read as a data-access decision

Read-only describes what a tool can't change. It has never described what a tool can reveal, and the incidents that made the term popular were disclosure incidents. Review each read by its broadest result and its exits, narrow the credential, and then let the routine reads run.

Tier Two lets you put an Ask on a sensitive read as easily as on a write, scope it to the project that needs it, and keep a record of every read that ran. Connect the system whose data you'd least like to see in a ticket reply, and decide those rows first.

Sign up for Tier Two