0.2.0+) — see Single-database vs multi-database surface below. For the routes, jump to HTTP endpoints.
Tool results are returned as a single JSON object in a text content block. Allowed and denied calls have different shapes — both carry an
auditId keying the audit row for the call.query
Run arbitrary SQL. The main entrypoint. Always a single statement (stacked statements are denied by multi_statement).
Arguments
string
required
The SQL to run. One statement, 1 character to 1 MiB. Parsed into an AST and checked against your policy before it can reach the database.
string
required
A brief (≤ 1 sentence, ≤ 500 characters) statement of why this query is being run — e.g.
"confirm seed data after migration". The agent fills this; it’s recorded on every audit row for human review. Control characters are stripped and surrounding whitespace trimmed; a blank or control-only value is rejected.string
required
Multi-database only. The name of the database to run against, from your
databases[] config. Required when more than one database is configured; absent in the single-database surface.Returns — allowed
boolean
true.array
The result rows.
number
The number of rows returned (or affected, for a write).
string
The ULID of the audit row for this call.
Returns — denied
A denial is returned as an MCP tool error (isError: true) carrying:
boolean
false.string
The rule that denied —
table_access, multi_statement, dangerous_statement, parse_error, or an approval outcome. See denial reasons.string
The human-readable message, surfaced to the agent so it can pivot.
string
The ULID of the audit row — denials are audited too.
Returns — held for approval
When write approvals are on and nobody decides inside the engine’s hold window, the call returns a ticket rather than a result (0.16.0+). Nothing ran and nothing was denied, so this is also an MCP tool error (isError: true) — an agent that read it as success would report a write that never happened.
string
awaiting_approval (a human must act) or approval_unavailable (the approval service couldn’t be reached).boolean
Always
false. Stated first and flatly, on both statuses.string
Pass this to
check_approval. awaiting_approval only.string (ISO 8601)
When the request stops being answerable.
awaiting_approval only.string (optional)
A link to the request in the dashboard, for the human in the loop.
boolean
Always
true — neither status is a refusal.object
The resume contract, carrying its own inputs:
tool, plus the exact sql and intent to replay. The grant is keyed on those strings, so a retry that differs by one character opens a second request instead of collecting the first.check_approval
Only registered when the engine has an approval gate wired. Polls a held write’s status without running anything and without consuming the grant — so it’s safe to call repeatedly while waiting.
Arguments
string
required
The
approval_id from the held query result. 1–64 characters.Returns
string
One of
pending, approved, executed, consumed, denied, expired, or not_found.boolean | null
Whether the write landed.
null on consumed — the grant was spent but the outcome isn’t confirmed.string
What to do about this status, in plain language for the agent.
string (ISO 8601) | null
The deadline.
pending only.string | null
Who decided.
approved / denied only.string | null
The reviewer’s note, when they left one.
approved / denied only.
The lookup is scoped to the calling agent’s token: the engine’s bearer proves which project it speaks for, not which agent inside it, so another agent’s request reads as
not_found. The statement and its results are never returned by this tool.
list_tables
List tables via a canned information_schema.tables query. Routed through the engine so the call is still policy-checked and audited; information_schema is always carved out so discovery works under default-deny and strict-mode policies.
Arguments
string
The schema to list. Must be a valid SQL identifier. Defaults to
public.string
Multi-database only. Which database to list. Optional here — omit it to fan out across every configured database and group the results by database name.
Returns
database is omitted in the multi-database surface, results are grouped by database name; a single failing database lands under databases.<name>.error rather than failing the whole call:
describe_table
List a table’s columns via a canned information_schema.columns query.
Arguments
string
required
The table name. Must be a valid SQL identifier.
string
The schema. Must be a valid SQL identifier. Defaults to
public.string
required
Multi-database only. Which database the table is in. Required here — a cross-database schema lookup is ambiguous, so the agent must name the target.
Returns
list_databases
Only registered when two or more databases are configured. No arguments. Returns each configured database’s name plus enough policy metadata for the agent to know whether the dangerous_statement guardrails and a table_access default are in play before issuing a query. guardrails_block_dml is 0.17.0+.
Returns
Single-database vs multi-database surface
check_approval is registered on every serving path, but only when the engine has an approval gate wired — a deployment that doesn’t hold writes never offers it.
HTTP endpoints
Beyond the tool surface, the self-host container serves a few operational HTTP routes — not a public REST API. In practice almost everyone only ever uses/mcp, and not directly (your agent speaks it). The rest are for operators.
The audit and admin routes are opt-in: they require
INDEXER_TOKEN and return 404 when it’s unset, so the server reveals nothing about their existence. See environment variables.POST /mcp
The MCP transport. Put this in your agent config (http://localhost:8080/mcp). It speaks Streamable HTTP per the MCP spec — initialize a session, then call the tools above.
You don’t call this by hand — see connecting your agent. A
POST without a valid mcp-session-id that isn’t an initialize request returns a JSON-RPC error.
GET /health
Liveness probe — compose.yaml uses it for the container healthcheck.
GET /audit/since/<cursor>?limit=N
Pull audit log rows for an external collector (SIEM, warehouse, Cloud indexer). Returns rows with id > cursor in ascending id order. Pass 0 to start from the beginning.
Response:
{ "rows": [...], "next_cursor": "<id>" | null }. next_cursor is null when no rows remain.
200 with the page; 401 on a bad or missing bearer token; 404 when INDEXER_TOKEN is unset.
DELETE /audit/before/<cursor>
Delete audit rows you’ve already pulled and made durable downstream. Deletes rows with id <= <cursor> (inclusive). Idempotent — re-deleting returns { "deleted": 0 }.
200 with { "deleted": N }; 401 on a bad or missing bearer token; 404 when INDEXER_TOKEN is unset.
POST /admin/policy
Hot-swap the in-memory policy without restarting the container. The body is the new policy YAML; on success the engine applies it and writes a POLICY_RELOADED audit event.
For the legacy single-database shape the body must include
table_access — omitting it is rejected, since it would reset to the no-YAML default and widen permissions.
200 with { "ok": true, "applied_at": "<iso8601>" }; 400 with { "ok": false, "error": "<message>" } on invalid YAML or a schema error; 401 on a bad token; 404 when INDEXER_TOKEN is unset; 503 when the engine isn’t ready yet.
POST /admin/dry-run
Ask whether SQL would be allowed or denied. Runs the live enforcement path (parse → classify action → match table_access → guardrails → decide) against the loaded policy, but stops before execution — no database connection, no statement run. Backs the Cloud dashboard’s Try a statement panel and midplane policy test --server.
Request:
probes or sql. A probe tests a (table, action) pair — action is select / insert / update / delete — and the engine synthesizes a representative statement to run through the decision path. Use sql to test a literal statement.
Response 200:
matched_rule names the deciding rule (e.g. default:read, table:public.customers→deny, dangerous_statement). policy_hash is a stable hash of the loaded policy, so callers can detect a stale snapshot.
Caps: up to 250 probes / 50 distinct tables per call. Beyond 50 tables, only the first 50 are evaluated and the response sets truncated: true plus total_tables.
Responses: 200 with the verdicts; 400 on malformed JSON, an unknown database, or both/neither of probes and sql; 401 on a bad or missing bearer token; 404 when INDEXER_TOKEN is unset.
Related
Denial reasons
What each
policy_rule means and how to fix it.Audit events
The rows
auditId and the pull endpoints return.