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, or parse_error. 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.

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.

Returns

Single-database vs multi-database surface

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 “test this policy” 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.