Two layers protect against accidental or malicious damage: a read-only SQL validator in front of the read tools, and response sanitization / size caps on everything that comes back. Bearer auth and loopback binding protect the HTTP surface.
None of this makes the write tools (execute, transaction) safe — those exist to write. Scope
what the configured database user can do at the database level; the validator only guards the
read path.
Source of truth: src/utils/sql-validator.ts, src/utils/response.ts, src/transport/http.ts.
query and explain_query run their SQL through validateReadonlySql before it reaches a
connector. It normalizes the SQL (strips string literals to inert placeholders, strips comments,
collapses whitespace) and then rejects the statement if any check below fails.
Rejected if any of these appear as a whole word (case-insensitive), anywhere in the normalized
SQL — so a write hidden inside a CTE (WITH x AS (DELETE …)) or a subquery is still caught:
INSERT UPDATE DELETE DROP ALTER TRUNCATE
CREATE GRANT REVOKE REPLACE MERGE COPY CALL
Rejected if invoked as a function call (name directly followed by (), so a column or table
merely named url or file_uploads is left alone. One dialect-agnostic list covers both engines:
PostgreSQL — admin / superuser, filesystem, cross-server, DoS:
pg_terminate_backend pg_cancel_backend pg_reload_conf
pg_read_file pg_read_binary_file pg_stat_file
pg_ls_dir pg_ls_logdir pg_ls_waldir pg_ls_tmpdir pg_ls_archive_statusdir
pg_file_write pg_file_rename pg_file_unlink lo_import lo_export
dblink dblink_exec dblink_connect dblink_connect_u
dblink_send_query dblink_open dblink_fetch dblink_close
pg_sleep pg_logical_emit_message pg_create_restore_point
pg_switch_wal pg_promote set_config
ClickHouse — table functions that reach outside the database (SSRF / exfiltration):
url file s3 remote remoteSecure mysql postgresql
jdbc odbc hdfs azureBlobStorage executable
The normalization is built to close the usual evasions:
- Comment-hidden writes — block (
/* */) and line (--) comments are stripped before matching, soSELECT 1 -- \nDROP TABLE tis caught. - CTE- or subquery-wrapped writes — the keyword match scans the whole statement, not just the leading verb.
- Multiple statements — a
;followed by more SQL is rejected outright (Multiple statements are not allowed). String literals are removed first, so a;inside a string doesn't false-positive. - Quoted-identifier tricks — double-quoted identifiers are unwrapped (not blanked) before
matching, because in PostgreSQL/ClickHouse
"pg_terminate_backend"(1)invokes the same function as the unquoted form. Failing safe here can at worst reject a benign read whose column happens to equal a denied name.
On success the validator returns normalizedSql — the original SQL with comments and trailing
semicolons removed but string/identifier content byte-for-byte intact (the placeholder text
used for keyword matching never reaches the database). That is wrapped as:
SELECT * FROM (<normalizedSql>) AS _q LIMIT <n>and, as defense-in-depth beyond the validator:
- PostgreSQL runs it (and
EXPLAIN ANALYZE) inside aBEGIN TRANSACTION READ ONLY, which blocks writes at the server even if a keyword slipped through. - ClickHouse runs it with
readonly: "1", which reliably blocks writes and settings changes.
Every row payload is sanitized and size-capped before it leaves the server — applied to write
results too, since a RETURNING clause can surface attacker-influenced data.
| Cap | Value | Behavior |
|---|---|---|
| Per-cell string | 10 000 chars | Oversized strings truncated to "<first 10k>... [truncated, N chars total]" |
| Binary cell | 16-byte preview | Buffer/Uint8Array rendered as <binary N bytes: <hex>...> (raw bytes never serialized) |
| Total payload | 1 000 000 chars | If the serialized JSON exceeds this and carries rows, the row count is halved repeatedly until it fits; response then carries truncated: true, returnedRows: <n> |
The per-cell and payload caps apply in both json and table
row formats.
The query tool's maxRows parameter can only lower the configured cap, never raise it
(min(requested, configured)). execute/transaction execute slice returned rows to the
configured maxRows and flag it with truncatedAt. See Truncation fields.
- Bearer auth — optional but strongly recommended for any non-loopback bind. Token comparison
is constant-time; a wrong or missing token on
/mcpreturns401. See Transports > authentication. - Bind to loopback — the default
hostis127.0.0.1. Binding a non-loopback host without auth logs a startup warning; don't ignore it. Put the server behind auth or a trusted network boundary before exposing it. /healthis unprotected but returns only{ "status": "ok" }to unauthenticated callers when auth is configured, so it doesn't leak the session count or database ids.