> ## Documentation Index
> Fetch the complete documentation index at: https://midplane.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# MCP tools & HTTP endpoints

> What Midplane exposes: the MCP tools your agent calls (query, list_tables, describe_table, list_databases), and the operational HTTP routes the self-host container serves.

Your agent talks to Midplane over [MCP](https://modelcontextprotocol.io/). 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](#single-database-vs-multi-database-surface) below. For the routes, jump to [HTTP endpoints](#http-endpoints).

<Note>
  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](/docs/reference/audit-events) for the call.
</Note>

## `query`

Run arbitrary SQL. The main entrypoint. Always a single statement (stacked statements are denied by [`multi_statement`](/docs/reference/denial-reasons)).

### Arguments

<ParamField path="sql" type="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.
</ParamField>

<ParamField path="intent" type="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.
</ParamField>

<ParamField path="database" type="string" required>
  **Multi-database only.** The name of the database to run against, from your [`databases[]`](/docs/reference/policy-schema) config. Required when more than one database is configured; absent in the single-database surface.
</ParamField>

### Returns — allowed

<ResponseField name="allowed" type="boolean">
  `true`.
</ResponseField>

<ResponseField name="rows" type="array">
  The result rows.
</ResponseField>

<ResponseField name="rowCount" type="number">
  The number of rows returned (or affected, for a write).
</ResponseField>

<ResponseField name="auditId" type="string">
  The ULID of the audit row for this call.
</ResponseField>

```json theme={null}
{
  "allowed": true,
  "rows": [{ "id": 1, "email": "ada@example.com" }],
  "rowCount": 1,
  "auditId": "01HXXX..."
}
```

### Returns — denied

A denial is returned as an MCP tool error (`isError: true`) carrying:

<ResponseField name="allowed" type="boolean">
  `false`.
</ResponseField>

<ResponseField name="policy_rule" type="string">
  The rule that denied — `table_access`, `multi_statement`, `dangerous_statement`, or `parse_error`. See [denial reasons](/docs/reference/denial-reasons).
</ResponseField>

<ResponseField name="reason" type="string">
  The human-readable message, surfaced to the agent so it can pivot.
</ResponseField>

<ResponseField name="auditId" type="string">
  The ULID of the audit row — denials are audited too.
</ResponseField>

```json theme={null}
{
  "allowed": false,
  "policy_rule": "table_access",
  "reason": "Midplane denied this query because writes to table `users` are not allowed by the table-access policy (`users` resolves to `read`; mark it `read_write` in your MIDPLANE_POLICY_FILE to grant writes).",
  "auditId": "01HXXX..."
}
```

## `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

<ParamField path="schema" type="string">
  The schema to list. Must be a valid SQL identifier. Defaults to `public`.
</ParamField>

<ParamField path="database" type="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.
</ParamField>

### Returns

```json theme={null}
{
  "allowed": true,
  "tables": [
    { "schema": "public", "name": "users" },
    { "schema": "public", "name": "posts" }
  ],
  "auditId": "01HXXX..."
}
```

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:

```json theme={null}
{
  "databases": {
    "app": { "allowed": true, "tables": [/* ... */], "auditId": "01HXXX..." },
    "analytics": { "allowed": false, "error": "connection refused" }
  }
}
```

## `describe_table`

List a table's columns via a canned `information_schema.columns` query.

### Arguments

<ParamField path="table" type="string" required>
  The table name. Must be a valid SQL identifier.
</ParamField>

<ParamField path="schema" type="string">
  The schema. Must be a valid SQL identifier. Defaults to `public`.
</ParamField>

<ParamField path="database" type="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.
</ParamField>

### Returns

```json theme={null}
{
  "allowed": true,
  "columns": [
    { "name": "id", "type": "bigint", "nullable": false, "default": "nextval('users_id_seq')" },
    { "name": "email", "type": "text", "nullable": false, "default": null }
  ],
  "auditId": "01HXXX..."
}
```

## `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

```json theme={null}
{
  "databases": [
    {
      "name": "app",
      "table_access_default": "read",
      "guardrails_block_unqualified_dml": true,
      "guardrails_block_ddl": true
    },
    {
      "name": "analytics",
      "table_access_default": "read",
      "guardrails_block_unqualified_dml": true,
      "guardrails_block_ddl": true
    }
  ]
}
```

## Single-database vs multi-database surface

| Tool             | Single-DB | Multi-DB | `database` arg            |
| ---------------- | --------- | -------- | ------------------------- |
| `query`          | ✓         | ✓        | required                  |
| `list_tables`    | ✓         | ✓        | optional (omit → fan out) |
| `describe_table` | ✓         | ✓        | required                  |
| `list_databases` | —         | ✓        | n/a (no args)             |

