This server exposes PostgreSQL operations to an MCP client over stdio. Anyone who can use that client can request queries using the configured database credentials. There is no separate MCP user authentication, per-tool authorization, or tenant identity mechanism. Protect the host process and the client that launches it; do not expose stdio through an unauthenticated network bridge.
Connection profiles are case-sensitive names mapped to bootstrap connection strings; a profile alone is not a database allowlist (use the database lock for that). An inherited POSTGRES_CONNECTION_STRING alone creates the primary profile; protected targets JSON/file remains optional for multiple independent profiles. Tool requests can select a physical PostgreSQL database on a profile's server, but cannot supply a new connection string or change its host, login or TLS settings. Optional target selects the profile; without it, exact aliases select their configured bootstrap database and other database names use the default profile (primary, otherwise ordinal-first). With target, database is always a physical name. Unknown profiles, missing databases and denied connections fail without falling back. Each call owns its connection selection; there is no shared current database. Equivalent configurations for the same physical database may share a bounded connection pool.
Live list_databases uses the catalog of the profile's bootstrap database (or, under a database lock, the first locked database when the bootstrap database is outside the lock), excluding templates, disabled connections and databases lacking the current role's CONNECT privilege. It exposes accessible database names, not just configured aliases. CONNECT discovery does not prove actual connectivity or schema/table access. Newly created or granted databases are selectable without configuration edits or restarting, whether credentials came from the session environment or a protected file. Review grants, including PUBLIC CONNECT, before upgrading from alias-only selection. A POSTGRES_DATABASES/--databases database lock filters listing and rejects every other physical name in both connection modes.
Use dedicated, least-privileged PostgreSQL roles, not superusers or database owners. Grant only the required database connection, schema usage, table access, and routine execution privileges. Configure row-level security and separate roles where required by the application's data model. Review role memberships, default privileges, PUBLIC grants, SECURITY DEFINER routines, extensions, foreign servers, and monitoring privileges.
Read-only transactions and restricted mode stop SQL writes, not every side effect a privileged login can cause: a superuser or a member of pg_execute_server_program, pg_write_server_files, pg_read_server_files, pg_signal_backend or pg_checkpoint can read or write server files, run programs, terminate other sessions or force checkpoints from a SELECT, and roles with REPLICATION or BYPASSRLS widen what can be read. --validate prints a non-blocking [warning] line for each such capability of every configured login, so tool annotations that say read-only can be checked against what the role can actually do.
Unrestricted access is the default when access mode is omitted. SQL requests still default to read_only=true; read-only operations use a server-owned read-only transaction and roll it back after reading the result. Writes require read_only=false on execute_sql, unrestricted mode and sufficient PostgreSQL privileges. Opt into POSTGRES_ACCESS_MODE=restricted or --access-mode restricted to refuse write requests; existing explicit restricted settings remain effective. Enabled writes use a transaction that commits after successful execution and result reading; failures dispose the transaction without committing. Other tools remain read-only. Connection loss or cancellation during commit can leave the caller uncertain whether a write committed; inspect database state before retrying. The server does not automatically replay writes.
The SQL guard is a statement-boundary lexer, not an authorization parser or SQL sandbox. It accepts one statement, handles quoted SQL and comments, and rejects direct transaction/session control plus unsupported operations such as COPY, DO, CALL, and VACUUM. PostgreSQL transactions and role permissions enforce the actual access restrictions.
Read-only transactions do not make arbitrary SQL harmless. Queries can execute functions, change session settings through functions, use advisory locks, access temporary objects, consume database resources, or cause external effects through installed extensions and privileged routines. EXPLAIN ANALYZE executes the query. Neither transaction rollback nor connection reset can undo external effects. Restrict these capabilities in PostgreSQL. HypoPG evaluation uses installed extension functions and connection-local hypothetical indexes; it does not grant permission to create permanent indexes or install extensions.
Run one server instance per PostgreSQL server and give each instance a database lock: POSTGRES_DATABASES (or --databases) as a JSON array of exact, case-sensitive physical database names. The lock is a separate setting on top of either connection mode; connection-string and targets JSON/file formats are unchanged. The server's tools enforce the list in every case. Whether PostgreSQL itself must also confine the login is chosen with POSTGRES_DATABASE_LOCK_MODE: advisory (the default) warns about grants that let the login reach other databases, strict refuses to start. Only strict mode, together with the role hardening below, makes PostgreSQL enforce the list.
- Every path that opens a connection resolves to a physical database first and must name a locked database: every tool's
database, alias resolution, explicittarget,list_databases,--validateand the startup check. The data-source cache re-checks the lock before creating any pool, so no pool for another database can exist. - Connection-string mode: each locked database becomes a profile whose alias is its name; the connection string's own
Databaseis not used. - Targets mode: the lock applies to every profile. A profile still supplies host, login and TLS settings, but its bootstrap database is used only when it is locked. Passing the alias of a profile whose bootstrap database is outside the lock as
databaseis rejected, whiletargetwith that profile and a lockeddatabaseworks.list_databases,--validateand the startup check connect through the profile's bootstrap database when locked, otherwise through the first database inPOSTGRES_DATABASESorder; they never open an out-of-lock bootstrap database. - Rejections return
invalid_targetwith a message naming the database lock, before any connection is attempted. POSTGRES_REQUIRE_DATABASE_LOCK=true(or--require-database-lock) makes a missing lock a startup error. It is off by default; without a lock, behavior is unchanged.
POSTGRES_DATABASE_LOCK_MODE (or --database-lock-mode; the CLI value overrides the environment) is advisory (default) or strict. Any other value is a startup error (POSTGRES_DATABASE_LOCK_MODE/--database-lock-mode must be advisory or strict.), as is strict without a configured lock (POSTGRES_DATABASE_LOCK_MODE/--database-lock-mode is strict, but no POSTGRES_DATABASES/--databases database lock is configured.). POSTGRES_REQUIRE_DATABASE_LOCK is independent and only requires that a lock is configured.
- advisory: the PostgreSQL grant check below still runs at startup and in
--validate. Each finding prints as[warning] database lock (advisory): <finding>(at most 32, then... N more findings.), followed by one line saying that PostgreSQL does not confine the configured login toPOSTGRES_DATABASES(or that the check could not confirm it), that tools still reject databases outside the list, and how to move to strict mode. Startup continues and--validateexits 0 when connections succeed. A check that cannot connect is also only a warning: the server starts and tool calls report the connection error. - strict: any finding, or a check that cannot complete, refuses startup (
[startup] Refusing to start: the PostgreSQL check for the configured database lock (POSTGRES_DATABASES) failed and POSTGRES_DATABASE_LOCK_MODE is strict.plus the findings, exit code 1;--validateexits 1).
A clean check in either mode prints [startup] Database lock verified: PostgreSQL grants confine every configured login to POSTGRES_DATABASES. The mode is reported to clients: list_databases returns database_lock_mode next to database_lock (omitted when there is no lock), and the initialize instructions and the execute_sql/list_databases descriptions state whether grants were verified (strict) or not required (advisory).
In advisory mode the server's own enforcement still applies. It protects against tool-level selection of a database outside the list: a call that names one returns invalid_target before any connection is attempted, list_databases lists only the locked names, and the server itself never opens a session on an out-of-lock database. That covers accidental or agent-chosen use of the wrong database through the tools.
It does not protect against SQL that runs inside an allowed database under a login whose PostgreSQL privileges reach further. The server cannot parse or filter SQL, so PostgreSQL grants are the only barrier, for example:
- A superuser, or a login granted
EXECUTEon it, can callpg_read_binary_file('base/<oid>/<relfilenode>')and read the data files of other databases; members ofpg_read_server_filescan read server files withCOPY ... FROMrun from a function. - A superuser, or a login that owns another database, can run
ALTER DATABASE <other> ...(rename it, change its owner or settings) from a locked database.DROP DATABASE <other>cannot run inside the transaction the server wraps around each call, but a superuser can issue it on a separate connection throughdblink_exec.CREATEDBandCREATEROLElet the login create databases or grant itself roles that reach further. - A superuser or a member of
pg_execute_server_programcan runCOPY ... TO PROGRAM '<shell command>'and act as the PostgreSQL operating-system user. The SQL guard refuses a statement that starts withCOPY, but the same command runs from a function the login creates (for example a PL/pgSQLEXECUTE), so the guard is not a barrier. - With
dblinkorpostgres_fdwinstalled (a superuser can install them withCREATE EXTENSIONwhen the server has the packages), or a foreign server defined, SQL can open connections to other databases.
Advisory mode lists such findings as warnings but does not stop them. Use strict mode with the role hardening below when PostgreSQL itself must enforce the list.
When a lock is configured, normal startup and --validate connect only through locked databases, once per distinct login/server profile, and check each of the following. In strict mode a hit refuses startup (exit code 1, findings on stderr); in advisory mode each hit is a warning (see Lock modes). Messages name profiles, roles and databases, never connection strings or credentials.
| Finding | Why it fails |
|---|---|
The login can connect (has_database_privilege(..., 'CONNECT'), including PUBLIC and inherited grants, on any database with datallowconn) to a database outside the lock |
PostgreSQL would accept that connection from any other client using the same credentials; the lock would be the only barrier. |
| The login is a superuser or a member of a superuser role | Superusers bypass every privilege check, including CONNECT grants, and can reach other databases through server-side features; membership allows SET ROLE to one. |
Membership in pg_read_server_files, pg_write_server_files or pg_execute_server_program |
Server file access and COPY ... PROGRAM reach other databases' data files or open new local connections outside PostgreSQL's database grants. |
CREATEROLE on the login or a role it is a member of |
It can create roles and grant memberships, so the verified grants need not hold one statement later. |
CREATEDB on the login or a role it is a member of |
It can create databases outside the lock (and drop ones it then owns), changing the set of databases the check verified. |
| The login (or a role it is a member of) owns a database outside the lock | Owners hold implicit CONNECT and can ALTER/DROP DATABASE from a connection to any other database, without connecting to it. |
EXECUTE on a server-file function (pg_read_file, pg_read_binary_file, pg_ls_dir, server-side lo_import/lo_export, adminpack pg_file_write/pg_file_unlink/pg_file_rename) held by the login or any role it can SET ROLE to, in a locked database the login can connect to |
PostgreSQL revokes these from PUBLIC, but an explicit grant needs no predefined-role membership. pg_read_binary_file can then read any file in the data directory, including other databases' relation files, so the role reads protected data without connecting to it; the adminpack functions can delete or move them. A grant reachable only through SET ROLE (a NOINHERIT login or an INHERIT FALSE membership) counts too. Function grants are per database, so every reachable locked database is checked. |
dblink or postgres_fdw installed, or any foreign server defined, in a locked database the login can connect to |
They open new connections from inside PostgreSQL, bypassing the server's connection-level lock. Installation is not checked as availability: both extensions are untrusted, so only a superuser can install them, which is already refused. |
Deliberately not refused: REPLICATION (the server never opens replication-protocol connections and logical decoding SQL functions decode only the current database), BYPASSRLS (affects rows only inside locked databases), and monitoring roles such as pg_read_all_stats (they expose names and activity, see below, not data). Review them anyway.
The check is a startup snapshot. Grants, role attributes, extensions or ownership changed after startup are not re-verified until the next start or --validate; the in-process lock still rejects other databases. Because CONNECT is evaluated from grants, a role that is confined only by pg_hba.conf rules is reported: revoke the grants too. Unreachable or unauthenticated profiles refuse startup in strict mode, because the lock cannot be verified; in advisory mode they are only a warning and tool calls report the connection error.
Follow this guide so that PostgreSQL itself enforces the lock and POSTGRES_DATABASE_LOCK_MODE=strict passes. Run as a superuser or the database owners, once per protected and locked database (replace placeholders):
-- Remove the default PUBLIC CONNECT from every database the MCP role must not reach,
-- including postgres and template1; grant it back explicitly to roles that need it.
REVOKE CONNECT ON DATABASE postgres FROM PUBLIC;
REVOKE CONNECT ON DATABASE template1 FROM PUBLIC;
REVOKE CONNECT ON DATABASE <protected_db> FROM PUBLIC;
GRANT CONNECT ON DATABASE postgres, <protected_db> TO <application_role>;
-- Locked databases: CONNECT for the MCP role only.
REVOKE CONNECT ON DATABASE <locked_db> FROM PUBLIC;
GRANT CONNECT ON DATABASE <locked_db> TO <mcp_role>;
ALTER ROLE <mcp_role> NOSUPERUSER NOCREATEDB NOCREATEROLE;
-- Remove memberships in superuser roles and pg_*_server_files/pg_execute_server_program;
-- In each locked database, revoke direct grants such as:
-- REVOKE EXECUTE ON FUNCTION pg_catalog.pg_read_binary_file(text) FROM <mcp_role>;
-- transfer ownership of out-of-lock databases away from the MCP role.Add pg_hba.conf rules so the server also rejects the role at authentication time. Rules match top to bottom, so place them before broader entries:
# TYPE DATABASE USER ADDRESS METHOD
hostssl <locked_db> <mcp_role> <client_cidr> scram-sha-256
host all <mcp_role> all reject
local all <mcp_role> reject
Do not install dblink or postgres_fdw and do not define foreign servers or user mappings in locked databases. If another application needs them, give it a separate database outside this role's lock. The server does not add SQL-guard rules for these features; PostgreSQL permissions remain the authority.
Database names remain visible: shared catalogs such as pg_database, pg_shdescription, pg_stat_database and pg_stat_activity (database and, with monitoring roles, other sessions' details) are readable from any locked database. list_databases filters by the lock, but execute_sql can query these catalogs directly. Treat database names as disclosed; the lock protects contents and connections, not names.
Each instance gets its own credentials source and lock. Keep credentials in each instance's owner-protected targets file or inherited environment, never in MCP JSON:
{
"mcpServers": {
"postgresql-server-a": {
"type": "stdio",
"command": "postgresql-sharp-mcp",
"env": {
"POSTGRES_TARGETS_FILE": "/home/<user>/postgresql-mcp/server-a-targets.json",
"POSTGRES_DATABASES": "[\"<server_a_db_1>\",\"<server_a_db_2>\"]",
"POSTGRES_DATABASE_LOCK_MODE": "strict",
"POSTGRES_REQUIRE_DATABASE_LOCK": "true"
}
},
"postgresql-server-b": {
"type": "stdio",
"command": "postgresql-sharp-mcp",
"env": {
"POSTGRES_TARGETS_FILE": "/home/<user>/postgresql-mcp/server-b-targets.json",
"POSTGRES_DATABASES": "[\"<server_b_db>\"]",
"POSTGRES_DATABASE_LOCK_MODE": "strict",
"POSTGRES_REQUIRE_DATABASE_LOCK": "true"
}
}
}
}Each targets file stays in its existing format, for example {"primary":"Host=<server_a_host>;Port=5432;Username=<mcp_role>;Password=<password>;Database=<server_a_db_1>;SSL Mode=VerifyFull"} (SSL Mode=VerifyFull is the hardened TLS choice described under Connection and resource settings, not a requirement). Run the same command with --validate and the same environment before starting the client; in strict mode it exits non-zero with the findings above if PostgreSQL does not enforce the lock. In advisory mode (the default when POSTGRES_DATABASE_LOCK_MODE is omitted) it prints them as warnings and exits 0.
The single-server quickstart uses masked Bash/Zsh/PowerShell entry into the process environment, not a credentials-bearing MCP JSON entry. Masking prevents terminal echo; the resulting value is still plaintext process state, inherited by child processes and inspectable by sufficiently privileged local processes. Process-level environment assignment does not automatically persist across sessions, but launchers, supervisors, containers or explicit persistence can retain it. Fully quit the client/server after changing a session credential, then launch the client from the same newly prepared shell. An already running client, a desktop launch or /mcp reload alone cannot acquire a changed parent-shell environment. Unsetting a parent variable does not erase copies already inherited by running children or deliberately persisted elsewhere.
An optional owner-protected targets file remains useful for multiple profiles, automation and clients where session propagation is impractical. It is persistent plaintext, protected by OS permissions rather than encryption; restrict file/folder access to the server's operating-system user and consider administrator, backup and sync exposure. A session environment is not inherently safer than an owner-only file: choose based on exposure, inheritance and lifecycle requirements. Keep existing protected file names if desired; no automatic local credential migration is performed.
Never put real credentials in committed files, SQL, tool arguments, profile aliases, literal CLI commands, shell history, shell startup profiles/rc files, setx or MCP JSON. A reusable rc function may contain only the masked-prompt code, invoked explicitly in the launching shell—not a literal secret or automatic plaintext export. CLI connection strings can be exposed in process arguments/history. Combining a connection string with targets JSON/file is rejected; when switching workflows, clear stale variables and remove conflicting client env/launch arguments rather than expecting secret precedence. Restart the server after changing file-based profiles/credentials; live database creation and grant/revoke changes need no recurring JSON edits.
The server does not return configured connection strings through database discovery. Live discovery can reveal sensitive database names; protect its results like other metadata. Configuration-parser failures use fixed diagnostics rather than forwarding parser or file-access exception messages. Unrecognized CLI arguments are not echoed. Missing or duplicate recognized options can identify the supported option name.
Npgsql error details and parameter logging are disabled by server-owned connection settings. PostgreSQL MessageText and Hint can contain query literals, data values, or arbitrary routine-generated text even when error details are disabled. Tool errors therefore retain SQLSTATE and a server-defined summary and forward PostgreSQL text only where it is derived from the caller's own statement: for SQLSTATE class 42 (syntax error or access rule violation) that PostgreSQL positions inside the submitted statement (position is set, so the parser or analyzer found it in the caller's own text) or that is a 42501 privilege error, and only when neither a routine context (Where) nor an internal query (InternalQuery, for example SPI inside query_to_xml) is present, server_message and server_hint carry MessageText/Hint, which name statement tokens and catalog objects. Class-42 errors raised while the statement executes carry no statement position and can echo row values (current_setting(col), col::regclass, col::regrole, query_to_xml(col, …)), so they stay withheld. position is PostgreSQL's cursor position mapped into the submitted statement, and integrity errors (class 23) not raised from a routine or internal query add only the violated constraint name, never Detail or key values. Other classes, such as 22P02 invalid input (which echoes the value) and routine-raised P0001, stay withheld. Connection and unexpected failures also use fixed summaries. Investigate other detailed database errors using an appropriately protected PostgreSQL diagnostic channel.
MCP SDK and Npgsql logger categories are disabled, including when the configured log level is debug or trace, because SDK transport logging can include complete requests and responses. Remaining host diagnostics go to stderr; stdout is reserved for MCP JSON-RPC. The log-level setting does not enable SDK wire tracing.
These protections are not content redaction for successful results. Queries, plans, object definitions, statistics, workload text, and metadata can contain sensitive information that the configured role is allowed to read. --validate writes the actual database name and a bounded result or safe error to stderr. An MCP client, process supervisor, PostgreSQL server, proxy, or external diagnostic tool can independently record sensitive inputs and outputs. Protect those channels as well.
TLS is encrypted when the server offers it, and not verified by default. A connection string (the base POSTGRES_CONNECTION_STRING/--connection-string or any targets JSON/file profile) that does not set SSL Mode is given SSL Mode=Prefer at startup, before any connection exists, for every host, loopback included. Prefer uses TLS when the server offers it and otherwise continues in plaintext, and it never verifies the server certificate or host name. This lets a plain connection string work against any server, but on a network you do not control an attacker on the path can force plaintext, read the traffic, or impersonate the server and capture the login credentials. This is acceptable on a trusted network or for local development. Startup and --validate therefore print a non-blocking [warning] for each profile whose host is not loopback or a Unix-domain socket and whose mode is not VerifyCA/VerifyFull (the Prefer default included): [warning] profile <alias>: SSL Mode=Prefer to a non-local host encrypts only if the server offers TLS and never verifies the server certificate, so someone on the network path could read the traffic or impersonate the server. This is fine on a trusted network; to harden, set SSL Mode=VerifyFull in the connection string (add Root Certificate=<CA file> for a private CA). The wording is adapted for Disable (does not encrypt the connection), Require (encrypts without verifying the server certificate) and Allow (encrypts only if the server requires TLS and never verifies the server certificate).
To verify the server, opt in with SSL Mode=VerifyFull (or VerifyCA) in the connection string, plus Root Certificate=<CA file> for a private or provider CA (the system store is used for public CAs). A connection string with Root Certificate but no SSL Mode also gets VerifyFull, because Npgsql's Prefer would ignore the CA file. The certificate chain and host name are then verified during the TLS handshake, no credential, not even the user name, is sent to an unverified server, and there is no fallback to plaintext or to unverified TLS. If verification fails, no connection is made: the tool or --validate reports the fixed errors tls_verification_failed (untrusted CA or host name not in the certificate) or tls_unavailable (the server offers no TLS; also reported with SSL Mode=Require), naming only the profile and the remedy, never the connection string, credentials or provider text. These errors occur only with a verifying mode (or Require for tls_unavailable). TLS is configured only through the connection string's SSL Mode; there is no separate server setting, so targets profiles can differ.
An explicit SSL Mode, any value, is honored unchanged. 0.6.0 instead defaulted to VerifyFull for non-loopback hosts and Disable for loopback, and 0.7.0 relaxed that default (see the upgrade note); existing profiles that relied on the 0.6.0 default keep verification by setting SSL Mode=VerifyFull explicitly (profiles that already set Root Certificate keep it automatically). TLS protocol versions and cipher suites are negotiated by .NET and the PostgreSQL server. Use network controls to restrict database reachability.
The server owns pool bounds, reset-on-close, transaction enlistment, multiplexing, application name, command timeouts, and logging safety settings. Configuration with No Reset On Close=true or multiplexing is rejected. Operation deadlines include queue and connection-pool wait; transactions also set statement and lock timeouts. Result row, byte, cell, and column limits bound responses. These are resource controls, not a guarantee against expensive queries, external side effects, or denial of service. Apply PostgreSQL and operating-system resource controls where needed.
Pools are keyed by normalized connection settings, including credentials/TLS and physical database. The runtime cache holds at most floor(256 / POSTGRES_POOL_SIZE) pool entries, each with that configured connection maximum. Idle least-recently-used entries are disposed when capacity is needed; active entries are not evicted. At full active capacity, calls wait within their existing operation deadline. Failed selections do not accumulate pool entries. These bounds prevent unbounded per-database pool growth, not privileged cross-database access.
Do not post credentials, private data, or an exploit against an operational database in a public issue. Private vulnerability reporting is enabled for this repository: open Security and quality, then Report a vulnerability, following GitHub's private-report instructions. No particular response time is guaranteed. If the private route becomes unavailable, open a minimal public issue asking the maintainer for a confidential contact route, without sensitive details.
A useful report includes the affected version or commit, operating system and PostgreSQL version, configuration with secrets removed, the security boundary crossed, and a minimal reproduction using disposable data. Do not test systems without authorization. This document describes implementation protections and limitations; it is not an independent security audit or certification.
The maintainer is the only person who sees a private report. This is the process, in order; no step has a guaranteed duration.
- Receipt and triage. The maintainer reads the private report, tries to reproduce it with disposable data and decides whether it crosses a boundary this document promises (trust boundary, PostgreSQL permissions are the authority, database lock). Behavior that is already documented as a limitation, such as the SQL guard not being an authorization parser, is answered with that documentation and may become a documentation fix. The maintainer asks the reporter for missing details in the private thread.
- Fix. A confirmed vulnerability is fixed privately when public discussion would help exploitation before a release exists, otherwise through the normal pull-request path. The fix includes a regression scenario in the verifier (CONTRIBUTING.md). A vulnerability in a dependency is fixed by updating or replacing it, as Dependabot alerts, the dependency audit and the container scan surface it.
- Release. The fix ships in a new patch release from
mainthrough the release procedure; older releases are not patched, so affected users upgrade (upgrade guidance). The CHANGELOG entry and GitHub Release notes name each fixed vulnerability. - Disclosure and credit. After a fixed release exists, the maintainer publishes a GitHub security advisory (requesting a CVE through GitHub when severity warrants one) and credits the reporter in the advisory and the CHANGELOG unless the reporter asks to stay anonymous. The reporter may propose a disclosure date; the maintainer decides it with the fix's availability in mind.
Security fixes are the only changes that may be developed outside a public pull request before release. Everything else follows docs/git-workflow.md.
docs/assurance-case.md lists the security requirements this software is meant to meet, the threat model and trust boundaries, and the argument, with evidence, that secure design principles were applied and common weaknesses are countered. It also records the residual risks and the gaps found while writing it. docs/architecture.md describes the components those arguments refer to.