Skip to content

Commit b75e4f7

Browse files
committed
fix(security): a saved query that writes is never run over MCP, called by name or by version (0.20.1)
The MCP server listed only read-only saved queries, but a call naming one that writes wasn't refused (0.11.0-0.20.0): an agent whose key's grant allowed that write could run it unsupervised, older versions included. Nothing beyond what the key could do over REST, but it broke "agents only read". run_saved() now refuses a writing saved query over MCP before it runs (read_only), whatever the version. - regression tests: by name, and an older writing version through `version` - __version__ 0.20.1; CHANGELOG Security entry with how to check; image pins
1 parent 1b8b04d commit b75e4f7

16 files changed

Lines changed: 174 additions & 12 deletions

‎BACKLOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ descriptions for agents, #87 per-agent usage, #88 write tools with confirmation.
7171
the DuckDB minimum.)
7272
8. **0.20: consolidation** - alerts, remote files, exports and column masking graduate, on terms written into the
7373
versioning policy (`experimental.GRADUATION`); CI against real S3, Keycloak and the oldest dependencies allowed.
74-
(Released as 0.20.0 on 2026-10-11.)
74+
(Released as 0.20.0 on 2026-10-11; 0.20.1 refused writes over MCP called by name.)
7575
9. **1.0.0-rc1:** freeze; invite external users to upgrade real stores and report back.
7676
10. **1.0.0.**
7777

