| PG_LICHT_MCP(1) | General Commands Manual | PG_LICHT_MCP(1) |
pg_licht_mcp —
read-only PostgreSQL catalog exploration server for the
Model Context Protocol
pg_licht_mcp |
[-c file.ini]
[database_url] |
pg_licht_mcp |
-h |
pg_licht_mcp is a Model Context Protocol
server that exposes the PostgreSQL system catalog to an MCP client over
JSON-RPC 2.0 on standard input and standard output. It is meant to be
launched by the client, not run interactively.
pg_licht_mcp queries the catalog only:
schemas, tables, columns, functions, types, roles, and the statistics views.
It does not read user table data, with the single narrow exception of
checkKey, which tests a primary key for existence.
Every tool call runs inside its own ‘READ
ONLY’ transaction; see
SECURITY
CONSIDERATIONS.
The options are as follows:
-c
file.ini, --config
file.ini-h,
--helpA single positional database_url argument is
used as the connection string when neither a configuration file nor
DATABASE_URL is present.
When a configuration file defines more than one connection, every
operation accepts an optional connection argument
naming one of its sections; omitting it uses the default section.
listConnections reports what is configured. An
operation may instead take instance,
replication_group or group and
run once per member; see
Topology:
instances, replication groups, and groups.
A connection string is handed to libpq unchanged, so both accepted forms work:
postgresql://user:pass@host:5432/dbname?sslmode=require host=localhost port=5432 dbname=mydb user=daniel
A single database needs nothing beyond
DATABASE_URL.
To reach several databases from one server, list them as INI sections:
[default] port = 6432 dbname = pglicht user = daniel sslmode = prefer ; no password here — use ~/.pgpass or a service file [prod] ; a libpq service name, resolved from ~/.pg_service.conf, ; $PGSERVICEFILE, or $PGSYSCONFDIR/pg_service.conf service = db01_ro [staging] service = db01_ro ; the service supplies the defaults dbname = db01_staging ; and explicit keys override them
Pass a section name as the connection argument of any operation to run it against that database; omit it to use [default], or, if there is no [default], the first section in the file.
Three optional keys per section say how the configured databases relate. They are deliberately separate, because only the first two license a conclusion:
[billing_prod] service = billing-prod instance = pg-prod-01 ; one postmaster replication_group = billing-ha ; a primary and its replicas group = prod, billing ; an operator label [billing_ro] service = billing-replica instance = pg-prod-02 replication_group = billing-ha group = prod, billing, reporting
There is no cluster
key. PostgreSQL uses that word for the first sense and RDS and Aurora for
the second, so it means opposite things to the two people most likely to
read the file. There is no role key either: primary or
replica is observed on every connection through
pg_is_in_recovery()
and never cached, because failover swaps it and failover is exactly when
this server gets used.
An instance is inferred when two sections
share a host and port, and reported as
‘inferred’ rather than
‘declared’. Behind a pooler one
endpoint can front several instances and one instance can be reached through
several pooler ports, so an inferred instance groups output without
licensing a contention claim; verifyTopology settles
it. A section using service is never inferred, since
the service file is deliberately not expanded and its host and port are
therefore unknown.
Passing instance, replication_group or group in place of connection runs the operation once per member and returns one result per member, in file order. A member that cannot be reached is reported in place rather than failing the sweep.
Which of the three an operation accepts depends on where its
answer actually varies, and its input schema says which. Catalog and bloat
readings vary between the databases of one instance but not between members
of a replication group, because a physical replica is byte-identical.
currentActivity,
currentLocks, statementStats
and the buffer cache operations are the other way round: they report the
whole instance from any one of its databases, so sweeping them across it
would repeat one answer, and that is refused with
-32602.
duplicateIndexes,
indexBloat and tableIOStats
are the case worth knowing: they carry idx_scan, which
is each server's own. An index that reads as unused on the primary may be
carrying a replica's entire reporting workload, and only that replica's scan
counts show it. role set to
‘primary’ or
‘replica’ narrows a sweep to one side.
connection, instance,
replication_group, group and
role are read by pg_licht_mcp
itself out of a call's arguments. An operation that declares an argument of
its own by one of those names keeps it —
roleDependencies takes a role,
naming the role to ask about — and cannot be targeted by that
selector. The tool's argument wins, in the published schema and in the call
alike.
A sweep is sequential — the server is a single-threaded
loop — so its width is wall-clock time.
listTopology reads the configuration file and opens
no connection, so it is cheap to call first.
A worked example covering all of this is shipped as cpp/test/connections.example.ini in the source tree.
Keys are passed through to libpq, so anything libpq accepts works,
including service, which may be used on its own in
place of host, port,
dbname, and user. A service name
is resolved from ~/.pg_service.conf,
PGSERVICEFILE, or
$PGSYSCONFDIR/pg_service.conf.
Each section must set either service or at least dbname; a section with neither is reported by name at startup rather than failing later with an obscure libpq error.
The key options is rejected. PgBouncer
refuses GUCs in the startup packet, so pg_licht_mcp
sets what it needs per transaction instead — the read-only guard
always, and statement_timeout from
statement_timeout_ms below; see
SECURITY
CONSIDERATIONS.
Every transaction sets statement_timeout, so no single call can run without an upper bound. The default is 120000 milliseconds. Set it per section:
[prod] service = billing-prod statement_timeout_ms = 300000 [wan] service = analytics-eu statement_timeout_ms = 15000
A value of 0 removes the ceiling and leaves the server's own
statement_timeout untouched, which is the behaviour of
3.2.0 and earlier. With a single DATABASE_URL, use
PG_LICHT_STATEMENT_TIMEOUT_MS instead.
Most operations are catalog reads that finish in milliseconds and
never approach it. The ones that can are those whose cost scales with the
server rather than with the query: tableBloat with
exact true, indexBloat on a
btree or hash index, and bufferCacheContents, which
scans every buffer in shared_buffers. Raise it for the
connection when such a scan is the point of the call.
A statement that reaches the ceiling is reported as the ceiling being reached, with the value and the key to change, rather than as an execution error: nothing went wrong, the answer simply needs longer than the connection allows.
Like the read-only guard, the ceiling is transaction-scoped rather than session-scoped, which is what makes it survive a transaction-mode pooler. It cannot be carried in the conninfo: statement_timeout is a GUC rather than a libpq keyword, and the options keyword that could carry it is the one PgBouncer rejects.
Total RAM and vCPU count belong to the machine, not to the
cluster. PostgreSQL has no catalog for them, and a backend could not read
them portably in any case — it would read the client's host, which is
the wrong machine. They are therefore injected, and
hostCapacity correlates them with
shared_buffers, work_mem, and
the rest. Four optional keys per section carry them:
[prod] service = db01_ro host_ram_mb = 65536 host_vcpus = 16 host_storage = local nvme ; free text, never interpreted host_note = shared with the app ; free text, never interpreted
These keys are consumed by pg_licht_mcp
and never reach libpq, which would reject the whole connection string over
an unrecognised keyword. host_ram_mb and
host_vcpus must be positive integers; anything else is
rejected at startup naming the section and the key, because
‘64GB’ where megabytes are meant would
be wrong by three orders of magnitude and would silently invalidate every
derived ratio.
There are two other ways in. The environment variables under
ENVIRONMENT apply to the
single-connection DATABASE_URL form only, where
there is no question which host they describe; with a connections file each
section declares its own, since the sections may well live on different
machines. And an agent that inspects the host at run time — over SSH,
say — can pass ram_mb and
vcpus straight to
hostCapacity, which takes precedence over both.
With several databases on one postmaster, a reserved [instance:name] section carries the same keys once and its members inherit them; an explicit per-connection value still wins:
[instance:pg-prod-01] host_ram_mb = 65536 host_vcpus = 16 [app] service = app_ro instance = pg-prod-01
An [instance:name] section is not a connection:
it never appears in listConnections and never
becomes the default. It accepts the four host capacity keys and nothing
else, and is rejected at startup if no section declares that instance.
Inheritance follows instance only, never
replication_group: replicas routinely run on smaller
machines, and inheriting the primary's RAM would give every replica a
confidently wrong shared_buffers ratio.
Limits pg_licht_mcp applies to itself live
in an optional budgets.ini. Today there is one: what
explainQuery may execute when
analyze is combined with explicit
settings, as shares of the host capacity declared for
the connection.
[analyze] memory_percent = 10 ; worst-case plan memory, % of host_ram_mb vcpus_per_worker = 4 ; one parallel worker per this many host_vcpus
Those are the built-in values, used when no file is found.
memory_percent takes 1 to 100 and
vcpus_per_worker 1 to 1024. The first of these is
read: PG_LICHT_BUDGETS, which must then exist;
budgets.ini in the same directory as the connections
file in use; and ~/.config/pg_licht/budgets.ini. The
last two are used only if they exist.
The file is strict: an unknown section or key, or a value out of range, stops the server at startup rather than leaving a limit in force that the operator did not write. It holds no credentials and may be readable by anyone, but it must not be writable by anyone but its owner, since a file another user can edit is a limit another user can raise. Each refusal and each permitted run reports the values and the file they came from under execution_budget.
The connection source is resolved in this order:
--config
filePG_LICHT_CONFIGDATABASE_URLBecause sections may carry passwords, the configuration file must
not be group- or world-accessible. pg_licht_mcp
refuses to load it otherwise, the same rule libpq applies to
~/.pgpass:
chmod 600 ~/.config/pg_licht/connections.ini
Note that libpq enforces that rule on ~/.pgpass but not on pg_service.conf, so if a service file holds a password its permissions are yours to manage.
Every operation below except
listConnections also accepts an optional
connection argument naming a section of the
configuration file; omit it to use the default connection. Arguments shown
as name are required.
Operations backed by an extension
(statementStats,
explainQuery, tableBloat
and indexBloat) locate its
objects through pg_extension and reference them
schema-qualified. The extension therefore does not have to be installed in
public, and does not have to be on any
search_path: ‘CREATE EXTENSION
pgstattuple SCHEMA extensions’ works exactly as an
installation into public does.
This cannot be solved with a search_path
instead. pg_licht_mcp sets no session state, because
the target deployment is a transaction-mode pooler that discards it, and
PgBouncer rejects a GUC passed through the options
connection keyword outright.
The location of each extension is resolved once per configured
connection and remembered for the life of the process, since one connection
addresses one database. It is looked up again after any failure to find the
object, so an extension installed (or moved with
‘ALTER EXTENSION ... SET SCHEMA’)
while pg_licht_mcp is running is picked up on the
next call. An extension that is absent is reported as not installed, with
the hint naming the setup steps; see
DIAGNOSTICS.
checkPrivilegesMost of this server works for any role that can connect,
because the catalog is world-readable. Measured on PostgreSQL 18: a bare
LOGIN role with no grants runs 50 of the 62
operations at full fidelity, the monitoring role takes that to 54, and
the three that remain are exactly the three that read row data —
the tableStats column histograms,
checkKey and
explainQuery.
Worth calling first against an unfamiliar connection or a
restricted role. The alternative is discovering the limits one operation
at a time, and the discovery misleads:
tableStats under a role without
SELECT returns every column present with null
statistics, which is what a table that was never analyzed looks like,
and statementStats returns rows whose query text
is <insufficient privilege> with no count
of what was hidden.
Operations named in neither list are fully available;
available is a count rather than a list, because
the exceptions are the answer. denied means the
operation cannot answer at all, degraded that it
answers less than a privileged role would see. The three that read row
data are always degraded and never
denied, because privilege there is per object: a
role without blanket read access may still hold
SELECT on some tables and not others.
An absent extension is distinguished from a missing privilege, because the two send an operator to different fixes.
No role memberships and no GRANT
statements appear in the output. Which predefined role gates an
operation is PostgreSQL's business; the caller needs to know what works.
Naming a grant in the hint of an operation that failed is diagnosis;
listing grants beside every unavailable operation would be a standing
recommendation to escalate privilege, which is not this server's to
make.
checkRoleAccess
grantee schema
objecthas_table_privilege()
and its relatives rather than reconstructed from ACLs, so inheritance,
grants to PUBLIC, ownership and superuser fold in
the way the server folds them. Needs no grant of its own: these functions
and the catalog are world-readable, so any role that can connect may ask
about any other. The argument is grantee because
role is reserved server-wide for narrowing a sweep
to a primary or a replica.
Schema USAGE and database
CONNECT come back beside the object privileges,
since a grant on the table is inert without them and the resulting error
names the table. Where a table-level privilege is absent but individual
columns carry it, those columns are listed. A routine name reports every
overload with its signature and
security_definer.
Row-level
security is not folded in, because
has_table_privilege()
does not consider it: a true here can still return no rows. The
row_level_security block carries whether RLS is
enabled, whether it is forced, whether this particular role is subject
to it — the owner is exempt unless forced — and every
policy with whether it applies to the role. Enabled with no applicable
permissive policy denies everything.
Not to be confused with
checkPrivileges, which reports which of this
server's own operations the connecting role can run.
roleDependencies
rolepg_shdepend.
by_kind separates owner,
which blocks DROP ROLE and is cleared by
REASSIGN
OWNED, from acl and
policy, which DROP OWNED clears.
pg_shdepend is
shared across the cluster, so the counts cover every database; an object
id resolves only from the database it lives in, so
objects names this database and the shared
catalogs while others appear as counts. Tables, functions, schemas and
types are named as
schema.name; every other
class, a policy or a default privilege included, by
pg_identify_object(),
so a policy reads as ‘p on
public.t’.
defaultPrivileges
[schema]global
overrides the hard-wired defaults; per-schema entries are added to them. A
default applies only to objects created by
granted_by. Naming a schema
returns its entries and the global ones, since both decide what the next
object there gets; a schema that does not exist is an error.listSchemaslistTables
schematableDetails, not here; this
operation is deliberately light for browsing a schema. Carries no
statistics and no sizes: see listTableStats and
listTableSizes.tableDetails
schema tablenull if the table has no
toastable columns), primary key as an ordered column list, index
definitions with valid, constraints, foreign keys,
inbound foreign keys (referenced_by), triggers
(timing, events, when condition, language, enabled),
rules (excluding the implicit view _RETURN rule),
row-level security status and policies, view definition, and role grants.
Two fields report an object that is not doing what its
definition suggests. An index with valid false is
what a CREATE INDEX CONCURRENTLY that failed
leaves behind: it occupies disk and is maintained on every write, and
the planner never uses it. A trigger with enabled
‘disabled’ still carries its whole
definition, because ALTER TABLE ... DISABLE
TRIGGER changes no text;
‘replica’ and
‘always’ are the
session_replication_role states, which is how a
trigger can be off for the application and on for a replication apply
worker.
Everything here changes only when someone issues DDL, and none
of it is sampled row data. For row counts, scan counters, autovacuum
state and the pg_stats histograms see
tableStats; for measured sizes see
tableSize.
tableStats
schema tablerelpages
* block_size
with the estimated_from
timestamp that produced it),
block_size is the server's compile-time
BLCKSZ, 8192 unless it was built otherwise.
seq_scan and idx_scan, live
and dead tuples, mods-since-analyze, inserts-since-vacuum, the four
pg_stat_user_tables timestamps
(last_vacuum, last_autovacuum,
last_analyze,
last_autoanalyze), per-index scan counts, and the
per-column pg_stats histograms
(null_frac, avg_width,
n_distinct, physical order
correlation, most_common_vals
and their frequencies). Reads the catalog and the
statistics collector only: no relation is opened and no file is measured.
most_common_vals is literal values sampled from the column, up to default_statistics_target of them. This is the only operation that returns them.
The manual and automatic timestamps are separate, and mean
what the catalog means by them. A recent
last_autovacuum says autovacuum is reaching the
table; a recent last_vacuum beside a null
last_autovacuum says the opposite —
somebody is keeping the table alive by hand and the cron job is hiding
the finding rather than being it. Through 4.1.1 both were returned
merged under last_vacuum, which made those two
indistinguishable. estimated_from remains the
latest of all four, since any of them refreshes
relpages and
reltuples.
columnHistogram
schema table
columntableStats carries three points off that histogram
— low, mid and high — which is enough to pick parameters to
re-plan a statement with; this is for when the shape of the distribution
is itself the question.
The two halves are complements, not alternatives.
ANALYZE puts the most frequent values in
most_common_vals and builds the histogram only
from what is left, so a value in the MCV list never appears in the
bounds however common it is, and reading either alone misdescribes the
column. The bounds are equal-frequency: consecutive entries delimit
buckets holding roughly the same number of rows, so bounds bunched
together are a dense region and a wide gap a sparse one.
statistics_target says why the histogram is the
width it is, and is the knob that changes it.
Costs nothing to read, and returns
more literal column values than any other operation here: the bounds and
the most common values are rows sampled out of the table.
pg_stats filters on
has_column_privilege(),
so a role without SELECT on the column gets
nulls rather than data.
listTableStats
schematableStats for those.tableSize
schema tablelistTableSizes
schemalistPartitions
schemanull, not
zero, when the default partition has never been analyzed:
reltuples stays at -1 until then even after rows
arrive, and has_default says whether a default
exists at all. rows and
size_estimate cover only the leaves that have been
analyzed; never_analyzed says how many of the
leaf_partitions they could not see, and both are
null when none has been measured. A
sub-partitioned child holds no rows itself, so its leaves are what is
summed. Reads reltuples and
relpages, so no relation is opened and no lock is
taken.
A growing default partition is the finding: rows land there when they match no bound, so it is a missing partition that has not failed loudly yet.
partitionDetails
schema, tablenull for a partition that has never been analyzed,
rather than a zero that would read as empty.
Autovacuum runs per
partition,
so a parent has no vacuum state of its own and ranking parents finds
nothing while one child falls behind. Bounds are verbatim rather than
parsed: a bound carries whatever types the key columns have, and a
misparsed boundary is worse than an unparsed one. For a range parent,
the highest upper bound against now() says
whether the next period's partition exists.
largeObjectsdiskUsage counts their
bytes. No sizes: the bytes are in pg_largeobject,
which is not publicly readable.listFunctions
schemafunctionDetails
schema functionlistEnums
schemaenumDetails
schema enumlistTypes
schematypeDetails
schema typelistSequences
schemaSERIAL and IDENTITY
columns.listExtendedStatistics
schemaCREATE STATISTICS)
for schema with target table, columns, statistics
kinds (ndistinct, dependencies, mcv), and description.searchTables
web_searchlistTables plus
roles. Structure only, and there is no statistics
counterpart: a text search is how a table is found, not how a counter is
read. Name a match to tableStats or
tableSize for those.searchFunctions
web_searchsearchEnums
web_searchlistRoleslistTablespaceslistEventTriggerslistLanguagesplpgsql or plpython3u,
with owner, trusted and procedural flags, handler function, and
description.listAccessMethodslistCastslistExtensionslistCollations
schemalistOperators
schemalistOperatorClasses
schemalistTextSearchConfigs
schemalistForeignTables
schemalistForeignServerslistPublicationslistSubscriptionsCREATE SUBSCRIPTION declared, changing
on DDL and not otherwise. For whether the subscriber is keeping up, call
subscriptionStats.
The connection string is never exposed, as it may contain credentials. PostgreSQL agrees: the catalog revokes subconninfo from public and grants every one of its other seventeen columns, so selecting it would fail for any non-superuser as well as leaking a password.
subscriptionStatsAnswers whether a subscriber is keeping up and,
if not, whether it is stuck copying a table or failing to apply. Reports
no byte lag, because a subscriber cannot measure it:
received_lsn and
latest_end_lsn track each other rather than the
publisher, so their difference is zero even when the subscriber is far
behind. For lag in bytes call replicationSlots
on the
publisher
and read retained_wal_bytes.
When a table is still copying, the worker pid is a real
backend pid: pass it to progressStats for the
byte and tuple counts of that exact copy.
bytes_total is zero there, and so
bytes_percent is null: a table sync streams from
the publisher rather than reading a file of known size.
errors is absent entirely on PostgreSQL 14, which has no pg_stat_subscription_stats, and its conflicts are PostgreSQL 18. An absent key means the server cannot answer, which is not the same as no errors.
replicationSlotswal_status is the verdict that retained
WAL bytes only hint at: ‘extended’
means the slot is already past max_wal_size, and
‘lost’ means the WAL it needs is
gone and the slot is unusable. safe_wal_size is
how much more WAL may be written before that happens.
The spill and stream counters come from
pg_stat_replication_slots, a different view:
they show logical decoding spilling large transactions to disk, which is
invisible in the slot's own row and is a common, silent throughput
cliff. PostgreSQL 16 adds conflicting, and 17 adds
invalidation_reason and
inactive_since.
replicationStatsTwo readings that are routinely misread. The view is
security restricted per
row rather than refused, so a role without
pg_read_all_stats sees the senders exist with
columns null, which looks like an idle replica and is a permission
answer. And the lag columns revert to NULL a
short time after a standby has entirely caught up and WAL activity
stops, so a null lag there means caught up while a non-null one is the
last measurement rather than the current state.
databaseSizeserverSettingspg_settings) grouped by
category, each with current value, unit, description, context, type,
source, and pending_restart flag.currentActivity
[pid, query_id,
min_duration_s, state]pg_stat_activity) across all databases: pid,
database, user, application_name, backend_type, state, wait event, query
text, transaction and query duration, leader pid for parallel workers, and
the backend's xid and xmin.
query_id is the join key to
statementStats and
explainQuery, which is what turns “this
is running now” into “this is its plan”. It is
returned as text, since a 64-bit value does not survive JSON number
precision, and is null unless compute_query_id is
enabled; the default ‘auto’
enables it when pg_stat_statements is loaded. On
PostgreSQL 17 and newer each wait event also carries its prose
description from pg_wait_events.
All four filters are optional and combine; with none of them the whole view is returned, which on a busy server is mostly idle connections and internal processes.
statementStats is traced to whoever is running
it now.active’ or
‘idle in transaction’.currentLocks
[pid]pg_locks) joined with the holding
backend's query and user, plus which pids are blocking each waiting lock.
Use to diagnose lock contention.
pg_blocking_pids().
Each row is tagged with chain_depth: 0 is the
backend asked about, and the largest depth is the one at the root of
the pile-up, which is the one to look at first. Omit it for every lock
in the cluster.databaseStatspg_stat_database) for
every database in the cluster: connections, commits and rollbacks, block
hit ratio inputs, tuple counts, conflicts, deadlocks, temp file usage, and
checksum failures.
The session counters (session_time, active_time, idle_in_transaction_time, sessions, sessions_abandoned, sessions_fatal, sessions_killed) distinguish a database that is busy from one merely holding transactions open, which the commit and rollback counts alone cannot tell apart. PostgreSQL 18 adds parallel_workers_to_launch and parallel_workers_launched; a shortfall between them means queries planned for parallelism ran without it.
statementStats
[limit, query_id,
order_by, min_calls]pg_stat_statements), with calls, timing, row
counts, buffer usage, temporary block I/O, and WAL volume, under
statements, alongside an info
block from pg_stat_statements_info. Returns a
clear error with setup instructions if the extension is not installed.
info.dealloc counts how often the extension has evicted its least-used entries because pg_stat_statements.max was exceeded. A non-zero value means this list is not the slowest queries in the cluster but the slowest of those that survived eviction — a distinction that cannot be drawn from the rows themselves, which is why it is returned beside them.
query_id is text, not a number: it is a
64-bit value that a client parsing JSON numbers as doubles would round,
and the rounded value would then not be found by
explainQuery.
pg_stat_statements keeps one entry per user
and database, so a queryid can match more than one row.progressStats
[pid, relation]VACUUM, ANALYZE,
CREATE INDEX, CLUSTER,
COPY, and base backups. Each carries its phase,
blocks or tuples done against the total, a completion percentage, and how
long it has been running. Use it to decide whether a
VACUUM will finish before wraparound — the
natural companion to wraparoundStatus — or
whether a CREATE INDEX is stuck waiting on a
locker.
All six categories are always present, empty when nothing is running, so an absent key never has to be read as either “idle” or “unsupported here”. PostgreSQL 17 renamed the vacuum dead-tuple columns from counts to bytes; both are reported under their own names, with dead_tuple_unit saying which the server produced, because they measure different things.
currentActivity.ioStats
[pid, backend_type,
object, context]pg_stat_io): reads, writes, extends, hits,
evictions, reuses, fsyncs, their timings, and a hit percentage. This is
where buffers written directly by backends, vacuum's ring-buffer reuse,
and bulk read and write I/O become visible separately from the aggregate
counters in checkpointStats. Rows with no activity
at all are omitted, since the view is a dense matrix of combinations most
of which are structurally impossible. Byte counters are reported on
PostgreSQL 18 and newer, where they replaced the former
op_bytes column.
Requires PostgreSQL 16 or newer; older servers get a clear error naming the alternatives.
pg_stat_get_backend_io(),
together with its WAL volume from
pg_stat_get_backend_wal():
a backend can be quiet in I/O and still be generating WAL heavily.
pg_stat_io has no pid column at all, so this
is a different source rather than a filter, and it requires PostgreSQL
18.client
backend’ or ‘autovacuum
worker’.relation’ or
‘temp relation’.normal,
‘vacuum,
‘bulkread’’’,
or ‘bulkwrite’.wraparoundStatus
[schema, limit]age(datfrozenxid),
age(datminmxid), and each as a percentage of its
own freeze_max_age and of the limit of 2144483647,
at which the cluster stops accepting commands that assign a new
transaction id or multixact. Both axes carry both budgets: multixacts have
the same three-million stop limit and the same forty-million warning as
transaction ids, and members-space exhaustion is its own incident. That
figure is
not 2^31:
PostgreSQL refuses new transaction ids three million short of wraparound
(xidStopLimit = xidWrapLimit - 3000000), and
begins warning in the log forty million short of it. Both are reported
— wraparound_limit and
wraparound_warn_limit under
limits, with
xids_until_wraparound_limit and
xids_until_warn_limit per database. The warning
threshold is the one an operator has usually already seen fire. For
tables: the oldest by age(relfrozenxid), with the
effective autovacuum_freeze_max_age and whether it
came from the table's storage parameters or the server, plus last vacuum
times and dead tuple counts.
TOAST tables are included, labelled with the table they belong
to. They carry their own relfrozenxid, do not
appear in pg_stat_user_tables, and are
frequently the relation actually holding the horizon back.
On PostgreSQL 18 and newer each table also reports
frozen_percent
(pg_class.relallfrozen) and its cumulative
vacuum and autovacuum times. Those turn an age into an estimate of the
work remaining: an old relfrozenxid on a table
that is already almost entirely frozen is a different problem from the
same age with nothing frozen, and the timings say whether autovacuum has
been trying and failing to keep up or has simply never run.
diskUsageHow much room is left is not knowable from SQL. PostgreSQL exposes no function for total or free space, so this reports what is consuming space and how it divides, never the headroom. A tablespace also carries its location, which matters because the database that is growing and the volume that is full need not be the same device.
A climbing .ready count in the archive status is a failing archive_command retaining every segment it has not archived — indistinguishable from an abandoned replication slot by WAL size alone, and a different fix.
The
pg_ls_(*)
sections need pg_monitor. Each section is guarded
on its own savepoint, so one refusal returns an error in that key and
leaves the rest answered:
pg_ls_logdir()
raises rather than returning empty when
logging_collector is off.
The four directory sections are
trivial. The tablespace and database sizes are the whole cost: they walk
the tree and stat every segment file, so they scale with file count
rather than with bytes, and the two overlap because
pg_tablespace_size()
on pg_default covers the same subtree the
per-database sizes do. Measured at 314 ms for the whole call against a
cluster of roughly a terabyte, where a bare
databaseSize is already 163 ms. Nothing is read,
no relation is opened and no lock is taken — this is metadata
rather than I/O — but that was measured with directory entries
warm in the page cache, and a filling disk is when they are not.
statement_timeout bounds it, so the failure mode
is a timeout that names itself.
checkpointStatspg_stat_wal record,
full-page-image, and byte counters, and the settings that govern all of it
(checkpoint_timeout,
max_wal_size,
checkpoint_completion_target,
the bgwriter
knobs).
The source views differ by major version: PostgreSQL 17 split
the checkpointer counters into
pg_stat_checkpointer and moved the
backend-written buffer counts to pg_stat_io,
while earlier versions keep everything in
pg_stat_bgwriter. Field names are normalized
across both, so a caller never branches on the version; the
source field names the views that produced the
numbers.
tableIOStats
schema [table,
limit]pg_statio_all_tables):
heap_blks_read against
heap_blks_hit, idx_blks_read
against idx_blks_hit, the TOAST and TOAST-index
pairs, and a combined ratio, with relation size and scan counts for
context.
A ratio is null, not zero, for an object that has seen no reads at all, so “no traffic” is never mistaken for “every read missed the cache”.
pg_statio_all_indexes; over a whole schema it
would multiply the payload by the index count without making the
table-level ratios any easier to read.bufferCacheSummarypg_buffercache.
tableIOStats counts only what
shared_buffers served, so a miss there may still
have come from the operating system page cache at RAM speed; this is the
only in-core view of that split. Read the histogram rather than a hit
ratio: mass at usage count 2 to 5 is a stable working set, everything at
0 to 1 with no unused buffers is clock-sweep churn, and those are the
same ratio with opposite diagnoses. A single sample is weak evidence;
two samples minutes apart are the method.
Requires the pg_buffercache extension
at version 1.4 or later — the summary functions arrived there
— and a role with pg_monitor. Version 1.4
ships with PostgreSQL 16; on 14 and 15 there is no 1.4 to update to, and
the operation says so and points at
bufferCacheContents, which reads the view and
works on every version. On 16 and newer an out-of-date extension is
reported as such rather than as absent, and a missing grant names the
grant.
bufferCacheContents
[limit]Answers which relation drives checkpoint writeback (pair with
checkpointStats), whether the visibility map
fork is resident enough for index-only scans to pay off, and which
database's working set is displacing the others.
Only buffers belonging to this database and to the shared catalogs are resolved to names. Buffers held by other databases on the same instance are visible to PostgreSQL but not reported, because they cannot be resolved locally and unnamed rows would invite being read as this database's.
hostCapacity
[ram_mb, vcpus,
storage]Total RAM and vCPU count are properties of the host, not of the cluster: no catalog holds them, so they have to be injected. See Host capacity. The reported source says which of the three paths supplied them. Every ratio that needs RAM is null when none was supplied, together with a hint naming the three ways to provide it. Nothing is ever guessed: a wrong memory figure would silently invalidate every number derived from it.
duplicateIndexes
schema [table]identical groups indexes whose key
columns, operator classes, collations, sort order,
INCLUDE columns, and partial predicate all
match. redundant reports an index whose key
columns are a leading prefix of a wider index that also covers its
INCLUDE columns.
Comparison is by column expression rather than attribute
number, so two different expression indexes are not confused with each
other, and (a DESC) is not treated as covered by
(a, b). A unique index is never reported as
redundant for being a prefix: dropping it would drop a constraint the
wider index does not enforce. Each entry carries size,
idx_scan, the name of any backing constraint, and
the replica identity and validity flags, since those decide whether the
index can be dropped at all.
evaluateIndex
sql [create,
hide]hypopg. create takes
CREATE INDEX statements to plan against without
building them; hide takes the names of existing
indexes to plan without, which is how to ask whether an index is safe to
drop — the question duplicateIndexes raises
and cannot settle, since neither redundancy nor an unused
idx_scan proves the planner would not miss it.
Nothing is built, no lock is taken, no catalog row is written,
and the statement is never executed: hypopg
cannot serve EXPLAIN ANALYZE, so this is
plan-only and safer than explainQuery with
analyze. Reports the plan and total cost before
and after, and for each index whether the planner actually used it
— a proposed index the planner ignores is the common case, and a
cost figure alone hides it. The cost is the planner's estimate, not a
measurement.
A hypothetical index lives in backend-local memory for the
whole session and is cleared by neither
ROLLBACK, a new transaction, nor
DISCARD ALL, PgBouncer's default
server_reset_query. A transaction-mode pooler
cannot be asked to help either: it runs no reset query at all unless
server_reset_query_always is set, and even then
DISCARD ALL does not clear it. This operation
therefore resets explicitly on the way in and on the way out, so that
behind a transaction pooler one caller's hypothetical index cannot
reshape the next caller's plans.
hide requires
hypopg 1.4.0 or later, gated on the extension
version.
Takes settings and
plan_as_role exactly as
explainQuery does, and for a reason of its own:
the whole output is a before/after cost comparison, so an environment
that does not match production makes both halves answer a different
question. Both plans are built under the same environment, and
planning_environment reports it.
tableBloat
schema table
[exact]pgstattuple
or pgstattuple_approx):
table size, live and dead tuple counts and percentages, free space and
percentage. More accurate than the ANALYZE-time estimates in
listTableStats and
tableStats. Returns a clear error with setup
instructions if the extension is not installed.
false.indexBloat
schema indexpgstattuple function fits its access method. The
access method is resolved from the catalog, so the caller does not need to
know it in advance.
btreepgstatindex:
version, tree_level,
root_block_no,
internal_pages,
leaf_pages, empty_pages,
deleted_pages,
avg_leaf_density and
leaf_fragmentation. Reads the whole index.ginpgstatginindex:
version, pending_pages and
pending_tuples, reported together with the
fastupdate setting and the effective
pending_list_limit_kb that bound them. Reads
only the metapage, so it is cheap on any size of index.hashpgstathashindex:
version, bucket_pages,
overflow_pages,
bitmap_pages,
unused_pages, live_items,
dead_items and
free_percent. Reads the whole index.The metrics are deliberately not normalized across access
methods, unlike tableBloat's exact and
approximate pair: leaf_fragmentation and
pending_tuples are not two spellings of one
quantity. access_method names which set was
returned.
index_size and
idx_scan accompany every result, because they are
what the metrics are weighed against: a fragmented index that nothing
has scanned since the last statistics reset is a candidate for dropping
rather than for ‘REINDEX’.
gist, spgist
and brin have no
pgstattuple function at all and are reported as
unsupported by name; their page-level detail is in the
pageinspect extension instead. A partitioned
index has no storage of its own and is reported as such.
checkKey
schema table
valuesexplainQuery
[queryid | sql]EXPLAIN (FORMAT JSON)’ plan for
a statement, recovered from pg_stat_statements by
queryid or supplied directly as
sql. Runs in a read-only transaction bounded by
statement_timeout. Returns the plan verbatim plus
generic, analyzed, and
read_only flags and the
pg_stat_statements row. There are no heuristics
and no generated DDL; the plan is yours to interpret.
pg_stat_statements queryid, as a decimal
string. It is a 64-bit value and does not survive JSON number
precision. Mutually exclusive with sql.SELECT,
INSERT, UPDATE,
DELETE, MERGE,
WITH, TABLE, or
VALUES statement. Utility statements are
rejected. Mutually exclusive with queryid.pg_stat_statements are normalized, so
without params they are planned with
‘GENERIC_PLAN’; supplying params
has the statement PREPAREd and planned with
real values.EXPLAIN (ANALYZE,
BUFFERS)’, which really executes the statement. Honoured
only after the plan is proven free of any
ModifyTable node, so data-modifying
statements, including data-modifying CTEs, are never executed.
Requires timeout_ms. Default
false.{ work_mem: 512MB }’. Applied
with
set_config(name,
value, true), so they
revert with the transaction and cannot leak to another session.
Allowlisted to the settings that change a plan:
work_mem,
hash_mem_multiplier, the cost and parallelism
knobs, every enable_*, the join-search limits,
constraint_exclusion,
plan_cache_mode and the jit knobs. An unknown
name is refused and nothing is planned, rather than ignored.
search_path is excluded on purpose: it changes
which objects the statement resolves to rather than how they are
joined.
With analyze the statement is executed under these settings only within a budget taken from the host capacity declared for the connection, as host_ram_mb and host_vcpus. The worst case the plan's memory limits permit must stay within a share of the declared RAM, 10% unless budgets.ini says otherwise: each sort-like node at work_mem, each hash node at work_mem times hash_mem_multiplier, times the processes running it. The plan may use at most one parallel worker per four declared vCPUs, again unless budgets.ini says otherwise; see Budgets. With no declared capacity, no settings change is executed at all. A refused plan is still returned with analyzed false, and execution_budget under planning_environment carries the arithmetic. The read-only guard and the timeout bound what the statement writes and how long it runs, but not what it allocates, and an out-of-memory kill of one backend restarts every connection on the instance.
pg_db_role_setting, filtered to the same
allowlist. A per-database entry (ALTER ROLE ... IN
DATABASE) overrides the role-wide one, as the server itself
applies them. Entries that are not planner settings, such as
search_path or
statement_timeout, are reported under
skipped_from_role rather than dropped silently.
Explicit settings win over what the role
carries. On its own it is not held to the execution budget, because it
applies what the role already runs with in production.When either is given the result carries
planning_environment, saying what was applied and,
for plan_as_role, what was skipped. The plan's own
Settings block reports what the server saw.
listTopology
[pattern]inferred’ was derived from a shared
host and port rather than declared. Takes no
connection argument.
where does billing_prod live’
and ‘show me the ha group’ are
the same argument. An unlabelled connection is matched by its own
name. A pattern matching nothing returns empty lists rather than an
error.verifyTopologyThe whole operation turns on one fact: a system identifier names a replication lineage, not a postmaster. A physical replica began as a copy of its primary and carries the same value forever, so the identifier alone cannot separate the two axes and the endpoint has to be read alongside it. The same identifier on the same host and port is one instance; the same identifier on different hosts is a replication group.
Reports declarations the servers contradict, connections that
share an identifier without being declared together, a replication group
with no reachable primary, and split brain — where it names every
primary it found and chooses none. Logical replication cannot be
verified this way, because a logical replica has its own identifier, and
is reported as ‘cannot verify’
rather than as a mismatch. Takes no connection
argument.
listConnections
[pattern]listConnections,
listTopology and
verifyTopology are the only operations that do not
take a connection argument.
A client that negotiated MCP revision
2025-06-18 or later receives its answer as
structuredContent. Every client, whatever it
negotiated, also receives the text block.
Sending both is what the specification advises, and 4.0.0 did not:
it sent the structured payload instead of the text block, reasoning that a
client which cannot read structuredContent would not
ask for a revision that has it. A client can advertise a revision it does
not fully implement, and this server cannot tell — Claude Code
negotiates 2025-06-18 and reads
content, so every call came back appearing to have no
content at all. The token saving from sending one format is real, but not at
the price of an answer that looks empty.
Two environment variables change this, both read once at startup.
PG_LICHT_STRUCTURED_ONLY2025-06-18 or later, which is what
4.0.0 and 4.1.0 did by default. Worth setting once you have confirmed your
client reads structured content: it removes 27-46% of the bytes on a
typical call.PG_LICHT_MAX_PROTOCOLinitialize answers with the revision the client
asked for only when it appears in the list this server will speak, and
with the highest it will speak otherwise, so capping the list is how an
operator forces an older response shape from the server side. This governs
the handshake only: the stateless revision carries its version per request
and is not negotiated.The text block is serialised compactly. Indentation measured 18-39% of the payload across real catalog reads and no consumer reads it; every one parses the text as JSON.
Errors remain a text block in both eras: structuredContent is the format for a tool's answer, and an error is a message about why there is no answer.
Every operation declares an outputSchema to
clients that negotiated the revision defining it. The schemas type the keys
that are unconditional, mark nothing required, and permit additional
properties. This is deliberate: payloads are version-conditional, and any
operation may answer {error, hint} when an extension
is absent or a grant is missing, so a schema that enumerated leaf fields
would reject correct answers.
Results that revision 2026-07-28 marks
cacheable — server/discover,
tools/list, prompts/list,
resources/list,
resources/templates/list and
resources/read — carry
ttlMs and cacheScope. Both are
emitted only alongside resultType, so a legacy result
is unchanged.
Anything compiled in or read from the configuration file at
startup is hinted at one hour: none of it can change while the process
lives, and no listChanged capability is declared, so
there is no mechanism by which it could. Anything read from a live catalog
is hinted at one minute. This matters most for
tools/list, which is roughly 93 kB once 62
operations carry descriptions, annotations, titles and output schemas, and
which a client would otherwise re-fetch whenever it needed the list.
tools/list is scoped
private rather than public.
Its content varies by negotiated revision, while the cache key is the method
and its parameters and the revision travels in _meta;
and its connection property names the configured
default connection. Neither belongs in a cache a shared gateway may serve to
another caller. A private cache still serves the calling client, which is
where the saving lands.
The list operations accept an opaque cursor
and return nextCursor when more results remain; an
unusable cursor is refused with -32602. The page
size is larger than any list this server currently produces, so a client
that ignores cursors still receives every operation.
resources/list is the one that grows, being
connections multiplied by schemas.
Startup opens no connection. The registry is parsed and validated, so a malformed conninfo still fails immediately, but reachability is left to the first call that needs it — otherwise one unreachable database prevents the server from starting at all, taking every other database with it.
A connection is held between calls and closed after 60 seconds
idle, one per configured connection, keyed on the connection name. Every
registry entry carries its own user, so a reused connection never crosses
identities. Idle connections belonging to
pg_licht_mcp therefore appear in
pg_stat_activity for up to a minute after use.
A cached connection can be closed by the server underneath
— idle_session_timeout, a restart, a firewall
that forgot the flow. There is no way to know but to use it, so a failure
discards it and reconnects once; the retry is safe because only
BEGIN and the setup batch have run at that
point.
A fan-out sweep visits up to 16 members at once. This is safe where general concurrency would not be: members of a replication group are distinct servers by definition, and a group sweep already collapses members that would answer identically, so the width is one connection per machine rather than several onto one. The payload stays in configuration order however the members finish.
Running behind a connection pooler remains supported and tested,
and is still worth it across a WAN: a pooler amortises the TLS handshake
across restarts of this process. Note that PgBouncer reads neither
.pgpass nor a libpq service
definition, so its [databases] section duplicates
whatever the connections file already says; and in transaction mode it does
not run server_reset_query at all unless
server_reset_query_always is set.
Every session runs inside a transaction opened with
SET TRANSACTION READ ONLY and ends in
ROLLBACK. Nothing is ever committed: there is
nothing a commit could preserve, and rolling back leaves the server provably
as it was found.
Rollback does not undo backend-local state set by an extension. A
hypopg hypothetical index survives
ROLLBACK, the next transaction, and
DISCARD ALL, which is why
evaluateIndex resets it explicitly rather than
relying on the transaction ending.
Structure is served as resources as well as operations — documents a client may browse and pin into context without a call. The division is volatility: a resource changes only when someone issues DDL, while a reading changes continuously and the model must decide when to take it.
pglicht://conn/schemaspglicht://conn/schema/schemapglicht://conn/schema/schema/table/tablepglicht://conn/schema/schema/functionspglicht://conn/schema/schema/enumspglicht://conn/schema/schema/typespglicht://conn/server/rolespglicht://conn/server/extensionspglicht://conn/server/settingsresources/list is bounded. It enumerates
connections and their schemas and reaches tables, functions, enums and types
through resources/templates/list instead: a database
with 10 000 tables would otherwise produce a 10 000-entry response on a call
clients make eagerly. Enumerating schemas costs one connection per
configured connection; one that cannot be reached is skipped rather than
failing the list.
The topology axes are absent from the URI space by design. A resource names exactly one object in exactly one database, and partial failure across members can only be reported per member, which belongs to operations.
Eight templates, each encoding an order of investigation that is
easy to get wrong. Every one whose tools are privilege-gated opens by
calling checkPrivileges: a restricted role receives
null statistics rather than an error, so a plan built on operations that
will not answer looks like it worked. They are static text with argument
substitution and touch no database until the model acts on them.
diagnose-slow-query
[query_id, min_duration_s]statementStats to a plan to a fix, reading
info.dealloc first so a truncated list is not
mistaken for the whole picture.triage-lock-contention
[connection]diagnose-deadlock.diagnose-deadlock
deadlock_log [connection]Recurrence is established in three stages, where the statement
merely appearing in a view is the finding. Still present in
statementStats on a cluster that is evicting
means it runs often enough to survive eviction, which is what a deadlock
needs; absent settles nothing, since it may have been evicted, reset, or
never tracked. Present in currentActivity means
it is running now, so the next occurrence can be watched rather than the
last one inferred. Live and slow earns
currentLocks, because the chains forming now are
the near-miss version of the one that closed — at which point
triage-lock-contention takes over.
triage-active-sessions
[connection]Begins by distrusting the word “active”:
pg_stat_activity calls a backend active while it
executes a statement, and one waiting on a lock, on a disk read or on a
parallel sibling is executing a statement. Only backends with no wait
event compete for CPU, and that is the count compared against the core
count — a server with fifty active sessions of which forty-five
are lock waits has one blocker and a queue, not a shortage of cores.
Parallel workers are collapsed into their leaders first, since one query
with four workers is five rows and one unit of user concurrency.
The split by wait_event_type then
decides which investigation this is:
triage-lock-contention for locks,
buffer-cache-review for I/O, the parallelism
settings for IPC, the write path for LWLock.
Where the server really is CPU-bound, concurrency is arrival
rate multiplied by duration, so it rises when statements get slower even
though nothing about the load changed.
statementStats says which term moved: more calls
is growth and a capacity answer, the same calls taking longer is a
regression and belongs to diagnose-slow-query. A
connection pooler bounds the damage and makes no statement faster.
triage-disk-space
[connection]Four consumers in order of how fast each can be undone: WAL
pinned by a slot (the usual answer, and the only one that frees a lot at
once), temporary files (the fastest, and the space returns the moment
the statement ends), bloat, and genuine growth. Notes the cause it
cannot see — a failing archive_command
retains WAL and pg_stat_archiver is not read
here — and that a stuck archiver and an abandoned slot look
identical from the size alone.
Says plainly that VACUUM FULL is not a
disk-full action: it needs free space equal to the table and its indexes
before it releases any, and holds an
AccessExclusiveLock throughout. Ranks what can
be freed by reversibility, with dropping a slot marked irreversible for
its consumer.
bloat-and-vacuum-review
[schema]replicationSlots before concluding anything
about bloat: an inactive slot holds back the xmin horizon cluster-wide,
and no autovacuum tuning fixes it.buffer-cache-review
[connection]capacity-check
[connection]replication-slot-review
[connection]subscriptionStats separates one that is stuck from
one that is gone, and disable_on_error in
listSubscriptions identifies the third case, a
subscriber that switched itself off on an apply error and is one command
from resuming. An unconsumed slot holds back the xmin horizon, so vacuum
cannot remove dead tuples anywhere in the cluster, and pins WAL until the
disk fills; both get diagnosed as bloat or as a disk problem. Reads
wal_status, establishes whose slot it is before
proposing anything, weighs max_slot_wal_keep_size
against the disk, and treats dropping a slot as irreversible for its
consumer.plan-schema-change
change [schema,
table]It sends the model to measure instead, and to measure every
object the change touches rather than only the one named: a foreign key
locks the table it references as well as the table it is added to, a
partitioned table means the parent and every partition, and the object
not named is often the busier one. The readings are rows and
most_common_vals from
tableStats, what a rewrite would move from
tableSize, the indexes and constraints that
multiply it from tableDetails, the oldest
running transaction from currentActivity
— then asks for the change to be classified as metadata only, a
scan, or a full rewrite, and for the size at which the answer would
flip. Lock level alone settles nothing: a strong lock held for a
millisecond is safe and a weak one held for an hour may not be, and
duration comes from the measurements.
check-role-access
role object
[privilege, connection]USAGE on the
schema, whose error names the table and sends people to the wrong object.
Row-level security is a second
gate, opening only after the grants have already said yes. Enabled with
no applicable permissive policy it is deny-all — every grant
checks out,
has_table_privilege()
would say yes, and the table returns nothing. The owner bypasses it
unless FORCE ROW LEVEL SECURITY is set, which is
why checking as the owner proves nothing about anybody else.
Distinct from checkPrivileges, which
reports which of this server's own operations the connecting role can
run. This prompt is about another role's access to a database
object.
explain-and-fix
sql [params,
schema]Whether params is supplied decides which
plan comes back, and the difference between the two is itself a
diagnosis. Without them a statement carrying $n
placeholders is planned with GENERIC_PLAN, which
is what a prepared statement settles on — frequently the whole
answer to “fast by hand, slow from the application”. The
prompt asks for the comparison in both directions.
Reads estimates against actuals, then buffers
(shared_read is a cold cache or
an oversized working set, not a bad plan), then
Heap Fetches on an index-only scan (a stale
visibility map, fixed by vacuum and not by an index), then time no node
accounts for (it waited). Grounds all of it in
tableStats: row count first, since a sequential
scan of a few thousand rows needs no fix at all, then the analyze
timestamps, then most_common_vals for the skew
that makes one plan right for a common value and wrong for a rare
one.
Ranks fixes by what they cost — analyze, rewrite,
configuration, DDL — and treats “already as fast as it can
be” as a legitimate outcome. An index proposal is shaped
deliberately: existing coverage checked with
duplicateIndexes, equality columns before range,
INCLUDE for an index-only scan, a partial
predicate where the constant is always the same, and the operator class
where the default will not be used.
Verifies before recommending: a rewrite is re-planned and
compared, an index is put to evaluateIndex so
the planner says whether it would take it. Creation is handed to
plan-schema-change, since whether an index is
correct and whether it is safe to build are different questions.
Both this and diagnose-slow-query
close by choosing the kind of fix rather than defaulting to DDL: a query
rewrite, a statistics fix, a configuration change, or DDL, preferred in
that order because that is the order of what they cost — an
ANALYZE is free, a rewrite costs a deploy and
nothing in the database, configuration changes every other query too,
and an index is a write cost paid by every
INSERT and UPDATE for as
long as it exists. A rewrite can then be verified on the spot by
explaining it; an index cannot, so its benefit is presented as the
prediction it is, and its creation handed to
plan-schema-change.
completion/complete offers values for the
conn, schema and
table variables that appear in prompt arguments and
resource templates. A completion is a convenience: an argument it does not
recognise, or a catalog it cannot read, yields an empty list rather than an
error.
DATABASE_URLPG_LICHT_CONFIG--config and before
~/.config/pg_licht/connections.ini.PG_LICHT_BUDGETSPG_LICHT_HOST_RAM_MBhostCapacity. Honoured only with the
single-connection DATABASE_URL form; with a
connections file, use host_ram_mb in the section
instead. See Host capacity.PG_LICHT_HOST_VCPUSPG_LICHT_HOST_STORAGEPG_LICHT_HOST_NOTEPG_LICHT_STATEMENT_TIMEOUT_MSDATABASE_URL form; with a connections file, use
statement_timeout_ms in the section instead. See
The statement
ceiling.PGSERVICEFILEPGSYSCONFDIRPGPASSFILEHOMEAny other libpq environment variable is honoured as usual, since connection strings are passed through unchanged.
The pg_licht_mcp utility exits 0 on
success, and >0 if an error occurs.
pg_licht_mcp exits non-zero when no
connection source is given, when the configuration file cannot be parsed or
has unsafe permissions, or when the default connection cannot be reached at
startup.
Run against a single database given by the environment:
DATABASE_URL="postgresql://user:pass@host/dbname" pg_licht_mcp
The libpq key-value form works too, as does a positional argument or a configuration file:
DATABASE_URL="host=localhost port=5432 dbname=mydb" pg_licht_mcp pg_licht_mcp "postgresql://user:pass@host/dbname" pg_licht_mcp --config ~/.config/pg_licht/connections.ini
Register the server with claude mcp
add:
claude mcp add --transport stdio pg-licht \ -e DATABASE_URL="postgresql://user:pass@host/dbname" \ -- /usr/local/bin/pg_licht_mcp
The --scope flag controls where the
configuration is written:
| Scope | Flag | Stored in |
| Local (default) | --scope
local |
~/.claude.json |
| Project | --scope
project |
.mcp.json |
| User | --scope
user |
~/.claude.json |
Local scope is personal and limited to the current project, project scope is shared with the team through version control, and user scope applies across all of your projects.
A project-scoped server lives in .mcp.json at the repository root; commit it to share it with the team:
{
"mcpServers": {
"pg-licht": {
"command": "/usr/local/bin/pg_licht_mcp",
"args": [],
"env": {
"DATABASE_URL": "postgresql://user:pass@host/dbname"
}
}
}
}
Local- and user-scoped servers live in
~/.claude.json instead. Edit that file with
claude mcp add or claude mcp
add-json rather than by hand, since it also holds per-project
state.
Add the same JSON block under mcpServers in claude_desktop_config.json.
Grok Build reads .mcp.json and ~/.claude.json for compatibility, so a server already configured for Claude Code is picked up automatically. To configure it directly:
grok mcp add pg-licht -- /usr/local/bin/pg_licht_mcp
Environment variables for stdio servers are set in the
configuration file rather than on the command line.
--scope project writes to
.grok/config.toml in the project; omit it for the
user-level ~/.grok/config.toml:
[mcp_servers.pg-licht]
command = "/usr/local/bin/pg_licht_mcp"
env = { DATABASE_URL = "postgresql://user:pass@host/dbname" }
Verify with grok mcp list or
grok mcp doctor pg-licht.
statementStats
and the queryid path of
explainQuery require the extension. The returned
hint gives the setup steps. An equivalent error is returned for
pgstattuple by tableBloat
and indexBloat.pgstattuple function is installed with
EXECUTE revoked from PUBLIC and granted to
pg_stat_scan_tables, so a role with every read
privilege on the data can still be refused the statistics. The hint names
the grant. Affects tableBloat and
indexBloat.gist,
spgist or brin, which the
extension does not cover. The hint names the supported access methods and
points at pageinspect.ALTER EXTENSION pgstattuple
UPDATE’.25006pg_licht_mcp supports PostgreSQL 14 and
newer, and is verified on 14 through 18.
One feature degrades gracefully below 16.
explainQuery can only plan a normalized
pg_stat_statements statement by using
‘EXPLAIN (GENERIC_PLAN)’, which
requires PostgreSQL 16. On 14 and 15 that path returns an actionable hint
instead; supplying params, which prepares and plans
the statement with real values, works on every supported version.
ioStats requires PostgreSQL 16, where
pg_stat_io was introduced, and returns an error
naming the alternatives on older servers.
bufferCacheSummary requires the
pg_buffercache extension at version 1.4 or later,
where pg_buffercache_summary() and
pg_buffercache_usage_counts() were added. The gate
is on the extension version rather than the server version: an extension
created before 1.4 and never updated is a live catalog entry missing exactly
those two functions, and is reported as out of date rather than absent.
Version 1.4 first shipped with PostgreSQL 16, so on 14 and 15 the operation
reports that instead — there is no 1.4 to update to there —
and points at bufferCacheContents, which reads the
view and is available in every version of the extension.
bufferCacheContents reads the view, which exists in
every version the extension has had. Both need a role with
pg_monitor.
Elsewhere, a field is simply omitted where the underlying column does not exist, and normalized where it was renamed. Every operation returns its full result on every supported version; only these individual fields vary:
pg_subscription.subtwophasestate).pg_stat_user_indexes.last_idx_scan); table
n_tup_newpage_upd and
last_seq_scan; slot
conflicting.pg_wait_events); slot
invalidation_reason and
inactive_since; statement
stats_since and
minmax_stats_since.pg_stat_io byte counters.Two views changed shape upstream and are normalized rather than
passed through. checkpointStats reads
pg_stat_checkpointer on PostgreSQL 17 and newer and
pg_stat_bgwriter before it, reporting one set of
field names either way and naming its sources in
source; it also drops the four
pg_stat_wal timing columns that PostgreSQL 18
removed. progressStats reports the PostgreSQL 17
vacuum dead-tuple columns under their own names and labels the unit, since
the old and new columns count different things.
pg_licht_mcp first appeared in 2025.
Version 2.0 extended it to full pgAdmin-shaped catalog parity. Version 2.1
made the read-only guard per transaction rather than per session, added
named multi-database connections, and added
explainQuery. This manual page first appeared in
version 2.2.
Daniel Cristian <danielcristian@gmail.com>
Every catalog query is parameterized, using
$1 and $2 bindings or
::regnamespace and
::regclass casts. No schema, table, or search-term
argument is ever concatenated into SQL text.
The one deliberate exception is
explainQuery, which must place the statement under
analysis into the EXPLAIN text because
EXPLAIN cannot take a bind parameter. It is guarded
by a leading-keyword whitelist, a single-statement check,
PREPAREd parameter binding with every value escaped
through libpq, a ModifyTable gate that refuses to
execute anything that writes, and a bounded
statement_timeout.
Every tool call runs inside its own ‘READ
ONLY’ transaction, so even a bug that let a query attempt a
write would fail with SQLSTATE 25006 rather than
succeed silently.
That guard is deliberately transaction-scoped rather than
session-scoped. Under a connection pooler in transaction mode, such as
PgBouncer with pool_mode = transaction, the server
connection is returned to the pool at commit and the next call may be handed
a different one, so a SET issued once at startup
silently stops applying to later calls. Whether the pooler also resets the
connection it takes back (server_reset_query,
which transaction mode skips by default) changes
nothing here: session state cannot be relied on either way. Transaction
scope is the scope a pooler preserves. The setting cannot be pushed into the
connection string either: PgBouncer rejects GUCs in the startup packet
outright, which is why the options configuration key
is refused.
statement_timeout is set on the same transaction and for the same reason, so no call can occupy a backend indefinitely. See The statement ceiling for the value and how to change it.
pg_licht_mcp never writes, and it never
returns rows from your tables. Every call is a read-only transaction, so
nothing it runs can change data or schema. Two operations read a user table,
and neither returns its rows: checkKey answers only
whether a row with the given primary key exists, though that answer is
itself a fact about the table; and explainQuery with
analyze executes a statement the caller supplied, only
once its plan is proven not to write, and returns the plan with row counts
and timings rather than the rows.
Values that came from your data can still reach the caller, in these places:
currentActivity,
currentLocksWHERE clause or a
statement carries — a password in ALTER ROLE ...
PASSWORD among them. Statements of other roles are hidden unless
the connecting role holds pg_read_all_stats.statementStats,
explainQuerypg_stat_statements, in
which the constants of a planned statement are replaced by
$n placeholders: up to 500 characters in a list,
whole when one queryid is named. Statements of other roles read
‘<insufficient privilege>’
without pg_read_all_stats.tableStats,
columnHistogrampg_stats: the most common
values with their frequencies, and histogram bounds, which are sample
values from the column. PostgreSQL returns them only for columns the
connecting role may SELECT.explainQuery,
evaluateIndexfunctionDetails,
tableDetailsserverSettingsNo operation returns a password
pg_licht_mcp itself holds:
listConnections never returns one and never expands
a service file, listForeignServers returns server
options but never user mappings, and
listSubscriptions omits each subscription's
connection string.
The connecting role decides the rest. A role without
SELECT on a column gets none of its statistics, and
a role without pg_read_all_stats sees only its own
statements. checkPrivileges reports what the current
role can reach, so the narrowest role that answers the question is the one
to connect as.
| September 15, 2026 | pg-licht |