Skip to main content
Your agent talks to Midplane over MCP. The engine exposes a small, fixed set of tools, and the self-host container serves a few operational HTTP routes around them. Every tool call runs through the same parse → policy → audit → execute pipeline, so even schema-browsing calls are policy-checked and audited. The tool surface depends on how many databases are configured (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

When 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.
Responses: 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 }.
Pull-then-delete is the contract. Don’t DELETE rows you haven’t read out — deletion is permanent.
Responses: 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.
Responses: 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:
Send exactly one of 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.

Denial reasons

What each policy_rule means and how to fix it.

Audit events

The rows auditId and the pull endpoints return.