mcp-read-only-sql
Allows read-only SQL queries on ClickHouse databases with enforced read-only mode, timeout protection, and support for both Python and CLI implementations.
Imports existing database connections from a DBeaver workspace into the MCP server's configuration.
Allows read-only SQL queries on PostgreSQL databases with enforced read-only mode, timeout protection, and support for both Python and CLI implementations.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-read-only-sqlshow me the first 10 rows from the orders table"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
MCP Read-Only SQL Server
A secure MCP (Model Context Protocol) server that provides read-only SQL access to PostgreSQL and ClickHouse databases with built-in safety features.
Default layout:
Config:
~/.config/lukleh/mcp-read-only-sql/connections.yamlCredentials: stored in
connections.yamlState:
~/.local/state/lukleh/mcp-read-only-sql/Query results:
~/.local/state/lukleh/mcp-read-only-sql/results/Cache:
~/.cache/lukleh/mcp-read-only-sql/
Security
Read-only access is enforced in three independent layers:
Client-side statement policy - PostgreSQL queries are parsed with PostgreSQL's own grammar and checked against an allow-list before anything is sent; every connector forwards a single statement only
Database-level read-only - PostgreSQL sessions run in a read-only transaction, ClickHouse sessions run with
readonly=1Timeout protection - Connection and query timeouts are configurable per connection, with a hard timeout around the whole call
All write operations (INSERT, UPDATE, DELETE, etc.) are blocked at the database level. Successful query results are written to state_dir/results with 0600 permissions instead of being returned inline.
How Read-Only Is Enforced
PostgreSQL (both implementations) – Every query is parsed with PostgreSQL's own grammar (
pglast) before it leaves the server. OnlySELECT,EXPLAINandSHOWshapes are accepted, and every function call must be on an allow-list ofpg_catalogfunctions PostgreSQL declares side-effect free. This refuses the things a read-only transaction alone does not stop, such asCOPY ... TO PROGRAM,DOblocks, and calls likepg_terminate_backend()orpg_read_file(). The list can be extended per connection withallowed_functions.PostgreSQL (Python) – Every statement runs inside one explicit transaction whose first statement is
SET TRANSACTION READ ONLY; SET LOCAL statement_timeout = ..., the same shape the CLI uses. Nothing is session state, so the guarantees hold behind a transaction pooler such as PgBouncer. Connections also ask fordefault_transaction_read_only=onat startup and fall back without it when a pooler rejects the option. SELECT-shaped statements are read through a server-side cursor in batches.PostgreSQL (CLI) – Queries are wrapped in a transaction that issues
SET TRANSACTION READ ONLY;before execution. Only a single statement (plus optional trailing semicolon) is forwarded, and allpsqlinvocations include--single-transaction,-v ON_ERROR_STOP=1, andPGOPTIONS=-c default_transaction_read_only=onfor defence in depth.ClickHouse (Python) – The driver sets
readonly=1plus connection/query timeouts, forcing the server to reject any write or DDL attempt.ClickHouse (CLI) –
clickhouse-clientis invoked with--readonly=1,--max_execution_time, and connection timeouts, turning the session into a read-only context.ClickHouse (both) – A login whose profile already sets
readonlyrefuses the client-sidereadonlyandmax_execution_timesettings, and a profile constraint can lock either one. Before the first statement on a server, each connector readssystem.settings, which every login can read, without any client-side settings, and sends only the settings the profile leaves changeable. A login whose profile locksreadonlyat 0 can be made read-only by nobody and is refused. The answer is remembered per server, and a statement is never re-run with weaker settings than it was first sent with.
The shared connector base also applies hard timeouts, giving the MCP server deterministic behaviour even if the database misbehaves.
Because bare names resolve through search_path, the guard also asks the server, right before the query, whether any bare function or operator it uses has a definition outside pg_catalog in a schema the session can see, and refuses the query if so. This matches on name only, so a visible public.length(integer) refuses length('abc') even though PostgreSQL would pick the catalog function; the error says to qualify the call as pg_catalog.length(...) or list the function. Resolving overloads client-side would mean reimplementing PostgreSQL's function resolution, so the guard stays conservative. The guard is still a filter, not a privilege boundary. It cannot see inside views, user-defined types or casts that already exist in the database, and it does not reduce what the configured login is allowed to do. Log in with a role that can only read (for PostgreSQL 14+, pg_read_all_data); a superuser login stays a superuser login.
See READ_ONLY_ENFORCEMENT_MATRIX.md for a statement-by-statement view of every write-capable command and the tests that enforce it.
Related MCP server: FastAPI Database MCP Server
Key Features
Read-only enforcement - Multiple layers of protection against writes
Multi-database support - PostgreSQL and ClickHouse
Dual implementations - Choose between Python (database drivers in the package) or CLI (uses
psql/clickhouse-client)SSH tunnel support - Tunnels go through the system
sshfor both implementations: key authentication, password authentication withsshpass, agent-loaded identities and host certificates, andssh's own host-key verificationSecurity built-in - Timeouts, managed result files, session controls
DBeaver import - Import existing connections easily
Prerequisites
uv for package installs and ephemeral
uvxrunsssh(OpenSSH 7.6 or newer) if any connection uses an SSH tunnel; both implementations tunnel through itpsql12 or newer if you want PostgreSQL connections withimplementation: cli(its CSV output mode is used)clickhouse-clientif you want ClickHouse connections withimplementation: clisshpassonly if you want SSH tunnels with password authenticationjust is optional and only needed for repo-local contributor workflows
Install the optional CLI binaries with your operating system's package manager or the official PostgreSQL / ClickHouse packages for your environment.
The CLI binaries are located via the override environment variable (MCP_READ_ONLY_SQL_PSQL_PATH / MCP_READ_ONLY_SQL_CLICKHOUSE_CLIENT_PATH) if set, then PATH, then OS-specific fallback locations (e.g. Homebrew keg-only libpq on macOS, packaged PostgreSQL directories on Linux). If a binary is installed somewhere not on PATH, set the matching variable to its full path.
The SQL package keeps both execution models first-class:
implementation: cliuses the official database client binaries you already trust in operations.implementation: pythonstays fully supported when you want a setup with no external database client binaries.
You can verify optional CLI dependencies with:
psql --version
clickhouse-client --version
sshpass -VQuick Start
1. Install or Run the Server
For the published package, prefer @latest with uvx:
uvx mcp-read-only-sql@latest --write-sample-configOr install it once and reuse the command directly:
uv tool install mcp-read-only-sql
mcp-read-only-sql --write-sample-configWhen using uvx with the published package, prefer
mcp-read-only-sql@latest in user-facing docs and MCP client configs. This
avoids reusing a stale cached tool environment after a new release is
published.
For one-off runs from this checkout, use uvx --from .:
uvx --from . mcp-read-only-sql --write-sample-configFor a persistent local install from this checkout:
uv tool install .
mcp-read-only-sql --write-sample-configFor checkout-based commands below, you can replace uvx --from . mcp-read-only-sql
with uvx mcp-read-only-sql@latest once you want to use the published package instead.
That creates:
~/.config/lukleh/mcp-read-only-sql/connections.yaml~/.local/state/lukleh/mcp-read-only-sql/~/.local/state/lukleh/mcp-read-only-sql/results/~/.cache/lukleh/mcp-read-only-sql/
2. Choose an Implementation Per Connection
connections.yaml supports both implementations side by side:
- connection_name: postgres_cli
type: postgresql
implementation: cli
servers:
- "db.example.com:5432"
db: analytics
username: analyst
password: change_me
- connection_name: clickhouse_python
type: clickhouse
implementation: python
servers:
- "analytics.example.com:8123"
db: default
username: analyst
password: change_meUse CLI mode when you want the behavior of psql or clickhouse-client, or when those tools are already part of your operational setup. Use Python mode when you want a package-only setup with no extra system binaries.
3. Import or Edit connections.yaml
You can edit the generated sample directly, or import a DBeaver workspace:
uvx --from . mcp-read-only-sql import-dbeaver \
~/Library/DBeaverData/workspace6/General/.dbeaverThat writes connections.yaml with any decrypted passwords stored directly in the file. The importer writes user-only permissions and keeps timestamped backups when it overwrites an existing file.
connections.yamlcontains credentials. Keep it private, do not commit it, and restart the MCP process after editing it so changes take effect.
To allow a connection to access multiple databases, add an explicit allowlist:
- connection_name: analytics_multi
type: postgresql
servers:
- "analytics.example.com:5432"
allowed_databases:
- analytics
- reporting
default_database: analytics
username: analyst
password: change_meIf you only set db, that single database is implicitly the allowlist.
PostgreSQL connections refuse function calls outside the built-in allow-list. If a query legitimately needs another function, list it under allowed_functions, spelled as the catalog spells it. A schema.name entry permits both schema.name(...) and the bare name(...), and pins the bare call to that schema: if another visible schema also defines the name, the query is refused. A bare entry permits only the bare call and trusts whatever it resolves to:
- connection_name: analytics
type: postgresql
servers:
- "analytics.example.com:5432"
db: analytics
username: analyst
password: change_me
allowed_functions:
- public.customer_segment
- st_distance4. Validate and Test Connections
The package includes management subcommands for connection validation and dry-run testing:
uvx --from . mcp-read-only-sql validate-config
uvx --from . mcp-read-only-sql test-connection
uvx --from . mcp-read-only-sql test-connection my_postgres
uvx --from . mcp-read-only-sql test-ssh-tunnel
uvx --from . mcp-read-only-sql --print-pathsIf you are working from a clone, the same helpers are available through just:
just validate
just test-connection
just test-connection my_postgres
just print-paths5. Add the MCP Server to Your Client
For Claude Code:
claude mcp add mcp-read-only-sql -- uvx mcp-read-only-sql@latestFor Codex:
codex mcp add mcp-read-only-sql -- uvx mcp-read-only-sql@latestFor manual testing with a different config root:
uvx mcp-read-only-sql@latest --config-dir /path/to/config-dir --print-pathsMCP Tools
run_query_read_only
Execute read-only SQL queries on configured databases.
{
"connection_name": "my_postgres",
"query": "SELECT * FROM users LIMIT 10",
"database": "analytics",
"server": "db2.example.com"
}Parameters:
connection_name(required): Identifier returned by list_connectionsquery(required): SQL text that must remain read-onlydatabase(optional): Database to use (must be listed in the connection's allowlist).server(optional): Hostname to target a specific server. If not provided, uses the first server in the connection's list.
Returns: Absolute path to a TSV file created under the server's managed
state directory, typically ~/.local/state/lukleh/mcp-read-only-sql/results/.
Successful query results are persisted with 0600 permissions and are no
longer returned inline on success.
The file holds a header line and one line per row, tab-separated. For
PostgreSQL both implementations follow the quoting rules of psql --csv:
NULL and the empty string are empty, and a value containing a tab, a double
quote or a line break is enclosed in double quotes with inner quotes doubled,
so such a row may span several lines. ClickHouse results use ClickHouse's own
TabSeparatedWithNames escaping (\N for NULL, backslash escapes).
Result files accumulate under state_dir/results/ until you remove them.
If you do not want to retain old query output, periodically clean
~/.local/state/lukleh/mcp-read-only-sql/results/.
list_connections
List all available database connections.
Returns: Tab-separated text with columns name, type, description,
servers, database, databases, and user. database is the default database,
while databases lists the allowlisted databases (comma-separated). The servers
column lists comma-separated hostnames after resolving SSH/VPN tunnels, so entries
reflect the endpoints the agent should reference.
Implementation Matrix
Database Support by Implementation
Feature | PostgreSQL CLI | PostgreSQL Python | ClickHouse CLI | ClickHouse Python |
Protocol | Native PostgreSQL | Native PostgreSQL | Native ClickHouse | HTTP/HTTPS |
Default Port | 5432 | 5432 | 9000 | 8123 |
Supported Ports | Any PostgreSQL port | Any PostgreSQL port | 9000, 9440 (native + TLS) | 8123 (HTTP), 8443 (HTTPS) |
TLS/SSL Support | ✅ Yes | ✅ Yes | ✅ Yes (--secure for 9440) | ✅ Yes (HTTPS on 8443) |
Read-Only Method |
|
|
|
|
SSH Key Auth | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
SSH Password Auth | ✅ Yes (requires | ✅ Yes (requires | ✅ Yes (requires | ✅ Yes (requires |
Timeout Control | ✅ Via SQL | ✅ Driver-level | ✅ CLI flags | ✅ Driver-level |
Result Streaming | ✅ Yes | ✅ Yes (server-side cursor, 1000 rows per fetch) | ✅ Yes | ✅ Yes |
Binary Required |
|
|
|
|
ClickHouse Port Compatibility
Port | Protocol | CLI Support | Python Support | Notes |
8123 | HTTP | ⚠️ Auto-converts to 9000 | ✅ Native support | Default HTTP interface |
8443 | HTTPS | ⚠️ Auto-converts to 9440 (--secure) | ✅ Native support | Secure HTTP interface |
9000 | Native TCP | ✅ Native support | ⚠️ Auto-converts to 8123 | Default native protocol |
9440 | Native TCP (TLS) | ✅ Native support (--secure) | ⚠️ Auto-converts to 8443 (HTTPS) | Secure native protocol |
Custom (e.g., 2650) | Usually HTTP | ❌ No conversion | ✅ Yes | HAProxy/Load balancers - NO auto-conversion |
Important Notes:
ClickHouse CLI (
clickhouse-client) uses native protocol ports (9000, 9440)ClickHouse Python (using
clickhouse-connect) uses HTTP/HTTPS ports (8123, 8443)Port mismatches are automatically handled - see below
Automatic Port Handling (Bidirectional):
ClickHouse Python Implementation:
Direct connections: Port 9000 → automatically uses port 8123 on the same host
SSH tunnels: Port 9000 → automatically tunnels to remote port 8123
SSH tunnels: Port 9440 → automatically tunnels to remote port 8443
ClickHouse CLI Implementation:
Direct connections: Port 8123 → automatically uses port 9000 on the same host
SSH tunnels: Port 8123 → automatically tunnels to remote port 9000
SSH tunnels: Port 8443 → automatically tunnels to remote port 9440
✨ This means you can use the same configuration for both CLI and Python implementations, regardless of which port you specify (8123 or 9000) - each implementation will automatically convert to the correct protocol port it needs!
Choosing an Implementation
Use CLI implementation when:
You have the database CLI tools installed (
psql,clickhouse-client)You prefer not to install Python database drivers
You're connecting to ClickHouse on native ports (9000, 9440)
You want the exact behavior of the official CLI tools
Use Python implementation when:
You want no database client binaries
You're connecting to ClickHouse HTTP interface (port 8123, 8443)
You want more programmatic control over connections
Configuration Notes
HAProxy and Custom Ports
When using HAProxy or other proxy servers with ClickHouse:
HAProxy typically provides HTTP interface on custom ports (e.g., 2650, 8000, etc.)
Custom ports are NOT auto-converted - the system only converts standard ports (8123, 8443, 9000, 9440)
For HAProxy connections: Use
implementation: pythonsince HAProxy usually proxies HTTP trafficIf you get "Unexpected packet" errors with CLI on custom ports, switch to Python implementation
Example HAProxy configuration:
- connection_name: clickhouse_haproxy
type: clickhouse
servers:
- haproxy-server:2650 # Custom HAProxy port
implementation: python # Use Python for HTTP protocol
# ... other settingsMultiple Servers
When multiple servers are specified in a connection's configuration, the system currently uses only the first server in the list. Load balancing across servers is not implemented.
SSH Authentication
One tunnel: Both implementations start the system
sshfor the tunnel, so everythingsshsupports applies: keys, agents, certificates,~/.ssh/config.ssh_tunnel.private_keyis passed with-i;ssh_tunnel.passwordneedssshpassSSH agent / identity fallback: Omit both
private_keyandpasswordandsshruns without-i, so agent identities and matching identity options are used. The configuredssh_tunnel.host,user, andportare still passed explicitly; full OpenSSHHostalias fallback for those fields is future work.Timeout behavior: Tunnel startup defaults to 30 seconds to allow
sshinteractive approval flows such as hardware tokens or short-lived certificate prompts. Setssh_tunnel.ssh_timeoutto a lower value when fail-fast behavior is preferred for unreachable bastions.Host-key verification:
sshverifies the bastion's host key, and by default the key must already be known:ssh_tunnel.host_key_checkingtakes the OpenSSHStrictHostKeyCheckingvalues and defaults toyes. A bastion you have connected to withsshbefore is already known. To provision one you have not, fetch its key withssh-keyscan -p <port> <host>, compare the fingerprint (ssh-keygen -lfon the output) with one you got from the bastion's operator or console, and only then append the line to~/.ssh/known_hosts;ssh-keyscanitself does not verify anything, so appending it unseen is trust on first use, which is whataccept-newdoes with a safety check.ssh_tunnel.known_hosts_filecan point at another file;/etc/ssh/ssh_known_hostsis read as well.accept-newis the explicit trust-on-first-use mode: the bastion is recorded on first use and refused if its key changes, and becausesshonly warns when it cannot write the file, the tunnel passes known_hosts files explicitly and checks that a first-use key was saved. It usesssh_tunnel.known_hosts_filewhen set, otherwise the standard user and global files. This mode overridesUserKnownHostsFileandGlobalKnownHostsFilefrom~/.ssh/config; setssh_tunnel.known_hosts_fileto use a custom file.HostKeyAliasandHostNamestill apply. A bastion covered only by an@cert-authorityentry is always connected to strictly, so a valid host certificate connects and a raw key is refused rather than recorded.norestores the previous behaviour: every key is trusted and none is recorded. A changed key surfaces as anSSH:error naming host key verification; remove the stale line from the file once you have confirmed the new key.
This server cannot be deployed
Maintenance
Related MCP Connectors
Safe, read-only Postgres and MySQL access for AI agents. Audit log + column-level controls.
Query 40 databases from Claude, ChatGPT, or Cursor — on any device. Read-only, encrypted, audited.
Query PostgreSQL databases in plain English — LLM-generated, safety-validated SQL.
Query your org's data in natural language — read-only MCP access to SQL, NoSQL, files & warehouses.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceProvides read-only access to PostgreSQL databases with schema inspection, query execution in multiple formats (JSON, CSV, Markdown), and query history tracking with built-in security features.-
- FlicenseNot gradedqualityFmaintenanceProvides read-only SQL query access to Postgres and DuckDB databases via MCP tools, with extensive security hardening for public endpoints.1-
- AlicenseNot gradedqualityDmaintenanceRead-only access to PostgreSQL databases, enabling schema inspection and safe SQL queries.8 npmMIT
- AlicenseAqualityBmaintenanceRead-only Postgres access with a policy gate that blocks writes and restricts visible tables and columns.63 npmMIT