postgres-readonly-mcp
Provides safe, read-only access to a PostgreSQL database, allowing agents to run SELECT/WITH queries, list schemas and tables, and describe table columns without exposing database credentials.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@postgres-readonly-mcplist all tables in the public schema"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
postgres-readonly-mcp
A small, dependency-light MCP server that gives local coding agents (OpenCode, or anything else that speaks MCP over stdio) safe, read-only access to a PostgreSQL database — without ever handing the LLM your database credential.
This is the same pattern as jira-readonly-mcp:
the MCP server owns the credential, calls the real backend itself, and hands the model back
compact, sanitized results.
This project is read-only. It cannot INSERT, UPDATE, DELETE, or run any DDL/admin statement. See Threat & security model for exactly how that's enforced — including a live end-to-end check, done while building this, that a real
DELETEagainst a real Postgres database was rejected by Postgres itself.
OpenCode / local LLM
│ MCP (stdio, JSON-RPC)
▼
postgres-readonly-mcp ← owns the DB credential; the LLM never sees it
│ PostgreSQL wire protocol (read-only session)
▼
PostgreSQLThreat & security model
Unlike a Jira API token, a Postgres credential has no built-in "this can only read" mode, and "read-only SQL" is user-supplied arbitrary text, so this server layers three independent protections rather than relying on any single one:
Every session is opened read-only at the protocol level.
src/db/pool.tsconnects with the Postgres startup option-c default_transaction_read_only=on. This means Postgres itself refuses any write statement -INSERT/UPDATE/DELETE/DDL/etc. all fail withERROR: cannot execute ... in a read-only transaction- regardless of what SQL text reaches it, even if this server's own validator has a bug, and even if the connected role otherwise has full write privileges. This was verified live against a real Postgres 16 instance while building this project: connecting with this exact startup option and a full-privilege role, aDELETEwas rejected by Postgres with that exact error; the sameDELETEwithout the option succeeded. This is the real enforcement boundary.Queries are always sent via the extended query protocol (
pool.query({ text, values }), never a bare string), which restricts a singleParsemessage to exactly one SQL statement. A stackedSELECT 1; DROP TABLE xpayload is rejected structurally before Postgres even tries to run the second statement.You should connect as a Postgres role that only has
SELECTgrants. This server can't create that role for you, but it's the credential-level backstop in case the two protections above are ever bypassed (e.g. by connecting to this same database directly, outside this server, with the same credential). See Recommended: a dedicated read-only role.
On top of those three, src/security/guard.ts adds a fail-closed,
defense-in-depth static check on every run_query call, before anything is sent to Postgres:
it rejects any query that doesn't start with SELECT/WITH, contains a second statement,
contains an INSERT/UPDATE/DELETE/DDL/session-control keyword anywhere (which also catches
the classic "data-modifying CTE" bypass, e.g. WITH x AS (DELETE FROM t RETURNING *) SELECT * FROM x),
contains a row-locking clause (FOR UPDATE/FOR SHARE), or calls one of a documented list of
functions (nextval, setval, pg_advisory_lock, dblink_exec, etc.) that Postgres
specifically exempts from read-only transactions. This validator is a heuristic, not a SQL
parser - it can reject a legitimate query whose text merely contains a blocked keyword or a
semicolon inside a string literal (fails closed, on purpose), and it is not a substitute for
layers 1-3 above.
Output is also passed through src/security/sanitize.ts, which
redacts common credential shapes (connection strings, Bearer/Basic auth headers,
password=/api_key=-style assignments, PEM private key blocks) that might otherwise be
echoed back in a verbose driver error message. This is defense-in-depth, not a security
boundary - it's a best-effort regex scrubber, not a guarantee, and it does nothing to protect
actual row data (that's the point of the tool - returning your own data to your own agent).
What this does not protect against: if the credential you give this server can write, and
somehow bypasses both the read-only session setting and the extended-protocol restriction (not
a known attack against a stock pg/Postgres setup, but assume nothing is bulletproof), it could
write. The role-scoping recommendation below is what protects against that residual case.
Anyone who can run this process or read your .env can read anything that credential can read.
Recommended: a dedicated read-only role
CREATE ROLE mcp_readonly WITH LOGIN PASSWORD 'choose-a-real-password';
GRANT CONNECT ON DATABASE your_database TO mcp_readonly;
GRANT USAGE ON SCHEMA public TO mcp_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_readonly;
-- Run this too, so future tables are covered automatically:
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO mcp_readonly;Point PGUSER/PGPASSWORD (or DATABASE_URL) at this role, not an application or admin
account. With multiple environments, create one such role per
database and point each DB_ENV_<NAME>_* block at its own — don't reuse one role's credential
across environments.
Related MCP server: PostgreSQL MCP Server
Available tools
Every tool below (except list_environments) takes an optional env argument selecting which
configured database to use — see Multiple environments. In
single-database mode (the default), env never needs to be passed.
Tool | Description |
| Lists configured environment names and non-secret connection info (never credentials). Use a returned name as |
| Runs a single read-only |
| Lists schemas (excludes |
| Lists tables and views in a schema (default |
| Column names, types, nullability, defaults, and primary key, for a table. |
describe_table's primary-key detection deliberately queries pg_catalog (pg_index/
pg_class/pg_attribute) rather than information_schema.key_column_usage: that
information_schema view only shows a constraint's columns to a role with more than SELECT
on the table, so it silently reports no primary key at all for exactly the kind of
SELECT-only role this README recommends. This was caught by testing against a real read-only
role while building this project, not assumed.
Multiple environments
By default this server connects to one database (configured with DATABASE_URL or
PGHOST/etc., as below). If you work across several databases — dev/staging/prod, or several
unrelated projects — configure them all at once and select by name per tool call instead of
running separate server instances or editing .env to switch:
DB_ENVIRONMENTS=dev,staging,prod
DB_DEFAULT_ENV=dev # optional; required only if you want an implicit default
DB_ENV_DEV_HOST=dev-db.example.internal
DB_ENV_DEV_DATABASE=appdb
DB_ENV_DEV_USER=readonly_user
DB_ENV_DEV_PASSWORD=...
DB_ENV_STAGING_URL=postgres://readonly_user:...@staging-db.example.internal:5432/appdb
DB_ENV_PROD_HOST=prod-db.example.internal
DB_ENV_PROD_DATABASE=appdb
DB_ENV_PROD_USER=readonly_user
DB_ENV_PROD_PASSWORD=...
DB_ENV_PROD_SSLMODE=verify-fullEach environment can use either a DB_ENV_<NAME>_URL connection string or discrete
DB_ENV_<NAME>_HOST/_PORT/_DATABASE/_USER/_PASSWORD fields, exactly like the
single-database vars — just prefixed with DB_ENV_<NAME>_. _SSLMODE is also per-environment
(falls back to the global PGSSLMODE if unset), since it's common for dev to run
unencrypted on localhost while prod requires verify-full. Row limits, statement timeout,
and cell-length truncation are global settings shared by every environment.
Environment names: letters/digits/_/-, starting with a letter (e.g. dev, qa-east,
prod). A name with a hyphen maps to an underscored var prefix — qa-east reads
DB_ENV_QA_EAST_*.
Once configured, call list_environments to see what's available, then pass env to any
other tool: run_query({ env: "prod", sql: "SELECT count(*) FROM orders" }). If you configure
more than one environment and don't set DB_DEFAULT_ENV, omitting env returns a clear error
listing the valid names — it never silently guesses which database you meant.
Each named environment gets its own connection pool and its own recommended SELECT-only role
(see below) — treat each one's credential with the sensitivity of that environment (a prod
credential deserves more caution than a dev one, even though this server enforces read-only
identically for both).
Installation on macOS
Requires Node.js 18.17+ (Apple Silicon or Intel; no native/compiled dependencies - pg's JS
driver, no libpq required).
git clone https://github.com/ranson21/postgres-readonly-mcp.git
cd postgres-readonly-mcp
npm install
npm run buildThis produces dist/index.js, a plain Node script you point OpenCode (or any MCP client) at.
Authentication configuration
Copy the example env file and fill in your real values — do not commit .env (it's
already git-ignored):
cp .env.example .env.env.example
# Copy this file to .env and fill in real values.
# .env is git-ignored - never commit it.
#
# All values below are FAKE placeholders for illustration only.
# ============================================================================
# SINGLE DATABASE (default) - use this section if you only have one database.
# Leave DB_ENVIRONMENTS (below) unset.
# ============================================================================
# Option A: a single connection string (takes precedence if set).
# DATABASE_URL=postgres://readonly_user:REPLACE_WITH_YOUR_PASSWORD@db.example.internal:5432/appdb
# Option B: discrete fields, used only if DATABASE_URL is not set.
PGHOST=db.example.internal
PGPORT=5432
PGDATABASE=appdb
PGUSER=readonly_user
PGPASSWORD=REPLACE_WITH_YOUR_PASSWORD
# TLS mode: disable | require | verify-full
# - disable: no encryption (fine for localhost/dev only)
# - require: encrypted, but does NOT verify the server certificate
# - verify-full: encrypted AND verifies the server certificate (recommended
# for anything that isn't localhost)
# PGSSLMODE=disable
# ============================================================================
# MULTIPLE NAMED DATABASES - set DB_ENVIRONMENTS to a comma-separated list of
# names, then give each one its own DB_ENV_<NAME>_* vars below. When set,
# this takes over entirely from the single-database vars above. Tools take
# an `env` argument to pick which one to use. See "Multiple environments".
# ============================================================================
# DB_ENVIRONMENTS=dev,staging,prod
# --- dev ---
# DB_ENV_DEV_URL=postgres://readonly_user:REPLACE_WITH_YOUR_PASSWORD@dev-db.example.internal:5432/appdb
# or discrete fields instead of DB_ENV_DEV_URL:
# DB_ENV_DEV_HOST=dev-db.example.internal
# DB_ENV_DEV_PORT=5432
# DB_ENV_DEV_DATABASE=appdb
# DB_ENV_DEV_USER=readonly_user
# DB_ENV_DEV_PASSWORD=REPLACE_WITH_YOUR_PASSWORD
# DB_ENV_DEV_SSLMODE=disable
# --- staging ---
# DB_ENV_STAGING_HOST=staging-db.example.internal
# DB_ENV_STAGING_PORT=5432
# DB_ENV_STAGING_DATABASE=appdb
# DB_ENV_STAGING_USER=readonly_user
# DB_ENV_STAGING_PASSWORD=REPLACE_WITH_YOUR_PASSWORD
# DB_ENV_STAGING_SSLMODE=require
# --- prod ---
# DB_ENV_PROD_HOST=prod-db.example.internal
# DB_ENV_PROD_PORT=5432
# DB_ENV_PROD_DATABASE=appdb
# DB_ENV_PROD_USER=readonly_user
# DB_ENV_PROD_PASSWORD=REPLACE_WITH_YOUR_PASSWORD
# DB_ENV_PROD_SSLMODE=verify-full
# Which environment a tool call uses when it doesn't pass `env` explicitly.
# Required if you configure more than one environment and want an implicit
# default; otherwise every tool call must pass `env`. Not needed at all in
# single-database mode (DB_ENVIRONMENTS unset).
# DB_DEFAULT_ENV=dev
# --- Read-only enforcement (applies to every environment) ---
# This server always opens sessions with default_transaction_read_only=on,
# so Postgres itself rejects write statements regardless of what SQL text
# reaches it. On top of that, STRONGLY prefer connecting as a Postgres role
# that only has SELECT grants, for every environment - see README
# "Threat & security model".
# --- Optional tuning (applies to every environment) ---
# Default/maximum rows returned by run_query (an outer LIMIT is always
# applied on top of whatever the query itself requests).
# PG_DEFAULT_ROW_LIMIT=100
# PG_MAX_ROW_LIMIT=500
# Server-side statement timeout, in milliseconds. Protects against a runaway
# query; enforced by Postgres itself via the session's statement_timeout.
# PG_STATEMENT_TIMEOUT_MS=15000
# Long text/jsonb cell values are truncated to this many characters before
# being returned to the model, to avoid wasting context on huge blobs.
# PG_MAX_CELL_LENGTH=2000
# Client-side connection timeout, in milliseconds.
# PG_CONNECT_TIMEOUT_MS=10000
# Optional: diagnostic log verbosity. One of: debug | info | warn | error.
# Logs always go to stderr, never stdout (stdout is reserved for MCP JSON-RPC
# traffic). Defaults to "info".
# DB_MCP_LOG_LEVEL=infoTwo ways to configure a single database's connection:
DATABASE_URL(takes precedence if set): a singlepostgres://user:password@host:port/dbconnection string.Discrete fields:
PGHOST,PGPORT(default5432),PGDATABASE,PGUSER,PGPASSWORD.
To connect to several databases instead (dev/staging/prod, or several unrelated projects), see Multiple environments — same two options, just per-environment.
Either way, set PGSSLMODE appropriately for where the database lives:
disable(default) - fine forlocalhost/a private network you trust.require- encrypts the connection but does not verify the server certificate.verify-full- encrypts and verifies the certificate. Use this for anything that isn'tlocalhost, especially a managed/cloud Postgres instance.
No database administrator action is required beyond creating the recommended read-only role
(a normal CREATE ROLE/GRANT any role with CREATEROLE, or a DBA, can run) — this server
only ever uses ordinary SQL a authenticated role can run.
Optional: macOS Keychain
Keep the password out of a plaintext .env file:
security add-generic-password -a "$USER" -s postgres-readonly-mcp-password -w 'your-password-here'Then wrap the launch command so PGPASSWORD is populated from Keychain right before the server
starts:
#!/bin/sh
# run.sh
export PGPASSWORD="$(security find-generic-password -a "$USER" -s postgres-readonly-mcp-password -w)"
exec node "$(dirname "$0")/dist/index.js"Point OpenCode's command at run.sh instead of node dist/index.js directly, and drop
PGPASSWORD from the environment block.
OpenCode MCP configuration
Add this to your OpenCode config (e.g. opencode.json or ~/.config/opencode/opencode.json),
using OpenCode's local MCP server schema:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"postgres": {
"type": "local",
"command": ["node", "/absolute/path/to/postgres-readonly-mcp/dist/index.js"],
"enabled": true,
"environment": {
"PGHOST": "db.example.internal",
"PGPORT": "5432",
"PGDATABASE": "appdb",
"PGUSER": "mcp_readonly",
"PGPASSWORD": "your-real-password-goes-here-not-in-git"
}
}
}
}Use an absolute path for command — OpenCode resolves it relative to its own working
directory, not this repo. If you're using the Keychain wrapper above, point command at
run.sh and drop PGPASSWORD from environment.
For multiple environments, put the DB_ENVIRONMENTS/DB_ENV_<NAME>_*
vars in environment instead — one server instance then serves all of them, selected per tool
call by name:
{
"environment": {
"DB_ENVIRONMENTS": "dev,staging,prod",
"DB_DEFAULT_ENV": "dev",
"DB_ENV_DEV_HOST": "dev-db.example.internal",
"DB_ENV_DEV_DATABASE": "appdb",
"DB_ENV_DEV_USER": "mcp_readonly",
"DB_ENV_DEV_PASSWORD": "...",
"DB_ENV_STAGING_HOST": "staging-db.example.internal",
"DB_ENV_STAGING_DATABASE": "appdb",
"DB_ENV_STAGING_USER": "mcp_readonly",
"DB_ENV_STAGING_PASSWORD": "...",
"DB_ENV_PROD_HOST": "prod-db.example.internal",
"DB_ENV_PROD_DATABASE": "appdb",
"DB_ENV_PROD_USER": "mcp_readonly",
"DB_ENV_PROD_PASSWORD": "...",
"DB_ENV_PROD_SSLMODE": "verify-full"
}
}Example tool calls
"List the tables in the database." (
list_tables)"Describe the
orderstable." (describe_table)"Run a query to find the 10 most recent orders with status 'open'." (
run_querywithSELECT * FROM orders WHERE status = 'open' ORDER BY created_at DESC LIMIT 10)"What schemas exist in this database?" (
list_schemas)With multiple environments configured: "What database environments are available?" (
list_environments), then "In theprodenvironment, how many orders were placed today?" (run_querywithenv: "prod")
A run_query result looks like:
{
"rowCount": 2,
"columns": ["id", "name", "price"],
"rows": [
{ "id": 1, "name": "Fake Widget A", "price": "9.99" },
{ "id": 2, "name": "Fake Widget B", "price": "19.99" }
],
"truncated": false
}truncated: true means the row count hit the applied limit — there may be more rows than
shown. Long text/jsonb cell values are truncated (see PG_MAX_CELL_LENGTH); binary (bytea)
values are replaced with a [binary N bytes omitted] placeholder, never returned raw.
Logging
Diagnostic logs go to stderr only. This server never writes to stdout, since stdout is the
MCP JSON-RPC transport for this stdio server — any stray line there would corrupt the protocol
stream. console.error is used everywhere logging happens; console.log is never used.
Control verbosity with DB_MCP_LOG_LEVEL (debug | info | warn | error, default info):
[2026-09-13T01:40:45.900Z] [INFO] [postgres-readonly-mcp] tool invoked {"tool":"run_query","env":"prod","limit":10}
[2026-09-13T01:40:46.031Z] [INFO] [postgres-readonly-mcp] query complete {"env":"prod","elapsedMs":12,"rowCount":3}
[2026-09-13T01:40:46.032Z] [INFO] [postgres-readonly-mcp] tool succeeded {"tool":"run_query"}Every log line produced while handling a given environment's queries carries "env":"<name>",
so with multiple environments configured you can tell at a glance (or with grep) which
database a given query hit — useful when dev and prod are both configured and something in
prod needs attention.
Event | Level |
Server startup (each configured environment's host/port/database, TLS mode, row limits, and the default env — never a password) | info |
Tool invoked (tool name, resolved | info |
The raw SQL text passed to | debug only |
Query complete (elapsed ms, row count) | info |
Sanitization complete (tool name only) | debug |
Tool succeeded (tool name only) | info |
Errors (auth failures, connection failures, rejected queries, query errors) | error |
The raw SQL text is deliberately logged at debug, not info: unlike a Jira issue key, a
WHERE clause can carry literal row data (emails, IDs, etc.) that shouldn't land in default
logs. Turn on DB_MCP_LOG_LEVEL=debug when you need to see exactly what was run.
Never logged, at any level: the database password, a full connection string, Authorization
headers, or raw driver error internals beyond a redacted message. As an extra safety net, every
structured log field also passes through the same redaction layer used for tool output before
being written — see Threat & security model for why that's
defense-in-depth, not a guarantee.
Verifying the server without exposing credentials
npm test # 100 tests, all against fixtures/fakes - no network, no real database required
npm run buildTo check the live server without ever printing a real password to a terminal a shared log might capture, use the MCP Inspector:
PGPASSWORD="$(security find-generic-password -a "$USER" -s postgres-readonly-mcp-password -w)" \
npx @modelcontextprotocol/inspector node dist/index.jsInspector lets you call tools/list and tools/call interactively in a browser UI. Try
run_query with an obvious mutation (e.g. DELETE FROM some_table) and confirm it's rejected
with "Only SELECT (or WITH ... SELECT) queries are allowed" before it ever reaches the database.
Troubleshooting
"Missing database connection settings" — you haven't set
DATABASE_URL, or all ofPGHOST/PGDATABASE/PGUSER/PGPASSWORD. See Authentication configuration.DbAuthError(password authentication failed) — checkPGUSER/PGPASSWORD, and that the role is allowed to connect from wherever this server runs (pg_hba.confon self-managed Postgres).DbConnectionError(connection refused/timed out) — checkPGHOST/PGPORT, network reachability, andPGSSLMODE(a managed Postgres provider often requiresrequireorverify-full).A query is rejected with "Only SELECT (or WITH ... SELECT) queries are allowed" even though it looks like a SELECT — the validator is a heuristic (see Threat & security model): a semicolon or a blocked keyword inside a string literal in your query text will trigger this. Rewrite to avoid the pattern.
describe_tableshows every column asisPrimaryKey: false— this was a real bug caught while building this project when connected as a SELECT-only role; it's fixed by queryingpg_catalogdirectly (see Available tools). If you still see it, you're likely running an older build —npm run buildagain.Row values look truncated — increase
PG_MAX_CELL_LENGTH, or note thattruncated: trueon the result means more rows exist beyondPG_MAX_ROW_LIMIT/the requestedlimit.OpenCode doesn't see the tools — double check
commandis an absolute path todist/index.jsand that you rannpm run build.
Running entirely locally
Aside from the connection this server makes directly to your configured PGHOST (or the host
in DATABASE_URL), everything else — the MCP process, the OpenCode/local-model side, tool
schema handling, the SQL guard, and the sanitization layer — runs entirely on your machine. No
third-party service, telemetry endpoint, or analytics call is contacted. This makes it a good
fit for local model setups (OpenCode + a locally-served model, e.g. via MLX/OptiQ) where you
want to keep everything except the necessary database traffic on-device.
Testing
npm test100 tests, all against fixtures/fakes/a fake pg-shaped pool — no real database, no network
access. Coverage includes: the SQL guard (allowed queries, mutation rejection, the
data-modifying-CTE bypass attempt, stacked statements, locking clauses, side-effecting
functions, documented false-positive limitations), row/value normalization (dates, bigints,
buffers, truncation), config parsing (DATABASE_URL vs discrete fields, SSL mode mapping),
credential redaction (by pattern and by key name), error classification (auth vs. connection
vs. query errors), and schema-introspection query shape (parameterized, never string-interpolated).
On top of the unit tests, this project's read-only guarantees were verified live against a real
local Postgres 16 instance (see Threat & security model) — a DELETE
was attempted and rejected by Postgres itself under the exact startup option this server uses,
both as the recommended SELECT-only role and as a full-privilege role.
Development
npm run dev # run src/index.ts directly with tsx, for local iteration
npm run build # compile to dist/
npm test # run the test suiteLicense
MIT — see LICENSE.
Available Tools
5 toolsdescribe_tableA
Describe a table's columns (name, type, nullable, default, primary key) in a schema (default: public). Read-only.
| Name | Required | Description | Default |
|---|---|---|---|
| env | No | Which configured database environment to use. Only "default" is configured, so this can be omitted. | |
| table | Yes | Table name | |
| schema | No | Schema name, defaults to "public" |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
No annotations are provided, so the description must carry the behavioral burden. It does declare 'Read-only', which is valuable safety context, but omits what happens on an unknown table/schema (error vs empty) and whether it touches metadata only. Some value added, but thin for a zero-annotation tool.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
A single front-loaded sentence: verb, resource, returned fields, then the default and safety note appended. No filler, every clause earns its place.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
There is no output schema, but the description compensates by enumerating the returned columns, so an agent knows what to expect. Minor gap: no indication of error behavior or whether schema/table are case-sensitive, but the essentials are covered.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 100%, so all three parameters are already documented in the schema, including the 'public' default. The description's '(default: public)' simply restates the schema's own note and adds no new semantics. Baseline 3.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb ('Describe') and resource ('a table's columns') and enumerates exactly what is returned (name, type, nullable, default, primary key). It does not name or distinguish itself against siblings like list_tables or run_query, which would be needed for a 5.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Usage is only implied: an agent can infer this is the introspection tool used before querying, but the description never says when to prefer it over list_tables or run_query, nor any prerequisites. Minimum viable guidance, no explicit when-to-use or exclusions.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_environmentsA
List configured database environments (name and non-secret connection info, never credentials). Use the returned names as the env argument to other tools. Read-only.
| Name | Required | Description | Default |
|---|---|---|---|
No parameters | |||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the full burden and does disclose the two traits that matter here: 'Read-only' and 'never credentials,' which reassures the agent that invoking it is safe and that secrets are not exposed. It does not mention auth requirements or whether the environment list can change at runtime, but for a simple enumeration tool this is solid coverage.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Three short sentences, all front-loaded and each earning its place: what it lists, what it returns, and how to use the result. No filler or redundancy.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
No output schema exists, so the description must describe the return shape, and it does ('name and non-secret connection info, never credentials'). Combined with the read-only disclosure and the env-routing hint, an agent has everything needed to call this correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The tool takes zero parameters, so there is nothing for the description to disambiguate and the baseline is 4. It usefully clarifies that the output feeds the `env` parameter of sibling tools, which is the only argument-related information relevant here.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource ('List configured database environments') and immediately scopes what is returned (name and non-secret connection info). This clearly distinguishes it from siblings like list_schemas and list_tables, which operate inside an environment rather than enumerating environments themselves.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicitly tells the agent how to use the output: 'Use the returned names as the `env` argument to other tools,' which is the key routing guidance for this discovery tool. It lacks an explicit when-not-to-use or a named alternative, but for a zero-param discovery call the contextual guidance is strong.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_schemasA
List schemas in the database. Excludes system schemas by default. Read-only.
| Name | Required | Description | Default |
|---|---|---|---|
| env | No | Which configured database environment to use. Only "default" is configured, so this can be omitted. | |
| include_system | No | Include pg_catalog/information_schema/pg_temp_*/pg_toast* schemas |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the full burden; it does disclose read-only status and the default exclusion of system schemas, which is genuinely useful. However, it omits anything about permissions, result size/pagination, or ordering of the returned schemas.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Three short sentences, front-loaded with the core action and free of filler. Every clause adds information (scope, default behavior, safety).
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a simple zero-required-parameter listing tool with full schema coverage and no output schema, the description covers what it does, its default filtering, and its safety profile. A brief note on the returned fields would make it fully complete.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 100%, so both parameters are already documented by the schema; baseline is 3. The description's 'excludes system schemas by default' reinforces the include_system parameter, but adds no syntax or format detail beyond the schema text.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource ('List schemas in the database') in the first sentence, which clearly separates it from siblings like list_tables and list_environments. It does not explicitly name alternatives, but the resource distinction is unambiguous.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Gives a default-behavior cue ('Excludes system schemas by default'), which implies the common case, but never says when to choose this over list_tables or describe_table, nor any prerequisites. Usage is implied rather than stated.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_tablesA
List tables and views in a schema (default: public). Read-only.
| Name | Required | Description | Default |
|---|---|---|---|
| env | No | Which configured database environment to use. Only "default" is configured, so this can be omitted. | |
| schema | No | Schema name, defaults to "public" |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the full behavioral burden, and it does disclose the key trait: 'Read-only.' That is genuinely useful safety context. However, it says nothing about pagination, result limits, ordering, or permissions, so it is only partially complete for a no-annotation tool.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
A single compact sentence, front-loaded with the action and resource, followed by the default and the read-only note. Every clause earns its place with no filler.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a simple two-parameter read tool with no output schema, the description covers what is listed, the default schema, and the safety profile. It is slightly thin on what the listing returns (names only? with types?), but no critical calling information is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, so both parameters (env, schema) are already documented, including the 'public' default and the enum note. The description merely restates the schema default, adding no syntax or format detail beyond the schema. Baseline 3 applies.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource (list tables and views) plus the scope (a schema, defaulting to public), which cleanly separates it from list_schemas and describe_table. It does not explicitly name or contrast with any sibling, so it falls just short of a 5.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Usage is implied by the name and resource: reach for it to discover what tables/views exist before describing or querying them. There is no explicit when-to-use guidance, no mention of when to prefer list_schemas or describe_table, and no stated prerequisites.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
run_queryA
Run a read-only SQL query (SELECT, or WITH ... SELECT) against the database and return compact rows. Results are capped by PG_MAX_ROW_LIMIT. Any INSERT/UPDATE/DELETE/DDL/etc. is rejected before it reaches the database - this server is read-only.
| Name | Required | Description | Default |
|---|---|---|---|
| env | No | Which configured database environment to use. Only "default" is configured, so this can be omitted. | |
| sql | Yes | A single SELECT (or WITH ... SELECT) statement, e.g. "SELECT * FROM orders WHERE status = 'open'" | |
| limit | No | Max rows to return |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the full burden and does well: it discloses the read-only server guarantee, that non-SELECT statements are rejected before reaching the database, and that output is capped by PG_MAX_ROW_LIMIT and returned as compact rows. It omits auth requirements and what happens when the cap is hit (error vs truncation), which keeps it short of a 5.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Three tight sentences with the core capability front-loaded and the read-only constraint immediately after. Every clause carries information; there is no filler.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a single-query tool with no output schema, the description covers the allowed statement forms, the write-rejection behavior, and the row-cap/return format. It stops just short of describing failure behavior at the cap or transaction semantics, which would make it fully self-contained.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, so the baseline is 3. The description adds some value by tying the row ceiling to PG_MAX_ROW_LIMIT, but it does not clarify how the user-supplied 'limit' interacts with that server cap or what the 'env' enum really means beyond the schema text.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb (Run) and resource (read-only SQL query) with the exact statement shapes allowed (SELECT, WITH ... SELECT), so the operation is unmistakable. It does not explicitly name how it differs from the introspection siblings (list_tables, describe_table), but the execution-vs-metadata distinction is obvious from the phrasing.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Gives a clear when-not: writes and DDL are rejected, so the agent knows this tool is only for reads. It never routes the agent between this and the sibling tools (e.g. use describe_table to inspect schema before querying), leaving usage context implied rather than stated.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
5 tool updates
v0.2.0- First observed
describe_table - First observed
list_environments - First observed
list_schemas - First observed
list_tables - First observed
run_query
TDQS
Scored across 5 tools
Each tool has a clearly distinct purpose: listing environments, running queries, listing schemas, listing tables, and describing a table. There is no overlap in their intended use, and descriptions reinforce the boundaries.
All tool names follow a consistent snake_case verb_noun pattern: list_environments, run_query, list_schemas, list_tables, describe_table. The slight variation in verbs (list, run, describe) is appropriate and predictable.
Five tools are well-scoped for a read-only PostgreSQL server, covering environment discovery, query execution, and schema introspection without bloat or omission of core operations.
The surface covers essential read-only operations: environment listing, ad-hoc queries, schema/table listing, and column description. Minor gaps exist for more granular introspection (e.g., indexes, foreign keys, constraints), but agents can work around them with run_query.
Maintenance
Related MCP Connectors
Query PostgreSQL databases in plain English — LLM-generated, safety-validated SQL.
Safe, read-only Postgres and MySQL access for AI agents. Audit log + column-level controls.
Query your Postgres from ChatGPT or Claude without exposing the database or handing over credentials. Run npx boltschema connect next to your database and it dials out over HTTPS — no inbound firewall rule, no open port, works with localhost and VPC-private databases. Read-only is enforced by a SQL guard, a Postgres READ ONLY transaction, and a scoped role generated for you.
PostgreSQL, MySQL, OpenAPI/Swagger, and shared Agent Memory with scoped access.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables LLMs to query and analyze PostgreSQL databases through a controlled interface. Supports SQL query execution, table schema inspection, and optional write operations with safety controls.84 npm180MIT
- AlicenseNot gradedqualityNot gradedmaintenanceProvides AI assistants with safe, controlled access to PostgreSQL databases with read-only defaults, granular permissions, query safety features, and schema introspection capabilities.1-
- FlicenseAqualityDmaintenanceEnables AI agents to inspect and query PostgreSQL databases safely, with features like listing tables, retrieving schemas, and running read-only SQL queries.3-
- AlicenseNot gradedqualityBmaintenanceEnables AI assistants to interact with PostgreSQL databases, providing tools for querying, table listing, schema exploration, and multi-environment management with write protection and schema scoping.1MIT