## 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.

<Note>
  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](/docs/reference/environment-variables).
</Note>

### `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.

|             |                                                                          |
| ----------- | ------------------------------------------------------------------------ |
| **Method**  | `POST` (also `GET` / `DELETE` for the streamable-HTTP session lifecycle) |
| **Path**    | `/mcp`                                                                   |
| **Auth**    | None at the engine on self-host. (Cloud fronts it with a per-token URL.) |
| **Purpose** | Carry the agent's MCP session: `initialize`, then tool calls.            |

You don't call this by hand — see [connecting your agent](/docs/agents/overview). 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.

|             |                           |
| ----------- | ------------------------- |
| **Method**  | `GET`                     |
| **Path**    | `/health`                 |
| **Auth**    | None                      |
| **Purpose** | Confirm the server is up. |

```bash theme={null}
curl http://localhost:8080/health
# {"ok":true}
```

### `GET /audit/since/<cursor>?limit=N`

Pull [audit log](/docs/reference/audit-events) 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.

|             |                                                                |
| ----------- | -------------------------------------------------------------- |
| **Method**  | `GET`                                                          |
| **Path**    | `/audit/since/<cursor>`                                        |
| **Query**   | `limit` — page size; defaults to `500`, max `1000`.            |
| **Auth**    | `Authorization: Bearer <INDEXER_TOKEN>` (constant-time check). |
| **Purpose** | Page through audit rows for an external indexer.               |

Response: `{ "rows": [...], "next_cursor": "<id>" | null }`. `next_cursor` is `null` when no rows remain.

```bash theme={null}
curl -sH "Authorization: Bearer $INDEXER_TOKEN" \
  "http://localhost:8080/audit/since/0?limit=100"
```

**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 }`.

|             |                                                                           |
| ----------- | ------------------------------------------------------------------------- |
| **Method**  | `DELETE`                                                                  |
| **Path**    | `/audit/before/<cursor>`                                                  |
| **Auth**    | `Authorization: Bearer <INDEXER_TOKEN>`.                                  |
| **Purpose** | Acknowledge pulled rows by deleting them after they're durable elsewhere. |

```bash theme={null}
curl -sX DELETE -H "Authorization: Bearer $INDEXER_TOKEN" \
  "http://localhost:8080/audit/before/<last-id-you-confirmed>"
```

<Warning>
  Pull-then-delete is the contract. Don't `DELETE` rows you haven't read out — deletion is permanent.
</Warning>

**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`](/docs/reference/audit-events) audit event.

|             |                                                                                      |
| ----------- | ------------------------------------------------------------------------------------ |
| **Method**  | `POST`                                                                               |
| **Path**    | `/admin/policy`                                                                      |
| **Body**    | The new policy YAML (text).                                                          |
| **Auth**    | `Authorization: Bearer <INDEXER_TOKEN>` (same token, same `404`-when-unset posture). |
| **Purpose** | Apply a policy edit live.                                                            |

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.

```bash theme={null}
curl -sX POST -H "Authorization: Bearer $INDEXER_TOKEN" \
  --data-binary @policy.yaml \
  http://localhost:8080/admin/policy
# {"ok":true,"applied_at":"2026-06-02T12:00:00.000Z"}
```

**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`](/docs/reference/cli#policy-test).

|             |                                                                                      |
| ----------- | ------------------------------------------------------------------------------------ |
| **Method**  | `POST`                                                                               |
| **Path**    | `/admin/dry-run`                                                                     |
| **Body**    | JSON — exactly one of `probes` or `sql`.                                             |
| **Auth**    | `Authorization: Bearer <INDEXER_TOKEN>` (same token, same `404`-when-unset posture). |
| **Purpose** | Test a policy decision with no execution and no database connection.                 |

**Request:**

```json theme={null}
{
  "database": "app",
  "probes": [
    { "table": "orders", "action": "select" },
    { "table": "customers", "action": "delete" }
  ]
}
```

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`:**

```json theme={null}
{
  "verdicts": [
    { "probe": { "table": "orders", "action": "select" },
      "decision": "allow", "reason": "...", "matched_rule": "default:read",
      "tables": ["public.orders"], "action": "select" }
  ],
  "truncated": false,
  "total_tables": 12,
  "policy_hash": "..."
}
```

`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

<Columns cols={2}>
  <Card title="Denial reasons" icon="ban" href="/docs/reference/denial-reasons">
    What each `policy_rule` means and how to fix it.
  </Card>

  <Card title="Audit events" icon="scroll-text" href="/docs/reference/audit-events">
    The rows `auditId` and the pull endpoints return.
  </Card>
</Columns>
