Skip to main content

03 — MCP Tool Surface

The set of tools the gateway exposes to agents. This is the agent-facing contract. Keep it small and boring.

Design rules

  1. Read-only by default. A write-capable tool exists only if 06-permissions explicitly enables it for the calling identity.
  2. Every tool accepts server + database (except list_servers / list_databases). The pair fully scopes the call.
  3. reason is optional in the protocol but may be required by policy. If policy requires it and it's absent, the gateway returns a structured error telling the agent to ask the user for one.
  4. Results are size-capped. run_query and sample_table accept a caller-supplied limit; most-restrictive-wins: the effective row limit is min(caller.limit, grant.row_limit, gateway-ceiling). The gateway-wide ceiling is 100,000 rows and applies regardless of what the caller asks for or whether the grant sets row_limit — an absent or oversized grant still clamps to the ceiling, never "unbounded". A grant may only tighten below the ceiling; it may not loosen past it. When neither the caller nor the grant names a limit, run_query defaults to 1,000 rows and sample_table defaults to 10 rows. Other result-returning tools (list_servers, list_databases, describe_schema, explain) do not accept a caller limit; they enforce internal caps sized to their output shape.
  5. No tool exposes credentials, connection strings, or hostnames. Servers and databases are referenced by their config-defined logical name.

Tools

list_servers

Returns the servers the caller can see — logical name, kind (postgres, mysql, mssql, …), human description. No connection info.

list_databases

Args: server. Returns databases on that server visible to the caller, with description and tags.

describe_schema

Args: server, database, optional schema, optional table. Returns tables/columns/types/PK/FK/indexes. Cached aggressively — schema doesn't change per query.

sample_table

Args: server, database, table, optional schema, optional limit (default 10, capped), optional reason. Returns a small sample. Useful for "what does this data look like" without writing SQL.

Requires query_read, not schema_read. This tool returns row data, so it is authorized exactly like run_query and honours the same require_reason constraint. A schema_read grant covers metadata only (describe_schema, list_databases).

run_query

Args: server, database, sql, optional limit, optional reason. Executes under the caller's grant with statement timeout. Returns rows + truncation flag + execution stats. The primary tool.

