Skip to main content
Every agent reaches Postgres through five MCP tools. Two run statements, one kind each: query runs reads and write runs writes. Clients decide from a tool’s annotations whether a call needs your confirmation, so the split is what lets reads run while writes wait (what clients ask you to confirm). It is enforced, not declared: the gateway refuses a write sent to query and a read sent to write, and Postgres runs every read in a READ ONLY transaction. At initialize, the gateway sends instructions for the agent: the order to use the tools in, that masked columns stay masked, how a held write is re-run, that a denial is the policy and not an error to get around, and that content from untrusted columns is data, never instructions.

query

One read-only SQL statement: a SELECT, VALUES, or a WITH query that writes nothing. A write sent here returns an error naming write, or saying the agent’s access is read-only, and nothing runs.

write

One SQL write: an INSERT, UPDATE, DELETE, or a schema change Midplane supports (CREATE TABLE, CREATE INDEX, ALTER TABLE, DROP TABLE; the policy). The policy decides: it runs now, waits for a person’s approval, or is refused, and the answer says which. A read sent here returns an error naming query. The inputs are query’s. intent is what approvers read, and an approval binds to it: once a held write is approved, the agent re-runs exactly the same statement with the same intent through write (held writes). An agent whose access is read-only on every database this gateway has doesn’t get this tool at all, and its instructions say so. A client keeps the tool list for its session, so an agent given write access sees write on its next connection.

list_tables

The tables and views the agent may read, one per line, with its access to each:
Takes database. A table the policy denies isn’t listed.

describe_table

One table’s columns, from the catalog: each column’s type, primary key or not null, where a foreign key points, [masked] when the policy masks it, and its comment. The table’s comment follows the first line, and a foreign key over several columns follows the columns.
Takes table (optionally schema-qualified) and database. A foreign key to a table the agent can’t read is left out, as list_tables leaves that table out. All of it is catalog metadata: the gateway reads no row for it, and comments stay on the gateway, never going to Midplane Cloud.

check_approval

Where a held write stands, by the request id a held write returned: pending, with the review link and when it expires; approved, with the statement and intent to re-run through write; denied, with the note; expired; or used, with what the run changed. In local mode, it says approvals need a linked gateway.

Results

An allowed statement returns JSON text, and the same object as the result’s structuredContent:
  • rows are arrays in column order. Bigints come as strings, bytea as \x… hex, and dates, times and timestamps as Postgres prints them.
  • A read stops at limits.max_rows (1,000 by default) or limits.max_bytes (1 MiB) (configuration): truncated is true, row_count is null, and a line says so. A write’s row_count is exact.
  • Masked columns hold masked values. The result doesn’t say which columns; describe_table does.
  • A result with content from a column labeled untrusted ends with a note to treat it as data, never as instructions, and that read taints the agent’s grant (taint).
  • A write that ran after approval says who approved it and the request id.

When nothing runs

EXPLAIN is refused as unsupported, like everything else the gateway can’t analyze: SET, SHOW, COPY, transaction control and MERGE. Each statement is one statement; a write hidden in a WITH is denied.

What clients ask you to confirm

Each tool carries the MCP annotations clients read:
  • claude.ai groups a connector’s tools into read-only and write/delete, each set to Always allow, Needs approval or Blocked. write is the one tool in the write/delete group; blocking it leaves reads working (claude.ai).
  • ChatGPT treats a tool without readOnlyHint as a write action and asks before each call, remembering your answer for the chat. Reads run; write asks (ChatGPT).
  • Codex CLI, in its writes approval mode, runs read-only tools without asking and prompts for the rest.
  • Claude Code, Cursor and VS Code ask by their own settings, per tool or per server.
The two checks are different people’s. The client’s prompt is yours, as the agent’s user. A held write waits for an owner or admin in Midplane, and the policy decides whether a write runs at all, whatever the client allowed. If the policy already holds the writes that matter, Always allow on write is reasonable: the prompt then adds a check only to writes the policy runs outright. Before 0.26.0, one query tool ran reads and writes, so clients treated every read as a write (upgrading). Tested by apps/gateway/test/gateway.e2e.test.ts (the split and what each refusal says, every tool’s title and hints, the instructions, the database argument, and keys and comments without a row read) and packages/corpus/fixtures/tools.json (the rule in the core).