MCPg User Guide
How to use MCPg once it’s installed. For getting it installed see
installation.md; for the full per-tool
parameters see tools.md; for short task-shaped
recipes see cookbook.md.
This guide is the narrative walkthrough — read it top-to-bottom the first time, skim it as a reference after.
Contents
- What MCPg is
- Access modes & capability gates
- Dynamic session intent (opt-in runtime growth)
- Connecting an MCP client
- Working with the tools
- Multi-tenancy
- Read-replica routing
- Natural-language SQL
- Server-side cursors
- Data movement
- Reactive workflows: LISTEN / NOTIFY
- Staged migrations
- Migration history
- ORM bridges
- Audit trail
- Observability
- Rate limiting
- Caching
- Feature Flags
- Security defaults
- Troubleshooting
What MCPg is
MCPg is an MCP server that exposes a PostgreSQL database to an AI
agent through a fixed, audited set of 254 tools (read-only mode exposes ~186). The agent never
gets a raw database connection — it can only call the tools MCPg
registers, every call is validated, and every call is logged. MCPg
runs as a single async process and ships as both a PyPI package
(pip install mcpg) and a hardened Docker image.
Access modes & capability gates
The MCPG_ACCESS_MODE setting decides the default tool surface;
additional capability gates unlock higher-blast-radius families
within unrestricted mode.
| Mode | What’s exposed |
|---|---|
read-only (default) |
Catalog introspection, querying, search, health, EXPLAIN — all read-only. |
restricted |
The above plus data writes (DML): run_write, run_maintenance, cancel_query, terminate_backend. No schema changes (DDL), subprocess/shell, LISTEN/NOTIFY, or migrations — the “safe read-write” tier. |
unrestricted |
Everything: the write tools above plus the gated DDL / shell / listen / migrate families below. |
Within unrestricted, the gate vars decide which additional
families come along:
| Gate | Unlocks |
|---|---|
MCPG_ALLOW_DDL=true |
run_ddl, enable_extension, hypertable tools (create_hypertable, add_compression_policy, add_retention_policy), AGE graph DDL (create_graph, drop_graph), staged migrations (prepare_migration / complete_migration / cancel_migration / validate_migration / validate_migration_schema / list_pending_migrations). |
MCPG_ALLOW_SHELL=true |
Subprocess tools — dump_database, restore_database, copy_table_between_databases. Requires the PostgreSQL client binaries (pg_dump / pg_restore / psql) on PATH. |
MCPG_ALLOW_LISTEN=true |
subscribe_channel, poll_notifications, unsubscribe_channel, list_notification_subscriptions. |
Read-only is the default because the typical agent workflow is inspect first, modify second. Each gate is a deliberate opt-in so an experimentation deployment can’t drop a table by accident.
Dynamic session intent (opt-in runtime growth)
MCPG_SESSION_INTENT (roadmap 8.8) narrows the tool surface MCPg
advertises to a stated goal: the operator declares intent once at
server start, and MCPg removes every tool outside that intent from
the registry before the first tools/list request is answered. Five
built-in presets ship — lookup, migration, vector_rag, monitor,
and admin (the no-filter sentinel) — plus raw capability-bucket ids
for finer control (see mcpg.session_intent for the bucket
membership).
The core preset
MCPG_SESSION_INTENT=core is a sixth, tighter preset — no new flag
needed, just this preset name. Instead of a bucket of tools it’s a
curated list of ten headline schema/query tools (list_tables,
describe_table, get_compact_schema, list_indexes,
list_foreign_keys, compare_schemas, run_select, explain_query,
analyze_query_plan, translate_nl_to_sql) plus the two
always-kept introspection tools (describe_self, describe_tool).
On a default (read-only, nothing else set) deployment, that’s 12
tools total — down from the 186-tool read-only default. (core
declares two further tool names, run_ddl and run_write, that only
materialize under MCPG_ACCESS_MODE=unrestricted — a separate,
pre-existing access-mode gate that session_intent filtering doesn’t
touch; under unrestricted with every gate on, core resolves to 14
tools instead of 12. The full catalogue tops out at 254 tools at that
same maximal ceiling — 186 is what a real default deployment sees.)
Growing the surface at runtime: MCPG_DYNAMIC_SESSION_INTENT
By default a session’s tool surface is fixed for the life of the
server process — the only way to see more tools is to restart with a
wider (or unset) MCPG_SESSION_INTENT. Setting
MCPG_DYNAMIC_SESSION_INTENT=1 turns that into a per-session, runtime
decision instead, without a restart and without affecting any other
concurrent session:
export MCPG_SESSION_INTENT=core # optional: the static starting point
export MCPG_DYNAMIC_SESSION_INTENT=true # opt-in: sessions can grow past it
- Every session starts at the configured static intent — or
coreifMCPG_SESSION_INTENTis unset. (core, not “no filter”, is the dynamic layer’s default starting point: settingMCPG_DYNAMIC_SESSION_INTENT=1by itself, with nothing else configured, still narrows a fresh session’stools/listdown to 14 tools — the 10 core survivors plus all 4 always-kept tools (describe_self,describe_tool, and, because the flag is on, the two meta-tools themselves) — rather than the 186-tool read-only default. That’s 2 more than the 12 toolsMCPG_SESSION_INTENT=corealone gives you with the flag off, since the meta-tools aren’t registered — and therefore aren’t kept — in that case. Either way, the full surface stays registered server-side; the session grows from its starting point, non-destructively.) - Two always-visible meta-tools let a session grow itself:
list_session_intents()lists every bucket/tool-name preset, its resolved tool count against what’s currently registered, and whether it’s already enabled for this session;enable_session_intent(name)adds a preset name or raw bucket id to the session’s enabled set. - Growth is additive and idempotent — there’s no
disable— and scoped to one session (keyed on the transport’sMcp-Session-Idheader; stdio sessions share one implicit key since stdio is inherently single-session-per-process). - The two meta-tools add to the ceiling: the full maximal surface
(every
MCPG_ALLOW_*gate on) is 254 tools without this flag, 256 onceMCPG_DYNAMIC_SESSION_INTENT=1is also on.
Security note: visibility, not authorization
This is a visibility filter, not an authorization boundary. A
client that already knows a filtered-out tool’s name and schema can
still call it directly — tools/call is never filtered by the
dynamic layer, only tools/list responses are narrowed.
MCPG_ACCESS_MODE (and the MCPG_ALLOW_* capability gates) are
completely unaffected by either layer and remain the real permission
boundary: a smaller tool list is not narrower permissions.
The static MCPG_SESSION_INTENT filter (Layer 1) is the stronger
guarantee — it physically removes tools from the registry at launch,
so a session under lookup (whose buckets are schema_introspection,
query_execution, and observability) truly cannot call
prepare_migration — a migrations-bucket tool that’s never
registered under that intent, even under MCPG_ACCESS_MODE=unrestricted
where it would otherwise be available. The dynamic layer (Layer 2)
can’t make that same claim, and isn’t documented as if it could — it
only filters what a given tools/list response shows.
How the two layers combine
If both are configured, Layer 2 can only ever reveal tools Layer 1
already left registered. For example, MCPG_SESSION_INTENT=lookup
plus MCPG_DYNAMIC_SESSION_INTENT=1 starts a session already at all
of lookup’s registered tools (56 under a default read-only
deployment: the 54 tools lookup alone would register, plus the 2
always-kept meta-tools, since they’re registered before the static
filter runs and so survive it too) — its configured static intent,
not core; core is only the dynamic layer’s default starting point
when no static intent is configured at all. Calling
enable_session_intent("vector_rag") still reveals nothing, since
vector_rag’s tools were never registered under the lookup ceiling
in the first place — rather than erroring or silently exceeding the
static ceiling.
Connecting an MCP client
stdio (Claude Desktop and other local clients)
{
"mcpServers": {
"mcpg": {
"command": "uvx",
"args": ["mcpg"],
"env": {
"MCPG_DATABASE_URL": "postgresql://user:pass@localhost:5432/mydb",
"MCPG_ACCESS_MODE": "read-only"
}
}
}
}
(Use "command": "mcpg" with no args if you’ve done a
pip install mcpg on the system Python.)
Streamable HTTP (Cursor, Continue, custom web clients, etc.)
The HTTP transport refuses to start unless it’s authenticated — set
either MCPG_HTTP_AUTH_TOKEN (static bearer, below) or
MCPG_AUTH_MODE=oidc (next). If you deliberately need it unauthenticated
(e.g. behind a proxy that already enforces auth), set
MCPG_HTTP_ALLOW_UNAUTHENTICATED=true — this is loudly logged on every
startup and not recommended.
export MCPG_DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
export MCPG_TRANSPORT=streamable-http
export MCPG_HTTP_AUTH_TOKEN=<random_long_token>
mcpg
Then point the client at http://<host>:8000/mcp and have it send
Authorization: Bearer <random_long_token>.
For production, prefer OIDC over a static token — set:
export MCPG_AUTH_MODE=oidc
export MCPG_OIDC_ISSUER=https://your-issuer/
export MCPG_OIDC_AUDIENCE=mcpg
# Optional — map a JWT claim to the per-request PG role:
export MCPG_OIDC_ROLE_CLAIM=pg_role
OIDC validates every request’s JWT against the issuer’s JWKS (asymmetric algorithms only — RS256/RS384/RS512 + ES256/ES384/ES512).
Working with the tools
The tools group into a few common workflows. Full discovery surface
is in tour.md; long-form parameters in
tools.md; task-shaped recipes in
cookbook.md.
Explore a schema
list_schemas → list_tables → describe_table maps a database.
list_indexes shows a table’s indexes and their access method
(B-tree / GIN / HNSW / IVFFlat / GiST / BRIN). list_extensions
and list_available_extensions show what extensions are installed
or available; describe_table reports the dimension of a
pgvector vector(N) column.
For a one-call snapshot use summarize_table(schema, table) —
columns + PK + FKs + indexes + stats + sample rows, all in one
round-trip. Excellent agent UX.
Query data
run_select(sql, max_rows=1000) runs a read-only SQL query —
validated against the SafeSQL allowlist, executed under a forced
read-only transaction, and capped with a truncated flag.
run_select_parallel(statements) fans out concurrently with
per-statement error isolation.
Long-running analytical queries. run_select is bounded to a short
timeout (~30 s) on the shared pool so an agent can’t pin a connection with a
runaway query. For genuine analytical work that needs more time (large
aggregations, multi-table joins, window functions), use
run_analytical_query(sql, timeout_ms=…, work_mem=…). It validates SQL through
the same allowlist and applies the same tenancy/RLS, but runs on a dedicated,
isolated connection pool with an elevated, bounded timeout — so a slow query
can never starve the fast-path tools. Boot-time knobs (all optional):
export MCPG_ENABLE_ANALYTICAL_QUERIES=true # gate the tool (default true; set false to withdraw it)
export MCPG_ANALYTICAL_TIMEOUT_MS=120000 # default per-call budget (2 min)
export MCPG_ANALYTICAL_MAX_TIMEOUT_MS=600000 # hard ceiling; a per-call timeout_ms is clamped to this (10 min)
export MCPG_ANALYTICAL_MAX_CONCURRENCY=2 # max simultaneous analytical queries (isolated pool size)
If a run_select call times out, the error points the agent at
run_analytical_query — so it needn’t predict a query’s duration up front. The
analytical path targets the primary database.
explain_query(sql) returns a query plan; analyze_query_plan(sql)
summarises it (cost, node types, sequential scans);
why_is_this_slow(sql) rolls EXPLAIN + active queries + locks +
cache + index suggestions into one call.
Search
fuzzy_search (trigram, needs pg_trgm), full_text_search
(tsvector / tsquery, web-search syntax), vector_search (pgvector
k-NN, choose <-> / <#> / <=> operator), vector_range_search
(distance threshold), hybrid_search (vector + FTS via
reciprocal-rank fusion), geo_search (PostGIS k-NN over a
geography column).
When the ParadeDB pg_search extension is installed,
pg_search_run adds BM25 keyword search (single or multi-column
OR), pg_search_more_like_this does similar-document recall with
nine optional pdb.more_like_this tuning knobs, and
hybrid_bm25_vector_search fuses a BM25 leg with a pgvector leg
via reciprocal-rank fusion. See tools.md for the
full pg_search row and the BM-25 integration plan
for design context.
For vector index sizing: recommend_vector_index (HNSW vs IVFFlat)
and recommend_vector_quantization (vector → halfvec / bit
storage advisor).
Diagnose and tune
check_database_health— connections, cache hit ratio, dead tuples, invalid indexes, replication lag, bloat.audit_database(schema)— comprehensive 5-category DBA report (Memory & I/O, Transactions & Connections, Concurrency & Locks, Cleanliness & Bloat, Slow Queries) with per-metric scores, top issues, and prescriptive recommendations.analyze_workload— slowest query templates frompg_stat_statements.read_pg_buffercache_summaryandread_pg_buffercache_relations— shared buffer cache usage summary and relation-level breakdown (requires thepg_buffercacheextension).read_pg_wal_recordsandread_pg_wal_stats— read WAL record information and aggregated statistics over LSN ranges (requires thepg_walinspectextension).recommend_indexes— missing-index heuristics.recommend_index_drops(schema=None, min_index_size_bytes=1_000_000, low_scan_ratio=0.01)— sibling for what to drop: walkspg_stat_user_indexesfor indexes that look like pure cost (never_used,scan_no_fetch,rarely_used), emits a ready-to-runDROP INDEX CONCURRENTLYper candidate. Execution stays with the operator.find_unused_objects(schema)— zero-scan tables and user indexes; great for “what can I drop?”.detect_n_plus_one(min_calls=100)— walkspg_stat_statementslooking for ORM lazy-load loops.
Change data (restricted or unrestricted mode)
run_write(sql)— oneINSERT/UPDATE/DELETE. Add aRETURNINGclause to get affected rows back. Available inrestrictedmode and up.run_ddl(sql)(unrestricted+MCPG_ALLOW_DDL=true) — one DDL statement; can optionally snapshot the structural diff for anaudit_databasefollow-up.enable_extension(name)— allowlisted extensions only.
Visualise
generate_schema_diagram(schema)— Mermaid ER, paste-ready into GitHub / Notion / Obsidian.generate_fk_cascade_graph(schema)— Mermaid blast-radius graph ofON DELETE CASCADE/SET NULL/SET DEFAULTFKs.generate_graph_diagram(graph_name)— Mermaid view of an Apache AGE property graph.
Multi-tenancy
MCPg supports per-request PostgreSQL role switching via
SET LOCAL ROLE so one process can safely serve many tenants from
a single pool.
# Static default role applied to every query that doesn't override:
export MCPG_DEFAULT_ROLE=tenant_a
# Allowlist — when set, the X-MCPG-Role header / OIDC claim must
# be in the list (otherwise 403):
export MCPG_ALLOWED_ROLES=tenant_a,tenant_b,tenant_c
HTTP requests override the default by sending
X-MCPG-Role: tenant_b. OIDC deployments override via
MCPG_OIDC_ROLE_CLAIM — the named JWT claim’s value becomes the
per-request role automatically.
Per-request role selection is honoured per message on the
streamable-http / sse transports — every tool call resolves the role
from its own request, so two tenants sharing one MCP session each run
under their own role. (An HTTP/SSE middleware validates the role and
stashes it on the request; TenantRoleContextMiddleware — a
ServerMiddleware that runs inside the MCP SDK’s own per-request dispatch
task — reads it back and sets a ContextVar scoped to that task for the
lifetime of the request, so it’s reliably resolved per message and can’t
leak between requests. Earlier releases up to 0.6.10 pinned the role to a
session’s first request — fixed in 0.6.11.)
Role names are identifier-validated
([A-Za-z_][A-Za-z0-9_]*) so they’re safe to inline into
SET LOCAL ROLE "<name>".
RLS policies keyed on current_user then isolate tenants
correctly, and the audit trail records which role ran each call.
Read-replica routing
export MCPG_REPLICA_URLS=postgresql://u:p@replica-1/db?sslmode=require,postgresql://u:p@replica-2/db?sslmode=require
When MCPG_REPLICA_URLS is non-empty, every force_readonly=true
query is round-robin-routed to a healthy replica; writes always go
to the primary. Replica failures fall back to the primary once and
mark the replica degraded for 30 s before re-trying.
Each replica has its own connection pool; per-request SET LOCAL
ROLE composes across replicas (each replica’s pool gets its own
TenantSqlDriver).
list_replicas() reports per-replica index, password-obfuscated
DSN, degraded flag, last error, and seconds-until-retry. Routing
decisions land in the Prometheus mcpg_tool_calls_total counter
under the synthetic tool name __replica_route with statuses
primary / primary_no_healthy / fallback / replica_<n>.
Natural-language SQL
translate_nl_to_sql(question, schema, provider=None, execute=false)
asks an LLM provider to produce read-only SQL for the supplied
natural-language question, given the named schema’s catalog. The
generated SQL is passed through the same SafeSQL allowlist as
any hand-written query before any optional execution.
Provider configuration
MCPg auto-discovers every configured provider from the environment at startup. Set as many of these as you want active — each becomes callable through the tool:
| Provider | Set in env | Default model |
|---|---|---|
anthropic |
ANTHROPIC_API_KEY |
recent Claude Sonnet |
openai |
OPENAI_API_KEY |
gpt-4o-mini |
gemini |
GEMINI_API_KEY (falls back to GOOGLE_API_KEY) |
recent Gemini Flash |
deepseek |
DEEPSEEK_API_KEY |
deepseek-chat |
qwen |
DASHSCOPE_API_KEY (falls back to QWEN_API_KEY) |
qwen-plus |
openrouter |
OPENROUTER_API_KEY |
openai/gpt-4o-mini |
perplexity |
PERPLEXITY_API_KEY |
sonar |
xai |
XAI_API_KEY |
grok-3-mini |
groq |
GROQ_API_KEY |
llama-3.1-8b-instant |
mistral |
MISTRAL_API_KEY |
mistral-small-latest |
together |
TOGETHER_API_KEY |
meta-llama/Llama-3.3-70B-Instruct-Turbo |
fireworks |
FIREWORKS_API_KEY |
accounts/fireworks/models/llama-v3p1-8b-instruct |
deepinfra |
DEEPINFRA_TOKEN |
meta-llama/Meta-Llama-3.1-8B-Instruct |
cerebras |
CEREBRAS_API_KEY |
qwen-3-32b |
nebius |
NEBIUS_API_KEY |
meta-llama/Meta-Llama-3.1-8B-Instruct |
huggingface |
HF_TOKEN |
openai/gpt-oss-20b |
github |
GITHUB_TOKEN (GitHub Models) |
openai/gpt-4o-mini |
sambanova |
SAMBANOVA_API_KEY |
Meta-Llama-3.1-8B-Instruct |
moonshot |
MOONSHOT_API_KEY (Kimi) |
kimi-k2.5 |
glm |
ZAI_API_KEY (falls back to ZHIPUAI_API_KEY) |
glm-4.5-flash |
doubao |
ARK_API_KEY (Volcengine Ark; model must be activated in the console) |
doubao-seed-1-6-251015 |
sakana |
SAKANA_API_KEY (Fugu) |
fugu |
All but the first three (anthropic / openai / gemini) are
OpenAI-compatible vendors: MCPg drives them through one chat-completions
client with vendor-preset endpoints. The whole list is a single
declarative registry (_PROVIDERS in nl2sql.py), so adding a vendor or
refreshing a default model as vendors retire them is a one-line data
change. Base URLs and key env vars were verified against each vendor’s
docs; the default_model column is the volatile one — override it in
configuration with MCPG_NL2SQL_MODEL (applies to the default provider).
Bring your own provider (no code change)
Any other OpenAI-compatible vendor — or a local model server — is declared through configuration alone:
export MCPG_NL2SQL_CUSTOM_PROVIDERS="
myvendor=https://api.myvendor.example/v1|big-model,
privategw=https://llm.internal/v1|my-model|PRIVATEGW_TOKEN,
ollama=http://localhost:11434/v1|llama3.1
"
export MYVENDOR_API_KEY=... # <NAME>_API_KEY convention
export PRIVATEGW_TOKEN=... # explicit third segment for a deviating key var
# ollama needs no key — it's a loopback endpoint
The names above must not collide with a built-in provider (the 19 in the table); a clash is rejected at startup. Use custom entries for vendors not already built in, or for a local model server.
Each entry is name=base_url|model with an optional |KEY_ENV_VAR
third segment. Rules, stated plainly:
- The API key is read from
<NAME>_API_KEY(the convention most vendors follow); the third segment names the env var explicitly when a vendor deviates from it. - Keyless is allowed for loopback endpoints — local Ollama / vLLM / LM Studio are first-class, no dummy key needed.
http://is accepted only for loopback hosts; anything remote must behttps://(mirroring MCPg’s database TLS policy).- The model segment is required — custom endpoints have no sensible
default.
MCPG_NL2SQL_MODELstill overrides it when the custom provider is the configured default. - Every declared name becomes callable via the tool’s
provider=argument and is listed byget_server_info; auto-pick prefers the built-in vendors first, then declared customs in order.
export ANTHROPIC_API_KEY=sk-ant-... # configures anthropic
export OPENAI_API_KEY=sk-... # configures openai too
# Both available; MCPg auto-picks anthropic as the default. Preference
# is the registry order — anthropic → openai → gemini stay first, then
# the OpenAI-compatible fleet (deepseek, qwen, …, xai, groq, …) — so
# existing setups keep their current default.
To pin a specific default, set MCPG_NL2SQL_PROVIDER explicitly:
export MCPG_NL2SQL_PROVIDER=openai # default is now openai
MCPG_NL2SQL_API_KEY (when set) supplies the key for the configured
default provider; it requires MCPG_NL2SQL_PROVIDER to also be set so
MCPg knows which provider it’s for.
Per-call routing
Multiple providers configured? Any caller can route per-call by
passing the optional provider= argument:
translate_nl_to_sql(question, schema, provider="anthropic")
translate_nl_to_sql(question, schema, provider="openai")
translate_nl_to_sql(question, schema, provider="gemini")
translate_nl_to_sql(question, schema) # uses default
This is the recommended shape for one MCPg server, many MCP
clients — set every vendor key on the host, run one MCPg over the
HTTP transport, let each agent / IDE pick its preferred LLM per call.
get_server_info() reports nl2sql_default_provider and
nl2sql_available_providers so a caller can introspect.
Model + endpoint overrides
Override the default provider’s model with MCPG_NL2SQL_MODEL, or
point at an OpenAI-compatible self-hosted endpoint (Ollama, vLLM,
OpenRouter) with MCPG_NL2SQL_BASE_URL. Both apply only when the
call targets the default provider — an explicit provider="openai"
call when the default is anthropic falls back to OpenAI’s default
model + endpoint, since the operator’s overrides are
provider-specific.
The per-call response budget is capped by MCPG_NL2SQL_MAX_TOKENS
(default 2048, hard limit 16384).
Server-side cursors
For pageable reads over millions of rows.
open_cursor(sql) → { cursor_id: "mcpg_e3a91f", … }
fetch_cursor(cursor_id, batch_size=100) → { rows: [...], exhausted: false }
fetch_cursor(cursor_id, batch_size=100) → { rows: [...], exhausted: true } # stop polling
close_cursor(cursor_id) # idempotent
list_cursors()
Each cursor holds a dedicated connection so long-lived cursors
don’t starve the main pool. Cursors auto-close after a 5-minute
idle timeout. The opening SQL passes through the same SafeSQL
allowlist as run_select, so cursors can only read.
Data movement
Five tools, three blast-radius tiers:
- In-process exports (no opt-in needed):
export_query(sql, format="csv|json"),export_table(schema, table, format="csv|json", limit=10000). - In-process imports (
unrestricted): bulk-load viaCOPY … FROM STDIN+ parametrisedexecutemany.import_csv(schema, table, content, header=true, delimiter=","),import_json(schema, table, content). - Subprocess dump / restore
(
unrestricted+MCPG_ALLOW_SHELL=true):dump_database,restore_database,copy_table_between_databasesshell out topg_dump/psql/pg_restorewith hard timeouts (MCPG_SHELL_TIMEOUT_SEC, default 60 s) and output caps (MCPG_SHELL_MAX_OUTPUT_BYTES, default 64 MiB).
Reactive workflows: LISTEN / NOTIFY
subscribe_channel(channel) opens a PG LISTEN on a dedicated
connection and returns a subscription_id. poll_notifications(id,
timeout_ms=0, max_messages=100) drains the per-sub bounded queue,
optionally waiting up to timeout_ms. unsubscribe_channel(id)
removes the subscription; list_notification_subscriptions()
reports the active ones.
Queue size cap: MCPG_LISTEN_QUEUE_MAX (default 1000); overflow
drops the oldest message and surfaces dropped_count on the next
poll. Requires MCPG_ACCESS_MODE=unrestricted +
MCPG_ALLOW_LISTEN=true.
Staged migrations
prepare_migration(name, target_schema, candidate_sql,
ttl_minutes=60) clones the target schema’s structure into a shadow,
applies the candidate SQL there, and runs compare_schemas so you
can review the structural diff. validate_migration separately
applies the candidate to a transient shadow seeded with sampled
real data so failures the diff misses (NOT NULL on existing NULLs,
CHECK violations, type narrowings) surface before apply.
validate_migration_schema(target_schema, reference_schema, candidate_sql)
clones the target structure, applies the candidate SQL, and diffs the
resulting shadow schema against a reference schema using compare_schemas
to ensure the proposed migration exactly achieves the reference state.
complete_migration(id) applies to the target;
cancel_migration(id) drops the shadow without applying.
list_pending_migrations() shows what’s staged.
All staged-migration tools require unrestricted +
MCPG_ALLOW_DDL=true.
Migration history
read_migration_history(schema=None) queries and summarizes applied migrations for popular frameworks (Alembic, Flyway, Diesel, Django, Prisma, Golang Migrate, Goose, Sequelize). Since it is read-only, it does not require DDL privileges or unrestricted mode.
ORM bridges
Eight read-only exporters generate a starting schema/model file from the live PG catalog:
generate_prisma_schema(TypeScript / Prisma)generate_drizzle_schema(TypeScript / Drizzle)generate_sqlalchemy_models(Python / SQLAlchemy 2.0)generate_sqlc_schema(Go / sqlc)generate_diesel_schema(Rust / Diesel)generate_jooq_config(Java / jOOQ codegen XML)generate_ent_schemas(Go / Ent — one file per table)generate_ecto_schemas(Elixir / Ecto — one file per table)
All eight cover: base tables, columns, primary keys, single-column intra-schema FKs, and enums. Cross-schema and composite FKs are documented gaps.
Audit trail
Every tool call — success or failure — is logged to the
mcpg.audit Python logger with the tool name, redacted
arguments (see below), and outcome. Configure where that logger’s
records are shipped via your deployment’s logging stack.
Log Format
By default, the server writes human-readable log messages. You can toggle structured JSON logging using the MCPG_LOG_FORMAT environment variable:
export MCPG_LOG_FORMAT=json # text (default) or json
When set to json, all logging events from mcpg (including tool execution audit events) are serialized as a single-line JSON object containing standard keys (timestamp, level, logger, message). For audit events, the fields (tool, status, arguments, and optionally error) are merged directly into the top level of the JSON log record.
Persistence
With MCPG_AUDIT_PERSIST=true, every run_write and run_ddl is
also persisted to a mcpg_audit.events table (auto-created
idempotently on first write) with redacted arguments + result +
status. Query the table via the list_audit_events tool.
Redaction
Argument values are masked when their key name matches a
case-insensitive regex (matched via re.search, so password
also covers PGPASSWORD, user_password, app.password).
Default patterns:
password, passwd, secret, token, api[_-]?key, bearer,
authorization, database_url, dsn, conninfo
Extend the pattern list via MCPG_AUDIT_REDACT_KEYS (comma-separated
regex fragments). Walks nested dicts / lists / tuples so credentials
buried in result payloads are masked too.
String leaves are passed through the obfuscate_password helper so
an embedded connection-string credential nested anywhere is
scrubbed.
Integrity
To prevent tampering (unauthorized alterations, insertions, or deletions) of your persisted audit events, MCPg supports a signature chain:
MCPG_AUDIT_INTEGRITY=true— Enables the HMAC-SHA256 signature chain.MCPG_AUDIT_HMAC_KEY=<key>— The secret key used to compute and verify the signature chain (required when integrity is enabled).
When enabled, each event carries a signature computed over the deterministic payload and the preceding event’s signature. Verify the entire log using the verify_audit_chain tool, which sequentially checks each link and reports any tampering or deletions.
Observability
The HTTP transports expose:
GET /metrics— Prometheus text-format snapshot. Two series:mcpg_tool_calls_total{tool,status}— counter;status∈ok/error/primary/fallback/replica_<n>/primary_no_healthy(the last four come from the synthetic__replica_routetool).mcpg_tool_duration_seconds_*— histogram per tool.
GET /healthz— liveness probe.GET /readyz— readiness probe (verifies a pool connection).
For stdio deployments, the get_metrics_exposition() MCP tool
returns the same Prometheus payload as a string the agent can hand
to a scrape target.
Slow-call latency logging
To flag slow MCP-side tool calls, MCPg supports a latency threshold warning log:
export MCPG_SLOW_CALL_THRESHOLD_MS=1000 # Threshold in milliseconds (default: 1000)
- When configured (defaults to
1000ms), tool calls that exceed this threshold are logged as a warning tomcpg.server. - Set this to
0or a negative value to disable slow-call logging.
OpenTelemetry tracing
MCPg can emit one OpenTelemetry span per call_tool so traces from
the agent → MCPg → PostgreSQL hop can be stitched together in
Jaeger / Tempo / Honeycomb / your APM of choice.
pip install 'mcpg[otel]' # adds the OTel SDK + exporters
export MCPG_OTEL_ENABLED=true
export MCPG_OTEL_SERVICE_NAME=mcpg-prod # optional, default "mcpg"
Standard OTel env vars (OTEL_EXPORTER_OTLP_ENDPOINT,
OTEL_RESOURCE_ATTRIBUTES, …) are honoured by the SDK as usual.
Each span carries mcp.tool.name, mcp.tool.argument_count, and
the outcome status. Argument values are deliberately not
attached — span exporters often ship to third-party SaaS, and
attaching agent-supplied SQL or other args would turn the trace
backend into a side channel for credentials or PII. Audit logging
(local-only by default) is the place to look for full per-call
payloads.
Rate limiting
export MCPG_RATE_LIMIT_ENABLED=true
export MCPG_RATE_LIMIT_MAX_REQUESTS=60 # global cap per window
export MCPG_RATE_LIMIT_WINDOW_SECONDS=60 # window length
export MCPG_RATE_LIMIT_HEAVY_MAX=5 # cap for heavy tools
export MCPG_RATE_LIMIT_HEAVY_WINDOW=60 # heavy-window length
Token-bucket per-tool rate limiting. “Heavy” tools include
run_write, run_ddl, dump_database, restore_database, and
similarly expensive operations. A rate-limited call returns an
error immediately rather than queueing.
Caching
export MCPG_CACHE_ENABLED=true # enable or disable caching (default: true)
export MCPG_CACHE_TTL_SECONDS=300 # default cache TTL in seconds (default: 300)
export MCPG_CACHE_MAXSIZE=1024 # maximum LRU cache entry capacity (default: 1024)
export MCPG_REDIS_URL=redis://localhost:6379/0 # optional Redis connection string for external caching
MCPg provides a high-performance caching layer to save context window tokens and prevent database connection pool saturation from duplicate schema reads:
- Adaptive Caching: Wraps all schema introspection, diagram generators, property graphs, and DBA performance advisors.
- Automatic Invalidation: A write or DDL statement executed through MCPg’s own tools (e.g.
run_ddl,enable_extension,complete_migration) clears the entire cache, so a change you make via MCPg is reflected immediately. - Freshness after out-of-band changes: The cache cannot see schema changes made outside MCPg — a direct
psql/migration-tool change, another connection, or a second MCPg process — until the entry’s TTL (MCPG_CACHE_TTL_SECONDS, default 300s) expires. When you’ve changed the schema out-of-band and want a live answer now, either passfresh=trueto the read tool (describe_table,list_indexes,list_constraints,list_foreign_keys,get_compact_schema,recommend_indexes,audit_database— re-reads live and refreshes the entry), or call theclear_cachetool (a full flush; available when writes are permitted). In a dev/iterative-schema workflow,MCPG_CACHE_ENABLED=falsesidesteps caching entirely. - Optional Redis Backend: Soft-dependency Redis async support. If
redisis configured but the library is not installed, the server logs a warning and falls back to a thread-safe, memory-bounded in-memory LRU cache rather than crashing.
Feature Flags
export MCPG_ENABLE_HEAVY_DIAGNOSTICS=true # toggle computationally heavy diagnostics (default: true)
Enables operational gating for administrators over expensive diagnostic tools. When set to false, diagnostic, diagramming, and advisor tools (run_advisors, recommend_indexes, generate_schema_diagram, etc.) remain registered for client discovery but raise a friendly, administrator-disabled RuntimeError at call-time.
export MCPG_ELICIT_CONFIRM_WRITES=true # interactive write confirmation (default: false)
When set, every write/DDL/shell/listen/migrate-tier tool call (any tool whose readOnlyHint annotation isn’t true) is gated behind an interactive ctx.elicit() confirmation prompt — the client must accept before the tool runs. Declining or cancelling the prompt blocks the call and now also records an audit event (status="denied") and a mcpg_tool_calls_total{...,status="denied"} metrics increment, so a blocked write is never invisible to the audit log or /metrics.
This is best-effort, not an enforcement boundary. The gate only engages when the incoming call carries a request context and the client declared the elicitation capability during initialize; a client that omits either — including any non-interactive/automated client — silently bypasses the gate and the tool runs exactly as it would with the flag off. Treat this as a UX safety net for interactive clients, not a substitute for MCPG_ACCESS_MODE / MCPG_ALLOW_* policy enforcement.
Security defaults
MCPg ships with defence-in-depth defaults:
- Read-only access mode by default. Writes and DDL need explicit opt-in.
- SafeSQL allowlist. Every agent-supplied SQL statement parses
through the vendored
SafeSqlDriver(built onpglast) — only whitelisted AST nodes pass through. Statement stacking, comment escapes, transaction-control escapes, DDL insiderun_select,COPY, andDOblocks are refused before execution. - Identifier allowlist. Every interpolated identifier (schema
/ table / column / role names) goes through a
[A-Za-z_][A-Za-z0-9_]*regex. - PG TLS enforcement on startup. MCPg refuses to start if
MCPG_DATABASE_URL(or anyMCPG_REPLICA_URLSentry) points at a non-loopback host withoutsslmode=require(or stronger). UseMCPG_ALLOW_INSECURE_TLS=trueto opt out for non-prod. - Per-session timeouts (
MCPG_STATEMENT_TIMEOUT_MS, default 30 s;MCPG_LOCK_TIMEOUT_MS, default 5 s) self-terminate runaway queries and lock waits. - HTTP authn enforced on startup. The HTTP transport refuses to
start unless authenticated: static bearer (
MCPG_HTTP_AUTH_TOKEN) with constant-time compare, or OIDC JWT validation against the issuer’s JWKS (asymmetric algorithms only). UseMCPG_HTTP_ALLOW_UNAUTHENTICATED=trueto explicitly opt out (loudly logged; not recommended). The defaultstdiotransport is unaffected. - Audit redaction as documented above.
- Graceful shutdown draining (controlled by
MCPG_SHUTDOWN_DRAIN_SECONDS, default 30 s) ensures the server lifespan exit drains all in-flight tool calls before releasing database connection pools. - Multi-tenancy via
SET ROLE. One process can serve many tenants without the RLS-meets-pool footgun.
See security.md for the full threat model and
security-hardening.md for the living
roadmap of shipped (✅) and queued (⬜) hardening items.
Troubleshooting
mcpg: configuration error: …— A requiredMCPG_*env var is missing or invalid; the message names it. See the README env-var reference.MCPG_DATABASE_URL points at a remote host (…) but its sslmode is 'prefer'— The TLS enforcement guard caught a plaintext-capable DSN. Add?sslmode=requireto the DSN or setMCPG_ALLOW_INSECURE_TLS=trueif intentional.- A write tool is missing. Set
MCPG_ACCESS_MODE=unrestrictedplus the matching gate (MCPG_ALLOW_DDL=true,MCPG_ALLOW_SHELL=true, orMCPG_ALLOW_LISTEN=true). fuzzy_search/analyze_workload/vector_search/read_pg_buffercache_summary/read_pg_buffercache_relations/read_pg_wal_records/read_pg_wal_statsreportsavailable: false. The corresponding extension isn’t installed in the target DB. MCPg degrades gracefully — install when ready.- A query is rejected.
run_selectonly permits safe read-only statements; writes, DDL, and multi-statement input are refused by design. Userun_write/run_ddl(underunrestrictedmode) instead. prepare_migrationrefuses with “cannot run inside a transaction” — the candidate SQL contains aCONCURRENTLY/VACUUM/ALTER SYSTEMstatement. The staged-migration workflow always wraps the candidate in a transaction; for those, userun_ddldirectly.- Rate-limit errors. With
MCPG_RATE_LIMIT_ENABLED=true, exceeding the per-tool quota returns “Rate limit exceeded for tool ‘'". Wait the window out or raise `MCPG_RATE_LIMIT_*` defaults. - Replica request fell back to primary. Check
list_replicas()— the affected replica will bedegraded=truewithlast_errorandseconds_until_retry. The 30 s window auto-clears once the replica is healthy. - JWT was rejected. OIDC validates against
iss+aud+exp+nbf+ signature, with 30 s clock leeway. ConfirmMCPG_OIDC_ISSUER+MCPG_OIDC_AUDIENCEmatch what your IdP is minting; check that the JWT algorithm is asymmetric (HS-family is rejected by design). audit_databasereturnspg_stat_statements: Not Installed. Addpg_stat_statementstoshared_preload_libraries, restart Postgres, thenCREATE EXTENSION pg_stat_statements;.