Read vs. write is per-grant. With a query_read grant the sql guard accepts only read-only statements (SELECT / EXPLAIN). A query_write grant on the target (server, database) additionally lets a single top-level INSERT / UPDATE / DELETE through — data writes only. Schema modification (CREATE / ALTER / DROP / TRUNCATE), GRANT / REVOKE, COPY, transaction control, and multi-statement bodies are rejected in both modes; the gateway never issues DDL. Writes also require the target-DB role to actually hold write privileges — the gateway does not provision them (see 06-permissions and CLAUDE.md non-negotiable #3). Writes commit synchronously before the response returns, and every write is audited exactly like a read. Mongo targets remain read-only regardless of grant.

Statement-timeout ceiling: every query is subject to a hard 30 s ceiling regardless of the per-grant statement_timeout_ms value. A grant may set a shorter timeout; it may not exceed 30 s — the gateway clamps it. The timeout is enforced both DB-side (SET LOCAL statement_timeout) and by a Tokio guard as belt-and-suspenders. A query that exceeds it returns timeout.

EXPLAIN ANALYZE is rejected. EXPLAIN ANALYZE executes the query and can therefore run write-containing CTEs on a read-only role, defeating the read-only guarantee. The sql guard rejects it before the query reaches the DB; the caller receives forbidden_sql.

null means SQL NULL — always. A value the gateway cannot render as JSON is never returned as null, because an agent acting on the result cannot tell a fabricated null from a real one. Such a cell comes back as {"unsupported_type": "<pg type>"} instead, naming the type so the caller can cast it (SELECT my_col::text).

Zero-row results still name their columns — best-effort. A successful query that matches no rows returns rows: [], truncated: false, and columns populated with the selected column names in their left-to-right order — same shape as a non-empty result. This lets the caller distinguish "your filter matched nothing" from "that table has no such columns" without a second round-trip. Column naming for zero-row Postgres results relies on a best-effort Describe after the row stream completes and before the transaction commits; in the rare case that Describe fails, the query still succeeds but columns comes back as [] (rows: [] and truncated: false are unaffected).

Rendering rules for the non-obvious types:

Postgres typeJSON
numeric / decimalstring, e.g. "1234.5600" — never a float, so money keeps full precision
timestamptzRFC 3339 string, normalised to UTC
timestamp / date / timeISO-8601 string, no zone invented
uuidstring
byteaPostgres' own \x-prefixed hex string
json / jsonbinlined as JSON
arrays, enums, ranges, user-defined{"unsupported_type": "…"}

explain

Args: server, database, sql, optional reason. Returns EXPLAIN (or vendor equivalent) without executing. Lets the agent estimate cost before running expensive queries. Honours the same require_reason constraint as run_query.

EXPLAIN ANALYZE is rejected — same reason as in run_query. Use plain EXPLAIN instead.

get_query_history

Args: server, database, optional since (RFC 3339), optional limit. Returns the caller's own recent queries (request id, SQL, reason, timestamp, duration, row count, outcome) for that (server, database), ordered newest-first. Lets the agent recover context across sessions without exposing other users' queries.

Scoping is the security-critical contract. The filter is on the SSO-verified identity (identity.user_sub) from the session middleware — NEVER on a client-supplied user field. The arguments struct uses #[serde(deny_unknown_fields)]; any attempt to pass user (or any other unknown key) is rejected with JSON-RPC invalid_params before the request reaches the audit table. The WHERE clause is user_sub = $1 AND server_name = $2 AND database_name = $3 (the composite index audit_calls_user_occurred_idx from migration 0003 covers it).

limit defaults to 100 and is clamped to GATEWAY_ROW_LIMIT_CEILING (100,000); a hostile u32::MAX request is clamped, not honoured. The truncated flag reports exactly that clamp: it is true when the caller's limit exceeded the ceiling and was lowered. It does not mean "more entries exist" — a caller who asks for 10 of their 50 entries gets 10 with truncated: false, because nothing was withheld beyond what they asked for. To see further back, widen since rather than raising limit.

since is parsed at the edge as RFC 3339; a malformed value is rejected with invalid_params rather than silently dropping the filter.

Requires the history_read grant, which is standalone (per #170query_read does not imply it). A caller without an explicit history_read grant for (server, database) gets forbidden. The call itself is audited like any other tool dispatch (tool = "get_query_history", sql = NULL).

Implemented with #169.

Errors

Errors are structured JSON, not free-text strings. Shape: { "error": { "category": "<code>", "code": "<detail>" } }.

CodeHTTPWhen
unauthenticated401Token missing/expired — agent triggers re-login
forbidden403Authenticated but permission denied for this server/db/action
forbidden_sql403SQL rejected before reaching the DB: statement not covered by the grant (a write without query_write, or a schema mod / COPY / multi-statement in any mode), EXPLAIN ANALYZE, or dangerous function (pg_read_file, lo_export, …)
invalid_arguments400Post-deserialisation argument validation failed (limit: 0, malformed since, other per-tool bounds). JSON-RPC envelope carries invalid_params (-32602); the audit row records the code so the boundary rejection is still accountable.
reason_required400Policy requires a reason for this call; none provided
timeout408Statement timeout fired (30 s ceiling)
row_limit_exceeded200Result truncated at configured cap (flag in response, not an error response)
syntax_error400DB rejected the SQL
unavailable503DB unreachable or pool exhausted
rate_limited429Calling identity has too many concurrent in-flight requests (per-identity cap). Retry-After: 1 header is set.
service_overloaded503Gateway-wide concurrency ceiling reached. Retry-After: 1 header is set.
internal500Bug. Has a request ID that matches a server-side log line

Every error includes a request_id the user can paste back to ops.

Resource safety

Two independent concurrency caps protect the query path:

LimitDefaultResponse when exceeded
Global (process-wide)512 concurrent requests503 service_overloaded
Per-identity (per SSO sub)16 concurrent requests429 rate_limited

Both caps are checked on the bearer-gated router after authentication. A per-identity permit is held for the full lifetime of the request. The global cap is checked first to keep the per-identity map lookup cheap on a saturated gateway.

The global cap also fronts the unauthenticated OAuth flow routes — POST /auth/login, GET /auth/callback, GET /authorize, POST /token, POST /revoke, POST /register — because each writes into a size-capped in-memory store (PendingFlows, AuthCodes) or drives IdP/DB work, so an unauthenticated flood must be bounded the same way authenticated traffic is. When the cap is exhausted these routes return 503 service_overloaded with Retry-After: 1, identical to the bearer-gated path. The per-identity cap does not apply here (no Identity extension exists pre-session). The configured MCP SSE endpoint (GET on mcp_path), discovery metadata, /healthz, /readyz, and /metrics are deliberately not gated — probes are trusted infra traffic and discovery documents are static.

The 30 s statement-timeout ceiling (see run_query above) is the complementary per-query bound: it limits how long one request can hold its permits.

What we don't expose

  • No DDL tools (create_table, drop_table, …) — those don't belong in a debugging gateway.
  • No DML tools (insert, update, delete) at the protocol layer. If writes are enabled by policy, they go through run_query with the role having write grants; the audit log captures the SQL.
  • No raw pg_dump / mysqldump style export. Bulk export is a different product.
  • No "run on all databases" — every call is scoped to one DB.