‎CHANGELOG.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ sooner, saying why. The list of what is deprecated now is `queryapigate/deprecat
5151

5252
## [Unreleased]
5353

54+
## [0.20.1] - 2026-10-11
55+
56+
### Security
57+
- **A saved query that writes could be run over MCP by calling it by name** (0.11.0 to 0.20.0). The MCP server lists
58+
only read-only saved queries as tools, but a call naming one that writes wasn't refused - so an agent holding a key
59+
whose grant allowed that write (`{"name": "...", "allow_writes": true}`, with `QUERYAPIGATE_ALLOW_WRITES` on) could
60+
run it unsupervised, including an older version that writes through `version`. It reached nothing the same key
61+
couldn't do over REST, but it broke the promise that agents only read. Now a saved query whose SQL writes is refused
62+
over MCP before it runs (`read_only`), whatever the version and the grant. To check whether it happened: run history
63+
entries with `"transport": "mcp"` for a saved query that writes.
64+
5465
## [0.20.0] - 2026-10-11
5566

5667
A consolidation release: four features leave the experimental list and come under the compatibility promise -
@@ -1896,7 +1907,8 @@ First public release, restructured from the original single-file application.
18961907
- JSON column order is preserved; Decimal, date and driver-specific number types serialise correctly.
18971908
- Concurrent saves can no longer lose a version.
18981909

1899-
[Unreleased]: https://github.com/AnanthaRajuC/QueryAPIGate/compare/v0.20.0...HEAD
1910+
[Unreleased]: https://github.com/AnanthaRajuC/QueryAPIGate/compare/v0.20.1...HEAD
1911+
[0.20.1]: https://github.com/AnanthaRajuC/QueryAPIGate/compare/v0.20.0...v0.20.1
19001912
[0.20.0]: https://github.com/AnanthaRajuC/QueryAPIGate/compare/v0.19.1...v0.20.0
19011913
[0.19.1]: https://github.com/AnanthaRajuC/QueryAPIGate/compare/v0.19.0...v0.19.1
19021914
[0.19.0]: https://github.com/AnanthaRajuC/QueryAPIGate/compare/v0.18.0...v0.19.0

‎deploy/kubernetes/queryapigate.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ spec:
2525
terminationGracePeriodSeconds: 45 # the preStop pause plus gunicorn's 30 s to finish requests in flight
2626
containers:
2727
- name: queryapigate
28-
image: ghcr.io/anantharajuc/queryapigate:0.20.0
28+
image: ghcr.io/anantharajuc/queryapigate:0.20.1
2929
ports: [{name: http, containerPort: 5000}]
3030
envFrom: [{secretRef: {name: queryapigate}}]
3131
env:
@@ -79,7 +79,7 @@ spec:
7979
enableServiceLinks: false
8080
containers:
8181
- name: events
82-
image: ghcr.io/anantharajuc/queryapigate:0.20.0
82+
image: ghcr.io/anantharajuc/queryapigate:0.20.1
8383
command: ["queryapigate", "events", "--host", "0.0.0.0"]
8484
ports: [{name: http, containerPort: 5002}]
8585
envFrom: [{secretRef: {name: queryapigate}}]

‎deploy/scale-out/.env.example‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copy to .env (chmod 600) and fill in. Never commit .env.
22

33
# Pin a release: an upgrade should be a deliberate change of this line (see DEPLOYMENT.md, Rolling upgrades).
4-
QUERYAPIGATE_IMAGE=ghcr.io/anantharajuc/queryapigate:0.20.0
4+
QUERYAPIGATE_IMAGE=ghcr.io/anantharajuc/queryapigate:0.20.1
55

66
# The domain Caddy serves and gets a certificate for. "localhost" uses a local certificate, for trying it out.
77
SITE_ADDRESS=localhost

‎deploy/scale-out/docker-compose.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
# Redis (rate limits, the response cache), so instances are interchangeable: add one by copying a block below.
1010

1111
x-queryapigate: &queryapigate
12-
image: ${QUERYAPIGATE_IMAGE:?set QUERYAPIGATE_IMAGE in .env, e.g. ghcr.io/anantharajuc/queryapigate:0.20.0}
12+
image: ${QUERYAPIGATE_IMAGE:?set QUERYAPIGATE_IMAGE in .env, e.g. ghcr.io/anantharajuc/queryapigate:0.20.1}
1313
restart: unless-stopped
1414
env_file: .env
1515
environment:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
--8<-- "documentation/adr/0008-mcp-write-tools-with-approval.md"

‎documentation/API.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1393,7 +1393,7 @@ curl 'http://127.0.0.1:5000/api/v1/usage/agents?since=2026-10-10' -H "X-API-Key:
13931393
`mcp` and `events` process seen in the last 90 seconds - and what looks wrong about them:
13941394

13951395
~~~json
1396-
{"items": [{"id": "3f9c2a1b7d4e-4242", "host": "api-1", "pid": 4242, "role": "serve", "version": "0.20.0",
1396+
{"items": [{"id": "3f9c2a1b7d4e-4242", "host": "api-1", "pid": 4242, "role": "serve", "version": "0.20.1",
13971397
"shared_limits": true, "started_at": "2026-10-04 09:00:12", "last_seen": "2026-10-04 12:47:03",
13981398
"this": true}],
13991399
"problems": []}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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.

‎documentation/adr/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ one it supersedes.
1616
| [0005](0005-column-masking.md) | Column masking - rules on keys, roles and exports, traced to source columns, failing closed | Accepted |
1717
| [0006](0006-ai-agents.md) | AI agents - the MCP server as an OAuth resource server, descriptions agents read, a per-agent usage view | Accepted |
1818
| [0007](0007-console-sign-in-through-sso.md) | Console sign-in through the organisation's identity provider (OpenID Connect) | Accepted |
19+
| [0008](0008-mcp-write-tools-with-approval.md) | Write tools over MCP, each write approved by a person before it runs | Accepted |
1920

2021
Statuses: **Proposed** (under discussion) → **Accepted** (in effect) → optionally **Superseded by NNNN** or
2122
**Deprecated**.

‎how-to/30-schedule-an-export.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ spec:
125125
restartPolicy: Never
126126
containers:
127127
- name: export
128-
image: ghcr.io/anantharajuc/queryapigate:0.20.0
128+
image: ghcr.io/anantharajuc/queryapigate:0.20.1
129129
command: ["queryapigate", "export", "example_rentals_since", "--out", "/exports/{name}_{date}.csv"]
130130
envFrom:
131131
- secretRef:

0 commit comments

Comments
 (0)