CLAUDE.md
May 21, 2026 · View on GitHub
Developer-facing notes for working on this repo. For user-facing setup, configuration, tool list, and env var reference, see README.md.
Project overview
Read-only PostgreSQL MCP server over stdio. Single-file server at database_read.py (~750 lines) plus a tests/ regression suite.
Architecture
database_read.py is intentionally single-file. Layout:
- Logging + config — JSON log events to stderr; env-driven safety/tuning constants.
- Env discovery (
_discover_database_urls,_normalize_env_name) — turnsDATABASE_URL_<ENV>vars into a name→URL map, with alias normalization (dev→local,prod→production, ...). - Engine cache (
_get_engine) — lazy, thread-safe, per-environment SQLAlchemy engines.atexitdisposes all on shutdown. - SQL safety (
validate_read_only_sql) —sqlparse-based: strips comments, rejects multi-statement payloads, rejects non-SELECT/WITHstarters, walks all tokens to block anyKeyword.DDL, writeKeyword.DML(INSERT/UPDATE/DELETE/MERGE/...), or_BLOCKED_ANYkeyword (INTO, which catchesSELECT * INTO new_t FROM ...). Replaces the old word-boundary regex which false-positived on identifiers likeis_deleted. Defense in depth: the wrapped query always runs in aSET TRANSACTION READ ONLYtransaction, so even a bypassed validator cannot mutate state. - Query execution (
execute_query) — wraps the user query asSELECT * FROM (q) AS _mcp_sub LIMIT :n+1 OFFSET :o, runs inside aREAD ONLYtransaction withSET LOCALtimeouts, streams results in batches, detects truncation via the extra row, and emitsquery_executed/query_failedlog events. Signal handlers (SIGINT/SIGTERM) only install when running on the main thread. - Schema allowlist (
_validate_schema,_quote_ident) — every tool that takes aschemaarg goes through the allowlist (DB_ALLOWED_SCHEMAS, defaultpublic). Identifiers used in unparameterized SQL go through_quote_ident, which rejects embedded"and\0. - Row serialization (
_jsonify_value) — convertsDecimal/UUID/datetime/date/time/bytes/nested containers into JSON-safe primitives before MCP returns them. - MCP tools — thin wrappers that call
execute_queryand shape responses; errors are returned as{"status": "error", "message": ...}rather than raising.
Running the server
uv sync
uv run python database_read.py
Testing
Two tiers:
- Unit — no DB. Validators, env discovery, JSON serialization, startup behavior, tool error paths (with mocked engine).
- Integration — gated on
MCP_TEST_DATABASE_URL. Spawns a unique schema per test, runs the real tool functions end-to-end, drops the schema on teardown. - Safety invariants (
tests/test_safety_invariants.py) — the canonical proof that no write reaches PG. Parametrized over every PG statement form that mutates state (DML, DDL, COPY, CALL, DO, LOCK, BEGIN/COMMIT, LISTEN/NOTIFY, VACUUM, GRANT/REVOKE, SELECT INTO, writable CTEs, comment-injection variants, ...). Includes defense-in-depth integration tests that monkeypatch out the validator and verify theREAD ONLYtransaction still rejects writes and leaves data unchanged.
# Unit only (always runs; integration auto-skips)
uv run pytest
# Full suite — point at any reachable Postgres
docker run --rm -d --name mcp_test_pg -e POSTGRES_PASSWORD=test -p 55432:5432 postgres:16
MCP_TEST_DATABASE_URL=postgresql://postgres:test@localhost:55432/postgres uv run pytest
docker stop mcp_test_pg
The integration fixture (mcp_env in tests/conftest.py) creates a unique throwaway schema, sets DB_ALLOWED_SCHEMAS to it, reloads the module, and clears the engine cache — tests can safely run against any Postgres without touching public.
Operational hardening
See docs/database-role-setup.md for the recommended deployment posture: dedicated SELECT-only Postgres role, optional redaction views for PHI/PII, verification queries, and the per-engine portability notes. The MCP's app-layer protections (validator, READ ONLY transaction, function blacklist) are best paired with a DB role that has no write grants — that turns "writes are blocked in four places" into a real four-layer wall.
Key dependencies
mcp[cli]>=1.26.0— FastMCPSQLAlchemy>=2.0.39— engine + Corepsycopg2-binary>=2.9.10— PG driversqlparse>=0.4.4— write/multi-statement detectionpytest,pytest-cov(dev) — regression suite