Implementation Guide

    How to stop an AI agent from writing to Postgres through a "read-only" Model Context Protocol server

    A "read-only" label on an MCP server is a claim, not a control. Postgres only refuses writes when the credential, role and session behind the connection cannot write. Build from database privileges upward, add query-level restrictions that do not depend on a second SQL parser, then enforce policy at runtime on every tool call and keep a record of each allow, block or escalation.

    Verify the controls with tests, not labels

    • Attempt an INSERT, UPDATE, DELETE and CREATE TABLE as the MCP role and confirm Postgres rejects each
    • Submit stacked statements, including the quote and backslash payload class, and confirm only one statement can execute
    • Include COMMIT in agent-supplied text and confirm it cannot end the read-only transaction
    • Call functions available to the role and confirm none writes data
    • Attempt the same write through every other tool and MCP server the agent can reach
    • Confirm every allowed, blocked or escalated call produces a record tied to the exact call

    Why the label does not guarantee read-only behavior

    Teams usually enforce "read-only" in one of four places: the tool description the agent reads, an application-layer filter on the query text, a read-only flag in the MCP server, or an annotation that marks the tool as a read tool. None of these is enforced by Postgres. Each depends on something other than the database agreeing that a write is not allowed.

    OWASP's MCP guidance is direct on this point. Clients should treat tool annotations as untrusted unless they come from trusted servers, and a tool's read/write label alone may not establish its risk. Authorization should be assessed by possible effects, including sensitive reads, writes, data disclosure and irreversible actions. The same guidance says tool inputs should be treated as untrusted because they originate from LLM output that potentially malicious context can influence.

    A recent CVE shows the failure mode. CVE-2026-17351 concerns the AI Assistant in pgAdmin 4, which is not an MCP server but follows the same LLM-to-SQL tool path. An earlier fix required the model's query to parse as exactly one non-transaction-control statement and then ran it inside a BEGIN TRANSACTION READ ONLY wrapper. The bypass used a payload that the validator read as one SELECT while PostgreSQL executed four statements. A smuggled COMMIT ended the read-only transaction, the trailing ROLLBACK did nothing, and the payload created a table. Delivery was by indirect prompt injection: content the assistant could read became the SQL it emitted.

    The bypass paths to look for in your deployment

    The pgAdmin case combines two paths, but a Postgres-backed MCP deployment has more. Inventory each of the following against the servers you actually run.

    • Stacked statements: One tool argument contains several statements. A validator and PostgreSQL can disagree about where a statement ends, as the string-literal lexing difference in CVE-2026-17351 shows.
    • Transaction control: If agent-supplied text can include COMMIT, a read-only transaction wrapper can be ended and later statements run outside it.
    • Functions with side effects: A statement that looks like a SELECT can call a function that writes. Whether it can depends on the function and the privileges of the role.
    • Writes through other tools: A second tool or MCP server may hold write access, so locking down the SQL tool alone leaves the path open.
    • Overbroad credentials: The server connects with its own broad privileges rather than the minimum needed. OWASP calls this the confused deputy problem.
    • Indirect prompt injection: Attacker-controlled content the agent reads becomes the query it sends. The agent's good intent is not a control.

    Layers 1 and 2: make the database the foundation

    Start with a dedicated role for each Postgres-facing MCP server, granted SELECT on the specific tables the task requires rather than all tables. OWASP's Excessive Agency guidance describes the anti-pattern: a plugin meant to read data that connects with an identity that also has UPDATE, INSERT and DELETE. Inventory every credential each server uses and confirm none carries those privileges. Keep schema objects owned by a different user from the one the agent's connection uses; the FIPS 193 read-only server profile makes the same assumption.

    This layer holds regardless of the prompt, the parser or the server's claims, because Postgres evaluates the grant on every statement. Also review which functions the role can execute and remove execute rights on any with side effects. Row-level security can narrow which rows the role sees, but it complements privilege limits rather than replacing them.

    Session settings and topology come second. A read-only session default or a connection to a read replica adds friction and reduces exposure on the primary. Do not make a session-level read-only mode the foundation, though. The pgAdmin bypass is a read-only transaction ended from inside the agent's own text. Test what your role can change within a session, and treat these settings as defense in depth behind the grants.

    • Separate role per MCP server, never shared
    • SELECT on named tables only
    • No INSERT, UPDATE or DELETE anywhere on the credential
    • Review function execute rights

    Layer 3: restrict the query without trusting a second parser

    A privilege-limited role still allows reads you may not want, and a query-level restriction narrows what the agent can ask for. The lesson from CVE-2026-17351 is where that restriction runs. A client-side parser that classifies a query as a single statement is not authoritative, because it can lex the text differently from PostgreSQL. Do not rely on its classification alone.

    Prefer restrictions that use the database's own parsing. NVD describes one candidate fix that ran the query through psycopg with prepare=True, intended to force PostgreSQL's Parse step in the extended query protocol to reject multi-statement text regardless of how sqlparse classified it. Whatever mechanism you choose, test it with the quote and backslash payload class from the advisory against your real deployment.

    Transaction control is a policy choice, not a given. Commit and rollback are standard parts of read-only SQL interfaces, since they signal that a read-only transaction has completed. Blocking them from agent-supplied text is reasonable, but then your server must own transaction boundaries itself, and you should confirm that the agent can never end the transaction it runs in.

    Layer 4: enforce policy at runtime on MCP tool calls

    The first three layers constrain what the connection can do. They do not tell you what the agent attempted, and they do not cover writes routed through other tools. A runtime policy on tool calls closes that gap by deciding on each call before it executes.

    A useful policy inspects the tool name, the arguments, the SQL statement type, the target schema and the server credential in use. It classifies the action by reversibility, such as read-only, reversible or irreversible, and lets that class decide what runs unattended. Calls that fall outside the class can be blocked, have sensitive values redacted, or be escalated for human approval. Approvals should be bound to the exact tool call and the current actor, remain valid, and be single-use. A generic user_confirmed flag is not sufficient, and tool calls should never be auto-approved.

    Each decision should produce evidence: the tool and arguments (redacted as policy requires), the statement type, the decision, the policy that matched, the approver where one applied, and a timestamp. Security-critical approvals need an audit trail and pre-execution validation.

    Trussed provides this layer. It enforces policy in the execution path on tool calls to MCP servers, with allow, redact, block, rate limit and escalate for human approval. It is enforced at runtime, not reviewed after the fact, and it generates audit-ready evidence from each enforcement decision. It can be deployed self-managed in the customer cloud, with configurable retention and data residency. It sits above the database controls and does not replace them.

    Four layers, each closing a gap in the one before

    1. Database privilege

    A dedicated role with only the SELECT access the task needs.

    2. Session and topology

    Read-only defaults and, where suitable, a read replica as added friction.

    3. Query restrictions

    Single-statement enforcement by the database's own parsing, with transaction control handled deliberately.

    4. Runtime tool-call policy

    Allow, redact, block or escalate each call, with audit-ready evidence.

    Enforce read-only at the database and in the tool-call path

    See how policy enforced at runtime on MCP tool calls produces audit-ready evidence of what was allowed, redacted, blocked or escalated.

    Explore Agent and Tool-Call Enforcement
    Next step

    How does your AI governance program compare?

    See where your program has gaps in less than 2 minutes.