multi-postgres-mcp-server is a Model Context Protocol (MCP) server that provides
AI coding assistants (Claude Code, Cursor, Windsurf, etc.) with access to multiple
PostgreSQL databases through a single process. Because this server bridges AI agents
and live databases, security is a first-class design concern.
This document describes the security model, built-in protections, recommended deployment practices, and how to report vulnerabilities.
- Security Model
- Recommended PostgreSQL Role Setup
- Deployment Hardening Checklist
- Known Limitations
- Reporting a Vulnerability
All database connections default to read-only mode. Every query issued through
the pg_query tool is wrapped in a read-only transaction:
BEGIN TRANSACTION READ ONLY;
-- user query executes here --
COMMIT;This means that INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER, and other
write operations will fail at the PostgreSQL level with an error, even if the
connected database user has write privileges.
When a connection is explicitly configured with readOnly: false, the read-only
transaction wrapper is removed for that connection. This should only be used in
development environments where write access is intentional and understood.
The server includes a state-machine SQL parser that analyzes queries before execution. The parser:
- Strips single-line comments (
-- ...) - Strips block comments (
/* ... */) - Strips string literals (single-quoted
'...', double-quoted"...", and dollar-quoted$$...$$) - Scans the remaining text for semicolons
If a semicolon is found outside of comments and string literals, the query is rejected as a potential multi-statement injection. This prevents attacks like:
SELECT 1; DROP TABLE users; --The pg_query tool supports parameterized queries with positional placeholders.
Callers can pass values through a params array, which maps to $1, $2, etc.
in the SQL text:
{
"database": "production",
"query": "SELECT * FROM users WHERE email = $1 AND status = $2",
"params": ["user@example.com", "active"]
}Values passed through params are sent to PostgreSQL as bind parameters and are
never interpolated into the SQL string, eliminating SQL injection through
parameter values.
The pg_list_tables and pg_describe_table tools use hardcoded,
parameterized queries against PostgreSQL's information_schema and system
catalogs. User-supplied table and schema names are always passed as bind
parameters ($1, $2), never concatenated into SQL.
For cloud-hosted databases (AWS RDS, Azure Database for PostgreSQL, Google Cloud
SQL, Supabase, Neon, etc.), SSL connections are supported. Configure the ssl
property in your connection entry:
{
"label": "cloud-db",
"host": "your-instance.region.rds.amazonaws.com",
"port": 5432,
"user": "mcp_readonly",
"password": "...",
"database": "myapp",
"ssl": { "rejectUnauthorized": true }
}For self-signed certificates, you may set "rejectUnauthorized": false, though
this is not recommended for production.
| Setting | Value | Purpose |
|---|---|---|
connectionTimeoutMillis |
10,000 ms (10 s) | Prevents hanging on unreachable hosts |
statement_timeout |
30,000 ms (30 s) | Kills long-running queries server-side |
These values are hardcoded to prevent denial-of-service scenarios where a malformed or expensive query could tie up a connection indefinitely.
The server uses pg.Pool with lazy initialization per database label. Pools are
created on first access and automatically refreshed if the connection config
changes (hot-reload aware via hash comparison). Key pool settings:
| Setting | Value | Purpose |
|---|---|---|
max |
Configurable (poolSize, default 5) |
Limits concurrent connections per database |
idleTimeoutMillis |
60,000 ms (60 s) | Closes idle connections to free resources |
On graceful shutdown (SIGTERM/SIGINT), all pools are drained and connections
are properly closed.
The configuration file is read from the local filesystem using Node.js fs
modules. There is no network-based config loading, no remote URLs, and no
environment variable interpolation inside the config file.
The config path is resolved in this order:
--config <path>CLI argumentMCP_POSTGRES_CONFIGenvironment variable- Default:
~/.mcp-postgres/config.json
Connection configuration is validated using Zod schemas. Malformed entries (missing required fields, wrong types, invalid port numbers) are rejected at load time with descriptive error messages.
Database passwords are stored in plaintext within the config file. To mitigate the risk of credential exposure:
On Linux/macOS:
chmod 600 ~/.mcp-postgres/config.jsonOn Windows: Restrict file access via Properties > Security to your user account only.
Additional recommendations:
- Add
config.jsonto your global.gitignoreto prevent accidental commits - Consider using connection strings with environment variables for CI/CD:
MCP_POSTGRES_CONFIG=/secure/path/config.json - For maximum security, use PostgreSQL's
~/.pgpassfile or cloud IAM authentication instead of passwords in the config
The --label flag restricts the server to a single database connection:
node dist/index.js --label productionWhen --label is set:
- Only the matching connection is visible
pg_list_databasesshows only that one database- All other connections are completely hidden from the AI agent
- This prevents an AI from discovering or querying databases it should not access
This is the recommended configuration for per-project Cursor setups, where each project should only access its own database.
Create a dedicated read-only role for the MCP server. Do not reuse application credentials or superuser accounts.
-- 1. Create a dedicated role
CREATE ROLE mcp_readonly LOGIN PASSWORD 'secure_password';
-- 2. Grant connection access
GRANT CONNECT ON DATABASE mydb TO mcp_readonly;
-- 3. Grant schema usage
GRANT USAGE ON SCHEMA public TO mcp_readonly;
-- 4. Grant SELECT on all existing tables
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_readonly;
-- 5. Auto-grant SELECT on future tables
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO mcp_readonly;-- Repeat for each schema the MCP server needs to read
GRANT USAGE ON SCHEMA analytics TO mcp_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA analytics TO mcp_readonly;
ALTER DEFAULT PRIVILEGES IN SCHEMA analytics
GRANT SELECT ON TABLES TO mcp_readonly;-- Prevent the role from creating objects
ALTER ROLE mcp_readonly NOSUPERUSER NOCREATEDB NOCREATEROLE;
-- Set a connection limit
ALTER ROLE mcp_readonly CONNECTION LIMIT 5;
-- Set a statement timeout at the role level (belt and suspenders)
ALTER ROLE mcp_readonly SET statement_timeout = '30s';-- Connect as mcp_readonly and confirm read-only access
SET ROLE mcp_readonly;
SELECT count(*) FROM users; -- Should succeed
INSERT INTO users DEFAULT VALUES; -- Should fail: permission denied
DROP TABLE users; -- Should fail: permission denied- Use a dedicated PostgreSQL role with
SELECT-only privileges (see above) - Set
chmod 600(or equivalent) on the config file - Add config file path to
.gitignore - Enable SSL for all non-localhost connections
- Use
--labelfiltering for per-project deployments - Set
enabled: falseon connections that are not actively needed - Keep
readOnly: true(the default) unless you have a specific reason to change it - Review PostgreSQL logs (
log_statement = 'all') to audit MCP server queries - Run the MCP server process under a non-privileged OS user
- Keep dependencies updated (
npm audit,npm update)
| Limitation | Mitigation |
|---|---|
| Config file stores passwords in plaintext | Use restrictive file permissions (chmod 600); consider ~/.pgpass or IAM auth |
| No query allow-listing or deny-listing | Rely on read-only transactions + restricted PostgreSQL role privileges |
Dollar-quoted string parsing ($$...$$) is best-effort |
Exotic or nested dollar-quoting may bypass multi-statement detection; the read-only transaction provides a second layer of defense |
| No rate limiting on tool invocations | Rate limiting is the responsibility of the MCP client (Claude Code, Cursor, etc.) |
| No audit logging | Enable PostgreSQL-side logging (log_statement, pgAudit) for a complete audit trail |
| Connection credentials visible in process arguments if passed via CLI | Use config file or environment variable instead of inline arguments |
| Version | Supported |
|---|---|
| 1.x (current) | Yes |
We take the security of this project seriously. If you discover a security vulnerability, please report it responsibly.
-
GitHub Private Vulnerability Reporting (preferred) Navigate to the Security Advisories page and click "Report a vulnerability". This creates a private channel between you and the maintainers.
-
GitHub Issues For lower-severity issues that do not involve credential exposure or remote code execution, you may open a regular GitHub Issue with the
securitylabel.
- A clear description of the vulnerability
- Steps to reproduce (proof of concept if possible)
- Affected version(s)
- Potential impact assessment
- Suggested fix (if you have one)
| Step | Timeframe |
|---|---|
| Acknowledgment of report | Within 48 hours |
| Initial assessment and severity classification | Within 5 business days |
| Patch development and testing | Depends on severity |
| Public disclosure (coordinated) | After patch is available |
The following are in scope for security reports:
- SQL injection bypasses (e.g., circumventing multi-statement detection)
- Read-only transaction escapes
- Configuration file parsing vulnerabilities
- Dependency vulnerabilities with a known exploit path
- Information disclosure through error messages
The following are out of scope:
- PostgreSQL server vulnerabilities (report to the PostgreSQL project)
- MCP protocol-level issues (report to the MCP specification maintainers)
- Denial-of-service through legitimate but expensive queries (mitigated by
statement_timeout) - Social engineering attacks
This project follows a defense-in-depth approach with multiple independent layers of protection:
Layer 1: PostgreSQL role privileges (SELECT only)
Layer 2: Read-only transaction wrapper (BEGIN TRANSACTION READ ONLY)
Layer 3: Multi-statement detection (reject semicolons)
Layer 4: Parameterized queries (no string interpolation)
Layer 5: Connection timeouts (10s connect, 30s query)
Layer 6: --label isolation (single-database exposure)
Each layer is designed to function independently. Even if one layer is bypassed, the remaining layers continue to provide protection. For example:
- If multi-statement detection is bypassed, the read-only transaction still prevents writes
- If the read-only transaction is somehow circumvented, the PostgreSQL role privileges still deny write operations
- If a query runs longer than expected, the
statement_timeoutkills it server-side
Last updated: 2026-02-07