|
| 1 | +# ADR 0008: Write tools over MCP, each write approved by a person before it runs |
| 2 | + |
| 3 | +- **Status:** Accepted |
| 4 | +- **Date:** 2026-10-11 |
| 5 | +- **Backlog:** #88 (write tools over MCP, with a confirmation step) - the remainder of #42. Builds on ADR 0003 |
| 6 | + (administrators and roles), ADR 0006 (agents signing in) and ADR 0007 (people signing in through SSO). |
| 7 | + |
| 8 | +## Context |
| 9 | + |
| 10 | +Agents can only read over MCP, deliberately: a saved query that writes is never listed as a tool, and `execute_sql` is |
| 11 | +read-only whatever the key may do over REST. Some agents must act - file a ticket, update a status, record an order - |
| 12 | +and a model deciding to write is a different risk from a person or a program deciding to: it can be confused, or steered |
| 13 | +by text it read (a description, a row, a document). The question #88 left open is how a model-initiated write is |
| 14 | +confirmed before it runs. |
| 15 | + |
| 16 | +What already governs writes over REST, and stays the base here: a key writes through a saved query only with an explicit |
| 17 | +per-query grant (`{"name": "submit_order", "allow_writes": true}`, never through `"*"`); `allowed_write_ops` narrows |
| 18 | +which statements; `QUERYAPIGATE_ALLOW_WRITES` is the server's ceiling; `allowed_tables` and masking apply. |
| 19 | + |
| 20 | +What MCP offers (2025-11-25): tool annotations (`destructiveHint`, `idempotentHint`) that a client may use to ask its |
| 21 | +user before calling - a hint, which the specification says not to rely on; and *elicitation*, the server asking the user |
| 22 | +through the client, in-band (a form) or out-of-band (a URL). Two facts narrow the choice: |
| 23 | + |
| 24 | +- **A client's own confirmation proves nothing to the server.** A client can auto-approve, and the server can't tell a |
| 25 | + person clicked "allow" from a client that never asked. |
| 26 | +- **This MCP server is stateless** - each HTTP request its own session - which is what makes it simple to run and scale, |
| 27 | + but rules out the server sending the client a request mid-call and waiting for the answer. |
| 28 | + |
| 29 | +## Decision |
| 30 | + |
| 31 | +### 1. What may write: saved queries an author marked for agents - never ad-hoc SQL |
| 32 | + |
| 33 | +A saved query becomes a write tool only when all of these hold: |
| 34 | + |
| 35 | +- its definition says so: **`agent_writes`** - `"approval"` (the default when set) or `"insert"` (section 3); |
| 36 | +- the caller's grant includes it with `allow_writes: true`, as over REST; `allowed_write_ops` and |
| 37 | + `QUERYAPIGATE_ALLOW_WRITES` apply; |
| 38 | +- its statement changes data and nothing else - one `INSERT`, `UPDATE` or `DELETE`, checked when it's saved - never |
| 39 | + DDL (`DROP`, `ALTER`, `TRUNCATE`, `CREATE`), whatever the grant would allow over REST; |
| 40 | +- its connection is one that runs a statement in a transaction and reports the rows it changed: PostgreSQL, MySQL, |
| 41 | + SQLite, DuckDB. Others (ClickHouse's mutations, H2, JDBC, MongoDB) are refused for agent writes. |
| 42 | + |
| 43 | +`execute_sql` stays read-only, always. Write tools carry `readOnlyHint: false`, `destructiveHint: true` (`false` for |
| 44 | +`"insert"` ones) and `idempotentHint: false`, and their description says a person approves each call. |
| 45 | + |
| 46 | +### 2. Every write is approved by a person, in QueryAPIGate - before it runs |
| 47 | + |
| 48 | +A call to a write tool doesn't write. It creates a **write request** - the caller, the saved query and its version, |
| 49 | +the exact arguments, the time - and answers at once: |
| 50 | + |
| 51 | +```json |
| 52 | +{"status": "approval_required", "request": "wr_7f3a9c", "approve_at": "https://data.example.com/console/approvals/wr_7f3a9c", |
| 53 | + "expires_at": "2026-10-11 14:30:00", |
| 54 | + "message": "A person must approve this write. Ask them to open the link; then call this tool again with approval: \"wr_7f3a9c\"."} |
| 55 | +``` |
| 56 | + |
| 57 | +- **Who approves:** an administrator whose role has the new capability **`writes.approve`** (owners and admins) - signed |
| 58 | + in to the Console, through SSO where it's set up, so the approval is a named person with the organisation's MFA |
| 59 | + behind them. The **Approvals** screen shows each request: the agent (key or `jwt:<user>`), the tool, the SQL, the |
| 60 | + arguments, when it expires; **Approve** or **Reject**, with a reason. The person who asked for the write - an agent |
| 61 | + acting for `jwt:alice` - is not who approves it unless alice is herself an approver. |
| 62 | +- **Then the agent calls the tool again** with the same arguments and `approval: "wr_7f3a9c"`. It runs only if the |
| 63 | + request was approved, hasn't expired (15 minutes by default, `QUERYAPIGATE_WRITE_APPROVAL_MINUTES`), was made by |
| 64 | + this same caller, and the arguments are the ones approved - byte for byte; approving one write never lets another run. |
| 65 | + It runs **once**: a second call with the same approval gets the first one's result, not a second write - so an agent |
| 66 | + retrying after a timeout can't write twice. |
| 67 | +- **Why not MCP elicitation:** form mode is the client's word for it (section "Context"); URL mode needs a session this |
| 68 | + stateless server doesn't keep. The answer above is a plain tool result every client can show - and its link is the |
| 69 | + same out-of-band step URL mode would open. When the server keeps sessions, it can also send that link as a URL-mode |
| 70 | + elicitation; the approval itself doesn't change. |
| 71 | + |
| 72 | +### 3. Without an approval: inserts only, and only within limits |
| 73 | + |
| 74 | +`"agent_writes": "insert"` lets an agent run a query **without** approval, for the cases where waiting for a person |
| 75 | +defeats the purpose (file a ticket, log an event) - and only when the query's statement is a single `INSERT` (checked |
| 76 | +when it's saved, as `allowed_write_ops` is), so it can add rows but never change or remove one. It takes an optional |
| 77 | +`idempotency_key` argument, kept a day, so a retried call doesn't insert twice. |
| 78 | + |
| 79 | +### 4. Every agent write has a ceiling on what it can change |
| 80 | + |
| 81 | +Each agent-writable query has **`max_affected_rows`** (default 1 - most agent writes change one row; set it higher on |
| 82 | +purpose). The write runs in a transaction; when the database reports more rows changed than that, it's rolled back and |
| 83 | +the call answers `too_many_rows_affected` - checked when it runs, not estimated beforehand, so a write approved when it |
| 84 | +would change one row and that would change thousands by the time it runs is still stopped. On MySQL this needs |
| 85 | +transactional tables (InnoDB, the default); a MyISAM table can't be rolled back, so the ceiling can't undo a write to |
| 86 | +one - the guide says to keep agent-writable queries off them. |
| 87 | + |
| 88 | +### 5. What's recorded |
| 89 | + |
| 90 | +- **Write requests** in the store (schema 11): who asked, what, the arguments (needed to show the approver and to run |
| 91 | + it), the status - pending, approved, rejected, expired, done, failed - who approved or rejected it and why, and the |
| 92 | + result (rows changed, or the error). Kept 30 days, then removed; the run's history entry and the audit entries stay. |
| 93 | +- **Audit:** `approve_write` and `reject_write`, by the approver, `via` their sign-in. **Run history:** the write, under |
| 94 | + the agent, with `transport: "mcp"`, `request` and `approved_by`. |
| 95 | +- **Agent usage** counts each agent's write requests and how they ended; a new alert, `writes_awaiting_approval`, says |
| 96 | + when requests are waiting - with how long the oldest has. |
| 97 | + |
| 98 | +## Consequences |
| 99 | + |
| 100 | +- **New:** `agent_writes` and `max_affected_rows` on saved queries; schema 11 (`write_requests`); the `writes.approve` |
| 101 | + capability; `/api/v1/write-requests` (list, get, approve, reject); the Console's Approvals screen; one setting, one |
| 102 | + alert. Experimental in its first release. |
| 103 | +- **Nothing changes for what exists:** no saved query becomes a tool until its author sets `agent_writes`, and no key |
| 104 | + writes until its grant says so - the same per-query grant REST uses. |
| 105 | +- **The approval is the control** that a model can't satisfy by itself: it needs a named person, signed in, with the |
| 106 | + write's SQL and arguments in front of them. What it can't do is judge intent - an approver who approves without |
| 107 | + reading approves what they see; the screen shows everything the write will do except the rows it will touch, which |
| 108 | + `max_affected_rows` bounds. |
| 109 | +- **Not in scope:** writes through `execute_sql`; approval by the end user an agent acts for (they don't sign in to the |
| 110 | + Console - a later step, through the same identity provider); a dry run that shows the rows a write would change |
| 111 | + (it would run the write's side effects - triggers, sequences - to show them); multi-statement writes. |
| 112 | + |
| 113 | +## Delivery |
| 114 | + |
| 115 | +About two weeks, in slices: |
| 116 | + |
| 117 | +1. Definitions and grants: `agent_writes`, `max_affected_rows` (validated when saved - `"insert"` only for a single |
| 118 | + `INSERT`); write tools listed for callers whose grant allows them, with their annotations; `writes.approve`. |
| 119 | +2. Write requests: schema 11, the request on a first call, the approval check on the second, single use, the row ceiling |
| 120 | + in a transaction, idempotency keys for inserts; `/api/v1/write-requests`; tests on PostgreSQL, MySQL, SQLite and |
| 121 | + DuckDB, including a changed argument, a different caller, an expired request and a write that changes too many rows. |
| 122 | +3. The Console's Approvals screen, with a count in the sidebar; the alert; agent usage. |
| 123 | +4. A guide (an agent that files a ticket on its own and updates an order only with approval, through a real MCP client), |
| 124 | + the threat model, the changelog - and a security review before release. |
0 commit comments