使用说明
PostgreSQL MCP Server
Connects AI to PostgreSQL via the Model Context Protocol (MCP).
Features
MCP tools
- 12 tools:
test_connection,list_schemas,list_tables,describe_table,query,executeand the performance toolsexplain_query,top_queries,table_stats,index_health,active_queries,performance_overview queryruns insideBEGIN READ ONLY, one statement per call — PostgreSQL itself rejects writes, not a keyword filterexecuteruns in a transaction with automaticROLLBACKon error- Parameterized queries (
params), results capped at 200 rows to keep LLM context small - Query plans via
explain_query(orEXPLAINthroughquery):ANALYZEactually runs the statement, but inside a read-only transaction that is always rolled back;generic_planexplains statements with unbound$1placeholders (PostgreSQL 16+) - Performance tuning: slowest statements from
pg_stat_statements, stale statistics and per-column planner statistics, table bloat and seq-scan hot spots, unused/duplicate/invalid indexes, running and blocked sessions with wait events, cache hit ratio and key settings — all read-only, see Performance analysis
Multi-user & access control
- Bearer-token authentication with two levels: admin token (
AUTH_TOKEN) and any number of client tokens - Per-token database connection — one server instance serves several databases / DB users; each token is routed to its own connection pool
- Client-supplied connections (opt-in per token): clients log in with their own database account or choose the database via
X-Pg-*headers (details) - Tokens can be created, renamed, disabled and deleted at runtime — via web Admin UI (
/admin, screenshots), REST API (OpenAPI spec included) oradmincli.sh - Admin UI in your corporate design: own logo and colors without rebuilding the image (branding)
- Tokens stored only as SHA-256 hashes, constant-time comparison; per-token DB passwords encrypted at rest (AES-256-GCM,
STORE_ENCRYPTION_KEY)
Security & transport
- stdio (Claude Desktop / Claude Code) or Streamable HTTP with session management
- HTTPS with own certificates, auto-generated self-signed certs (renewed before expiry) and optional mTLS for clients
- TLS to PostgreSQL incl. server-cert verification (
PG_SSL=verify) and client certificates - Container runs as non-root (uid 1000), no privilege escalation, all capabilities dropped in Kubernetes
Audit logging
- Every tool call with token name, client IP and parameters; session start/stop with duration
- Failed logins with reason (unknown / disabled / invalid admin token) — presented tokens are never logged
- Admin API actions (example output);
LOG_LEVEL(debug/info/warn/error) — SQL text only atdebug
Operations
- Docker image on Docker Hub,
docker-composewith test database, persistent volumes for tokens and certs - Helm chart: PVC for the token store, existing Secrets, ingress, HPA, TLS-aware health probes
- Only two runtime dependencies (MCP SDK,
pg), no build step, ~100 unit tests, Dependabot updates
Why this server?
Most PostgreSQL MCP servers are built for one developer on one machine: started via stdio with a single connection string, no authentication and no audit trail. This server targets the shared, self-hosted case — a team or several AI clients using one central instance:
| Typical PostgreSQL MCP server | pg-mcp-server | |
|---|---|---|
| Deployment | Local process per user (stdio) | Central service (Docker / Kubernetes) plus stdio |
| Authentication | None — whoever can start it has access | Bearer tokens, admin vs. client, revocable at runtime |
| Databases per instance | One connection string | One per token, managed centrally |
| Credential handling | DB password in every client config | DB password stays on the server; clients only get a revocable token |
| Read-only safety | Often SQL keyword checks or none | Enforced by PostgreSQL (BEGIN READ ONLY) |
| Audit | Usually none | Who ran which tool from which IP, failed logins, sessions |
| Transport security | Plain local process | HTTPS, mTLS, verified TLS to PostgreSQL |
Compared to the archived reference server @modelcontextprotocol/server-postgres (stdio only, a single read-only query tool), this server adds schema tools, a separate write tool, remote transport and everything above.
When to choose something else: the performance tools cover plans, workload statistics from pg_stat_statements, index and table health. For index recommendations verified with hypothetical indexes, look at Postgres MCP Pro; for several database engines (MySQL, SQL Server, SQLite, …) behind one server, look at DBHub. pg-mcp-server focuses on secure, audited, multi-user SQL access to PostgreSQL.
Overview
| Mode | Transport | When to use |
|---|---|---|
| Local (Node.js) | stdio | Development, no Docker |
| Docker / Remote | HTTP or HTTPS | Different host on the network |
| Kubernetes | HTTP or HTTPS | Production, Helm chart |
Environment Variables
| Variable | Default | Description |
|---|---|---|
TRANSPORT |
stdio |
stdio or http |
PORT |
3000 |
HTTP(S) port |
AUTH_TOKEN |
– | Admin token for /mcp and /admin/tokens (empty = auth disabled) |
MCP_SERVER_NAME |
pg-mcp-server |
Server name shown in MCP clients and the Admin UI title; prefix of the database application_name (<name>:<token>) |
ADMIN_THEME_CSS |
– | CSS file loaded after the built-in admin UI styles (branding) |
ADMIN_LOGO |
– | Admin UI logo: file (.svg .png .jpg .gif .webp, embedded) or http(s):// URL |
LOG_LEVEL |
info |
debug, info, warn or error. SQL text of query/execute is only logged at debug |
STORE_ENCRYPTION_KEY |
– | Passphrase for AES-256-GCM encryption of stored connection passwords. Set before adding tokens with passwords. |
TOKENS_FILE |
./tokens.json |
Path to the JSON file that stores tokens and their connection configs |
TLS_ENABLED |
false |
true → HTTPS, false → HTTP |
TLS_CERT_FILE |
/certs/tls.crt |
Server certificate (PEM) |
TLS_KEY_FILE |
/certs/tls.key |
Server key (PEM) |
TLS_CA_FILE |
– | Client CA for mTLS (optional) |
TLS_SAN |
– | Additional SANs for self-signed cert, e.g. DNS:myhost,IP:1.2.3.4 |
PG_HOST |
localhost |
Default PostgreSQL host (used when a token has no custom connection) |
PG_PORT |
5432 |
Default PostgreSQL port |
PG_DATABASE |
postgres |
Default database name |
PG_USER |
postgres |
Default username |
PG_PASSWORD |
– | Default password |
PG_SSL |
false |
Default SSL mode: false / true / verify |
PG_CLIENT_CONNECTION |
none |
none | credentials | full: connection parameters clients of the AUTH_TOKEN (or anonymous) may send as X-Pg-* headers (details) |
PG_SSL_CA_FILE |
– | CA for PostgreSQL certificate (when PG_SSL=verify) |
PG_SSL_CERT_FILE |
– | Client certificate for PostgreSQL mTLS |
PG_SSL_KEY_FILE |
– | Client key for PostgreSQL mTLS |
Docker Hub
The image is available on Docker Hub:
docker pull tommi2day/pg-mcp-server:latest
| Tag | Description |
|---|---|
latest |
Latest build from main |
1.2.3 |
Specific version |
1.2 |
Latest patch of 1.2 |
sha-abc1234 |
Specific commit |
Quick start from Hub
docker run -d --name pg-mcp-server \
-p 3000:3000 \
--add-host=host.docker.internal:host-gateway \
-e TRANSPORT=http \
-e AUTH_TOKEN=$(openssl rand -hex 32) \
-e TOKENS_FILE=/data/tokens.json \
-e PG_HOST=host.docker.internal \
-e PG_DATABASE=mydb \
-e PG_USER=user \
-e PG_PASSWORD=password \
-v pg-mcp-data:/data \
-v pg-mcp-certs:/certs \
tommi2day/pg-mcp-server:latest
Both volumes keep their contents across container restarts and re-creation:
| Volume | Mount | Content |
|---|---|---|
pg-mcp-data |
/data |
Token store (tokens.json) |
pg-mcp-certs |
/certs |
TLS certificate + key (self-signed cert generated on first start with TLS_ENABLED=true), optional pg-ca.crt |
In docker-compose.yml
Use the Hub image instead of building locally:
services:
pg-mcp-server:
image: tommi2day/pg-mcp-server:latest
# build: . ← remove or comment out
1 · Local (stdio)
npm install
node src/index.js
claude_desktop_config.json:
{
"mcpServers": {
"postgresql": {
"command": "node",
"args": ["/path/to/src/index.js"],
"env": {
"PG_HOST": "localhost",
"PG_DATABASE": "mydb",
"PG_USER": "user",
"PG_PASSWORD": "password"
}
}
}
}
2 · Docker
Quick start with run.sh
scripts/run.sh builds and starts the container in one step:
# Optionally configure the PostgreSQL connection via .env in the project root
cp .env.example .env
# edit .env: set PG_HOST, PG_DATABASE, PG_USER, PG_PASSWORD, ...
./scripts/run.sh # start as "pg-mcp-server"
./scripts/run.sh my-name # start with a custom container name
run.sh reads PGHOST / PGPORT / PGDATABASE / PGUSER / PGPASSWORD / PG_SSL from .env and auto-generates AUTH_TOKEN on first run (saved to ./auth_token).
- Stops and removes any existing container with the same name
- Auto-generates
AUTH_TOKENon first run and saves it to./auth_token - Reads
defaults.envand then.envfrom the project root if present (.envwins) - Image:
IMAGE(defaulttommi2day/pg-mcp-server) andIMAGE_TAG(defaultlatest), e.g. to use a mirror in a private registry
defaults.env is meant for site-specific defaults that are committed in a fork (registry, image tag, Helm values files), while .env stays local and untracked. scripts/helm_install.sh reads the same files and additionally accepts IMAGE_REPO, IMAGE_TAG and HELM_VALUES_FILES (space-separated list of extra -f values files).
Quick start (manual)
# Build image
docker build -t pg-mcp-server .
# Run against a local PostgreSQL
docker run -d --name pg-mcp-server \
-p 3000:3000 \
--add-host=host.docker.internal:host-gateway \
-e TRANSPORT=http \
-e AUTH_TOKEN=$(openssl rand -hex 32) \
-e TOKENS_FILE=/data/tokens.json \
-e PG_HOST=host.docker.internal \
-e PG_DATABASE=mydb \
-e PG_USER=user \
-e PG_PASSWORD=password \
-v pg-mcp-data:/data \
-v pg-mcp-certs:/certs \
pg-mcp-server
With docker-compose (including test database)
Copy the example env file, edit it, then start:
cp .env.example .env
# edit .env: set AUTH_TOKEN, PG_PASSWORD, etc.
docker compose up -d
docker compose logs -f pg-mcp-server
docker compose automatically reads .env from the project root. The docker-compose.yml includes a postgres-test container (port 5433) that must be healthy before pg-mcp-server starts (depends_on: condition: service_healthy).
Key variables in .env:
| Variable | Default | Description |
|---|---|---|
AUTH_TOKEN |
(empty) | Admin bearer token; leave empty to disable auth |
MCP_SERVER_NAME |
pg-mcp-server |
Server name shown in MCP clients and the Admin UI; prefix of the database application_name |
LOG_LEVEL |
info |
debug / info / warn / error |
STORE_ENCRYPTION_KEY |
(empty) | Passphrase for AES-256-GCM encryption of stored connection passwords |
MCP_PORT |
3000 |
Host port for the MCP server |
MCP_HOSTNAME |
pg-mcp-server |
Fixed container hostname; included as DNS SAN in the self-signed certificate |
PG_HOST |
postgres-test |
PostgreSQL host (use host.docker.internal for a local DB outside Docker) |
PG_DATABASE |
testdb |
Database name |
PG_USER |
postgres |
Database user |
PG_PASSWORD |
postgres |
Database password |
PG_SSL |
false |
false / true / verify |
TLS_ENABLED |
false |
true to enable HTTPS |
Enable TLS (optional)
environment:
TLS_ENABLED: "true"
TLS_SAN: "DNS:my-host.local,IP:192.168.1.10"
docker-compose.yml mounts the named volume mcp-certs at /certs (and mcp-data at /data for the token store), so certificates survive docker compose down / up. To use your own certificates, replace mcp-certs:/certs with a bind mount such as ./certs:/certs.
On startup:
/certscontains a certificate → it is used. If the container (uid 1000) owns the key, it is set to mode640; the key is never made world-readable. Mounted certs must be readable by uid 1000 (owner, or group 1000 with key mode640)/certsis empty → a self-signed certificate is generated and stored in the volume- A previously generated self-signed certificate (
CN=pg-mcp-server) that expires within 30 days is regenerated automatically; your own certificates are never replaced
docker compose down -v deletes both volumes, including all tokens.
Once running, open http://localhost:3000/admin to manage tokens via the web UI.
Traefik reverse proxy (optional)
Route traffic through Traefik with automatic HTTP → HTTPS redirect and Let's Encrypt TLS.
TLS terminates at Traefik — keep TLS_ENABLED=false inside the container.
Both variants require the traefik-public external network (created by your Traefik stack).
Add it to the pg-mcp-server service networks list:
networks:
- mcp-net
- traefik-public
And declare it in the top-level networks: section:
networks:
mcp-net:
driver: bridge
traefik-public:
external: true
Host-based routing
Dedicated domain for the MCP server (e.g. pg-mcp.example.com):
labels:
- "traefik.enable=true"
# HTTP → HTTPS redirect
- "traefik.http.routers.pg-mcp-http.rule=Host(`pg-mcp.example.com`)"
- "traefik.http.routers.pg-mcp-http.entrypoints=web"
- "traefik.http.routers.pg-mcp-http.middlewares=pg-mcp-https-redirect"
- "traefik.http.middlewares.pg-mcp-https-redirect.redirectscheme.scheme=https"
- "traefik.http.middlewares.pg-mcp-https-redirect.redirectscheme.permanent=true"
# HTTPS router
- "traefik.http.routers.pg-mcp.rule=Host(`pg-mcp.example.com`)"
- "traefik.http.routers.pg-mcp.entrypoints=websecure"
- "traefik.http.routers.pg-mcp.tls=true"
- "traefik.http.routers.pg-mcp.tls.certresolver=letsencrypt"
- "traefik.http.services.pg-mcp.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public"
MCP URL: https://pg-mcp.example.com/mcp
Path-based routing
Sub-path on a shared domain (e.g. https://proxy.example.com/pg-mcp).
Traefik strips the /pg-mcp prefix before forwarding, so the container still receives requests at /mcp, /admin/tokens, etc. unchanged.
labels:
- "traefik.enable=true"
# HTTP → HTTPS redirect
- "traefik.http.routers.pg-mcp-http.rule=PathPrefix(`/pg-mcp`)"
- "traefik.http.routers.pg-mcp-http.entrypoints=web"
- "traefik.http.routers.pg-mcp-http.middlewares=pg-mcp-https-redirect"
- "traefik.http.middlewares.pg-mcp-https-redirect.redirectscheme.scheme=https"
- "traefik.http.middlewares.pg-mcp-https-redirect.redirectscheme.permanent=true"
# HTTPS router — strip prefix, then forward to container
- "traefik.http.routers.pg-mcp.rule=PathPrefix(`/pg-mcp`)"
- "traefik.http.routers.pg-mcp.entrypoints=websecure"
- "traefik.http.routers.pg-mcp.tls=true"
- "traefik.http.routers.pg-mcp.tls.certresolver=letsencrypt"
- "traefik.http.routers.pg-mcp.middlewares=pg-mcp-strip"
- "traefik.http.middlewares.pg-mcp-strip.stripprefix.prefixes=/pg-mcp"
- "traefik.http.services.pg-mcp.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public"
MCP URL: https://proxy.example.com/pg-mcp/mcp
For run.sh / docker run, translate each label to a --label flag.
Connect Claude Desktop / .mcp.json
{
"mcpServers": {
"postgresql": {
"type": "http",
"url": "http://<HOST>:3000/mcp",
"headers": {
"Authorization": "Bearer <AUTH_TOKEN>"
}
}
}
}
Replace http:// with https:// for HTTPS.
Claude Desktop (claude_desktop_config.json) — HTTP/Remote
[!WARNING] Claude Desktop only supports HTTPS for non-local (remote) MCP servers. If your server runs on plain HTTP and is not on
localhost, Claude Desktop will refuse the connection. Use one of the options below:Option A — Preferred: Put the server behind a reverse proxy (Traefik, nginx, Caddy) with a valid TLS certificate and use
https://in the URL.Option B — Quick workaround: Use
mcp-remoteas a local stdio bridge. It runs on your machine and forwards requests to the HTTP server, so Claude Desktop treats it like a stdio tool.
{
"mcpServers": {
"postgresql": {
"command": "npx",
"args": [
"-y", "mcp-remote",
"http://192.168.1.100:3000/mcp",
"--allow-http",
"--header", "Authorization:Bearer <AUTH_TOKEN>"
]
}
}
}
Verify the MCP endpoint
Health check:
curl http://localhost:3000/health
MCP initialize handshake (checks that the server responds to MCP requests):
curl -s -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <AUTH_TOKEN>" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
Interactive browser UI via MCP Inspector:
# Local server — direct HTTP
npx @modelcontextprotocol/inspector http://localhost:3000/mcp
# Remote HTTP server — via mcp-remote bridge
npx @modelcontextprotocol/inspector \
npx mcp-remote http://192.168.1.100:3000/mcp \
--allow-http \
--header "Authorization:Bearer <AUTH_TOKEN>"
The inspector opens a browser UI where you can list tools, call them individually, and inspect responses.
3 · Kubernetes with Helm
Prerequisites
kubectlconfiguredhelmv3 installed- Image accessible in a registry
Quick start (HTTP, no TLS)
helm install pg-mcp ./helm/pg-mcp-server \
--namespace mcp --create-namespace \
--set image.repository=tommi2day/pg-mcp-server \
--set postgresql.host=my-db-host \
--set postgresql.database=mydb \
--set postgresql.user=user \
--set postgresql.password=secret \
--set auth.token=$(openssl rand -hex 32)
With HTTPS
# Create TLS secret
kubectl create secret tls pg-mcp-tls \
--cert=certs/tls.crt --key=certs/tls.key -n mcp
# Create auth secret
kubectl create secret generic my-auth-secret \
--from-literal=token=$(openssl rand -hex 32) -n mcp
# PostgreSQL CA (only for PG_SSL=verify)
kubectl create secret generic pg-ca-cert \
--from-file=ca.crt=certs/pg-ca.crt -n mcp
helm install pg-mcp ./helm/pg-mcp-server \
--namespace mcp --create-namespace \
--set server.tlsEnabled=true \
--set tls.existingSecret=pg-mcp-tls \
--set auth.existingSecret=my-auth-secret \
--set postgresql.ssl=verify \
--set tls.pgCaSecret=pg-ca-cert \
--set image.repository=tommi2day/pg-mcp-server \
--set postgresql.host=my-db-host \
--set postgresql.database=mydb \
--set postgresql.user=user \
--set postgresql.existingSecret=pg-credentials
Production values.yaml
image:
repository: tommi2day/pg-mcp-server
tag: "latest"
replicaCount: 2
persistence:
enabled: true
size: 50Mi
storageClass: "standard"
auth:
existingSecret: "my-auth-secret"
postgresql:
host: "rds.example.com"
database: "prod_db"
user: "prod_user"
ssl: "verify"
existingSecret: "pg-credentials"
server:
tlsEnabled: true
tls:
existingSecret: "pg-mcp-tls"
pgCaSecret: "pg-ca-cert"
service:
type: LoadBalancer
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: pg-mcp.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: pg-mcp-ingress-tls
hosts:
- pg-mcp.example.com
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
Upgrade / Uninstall
helm upgrade pg-mcp ./helm/pg-mcp-server -n mcp -f my-values.yaml
helm uninstall pg-mcp -n mcp
MCP Client Configuration
Use the HTTP variant when the server runs in Docker or on a remote host, and the stdio variant for a local Node.js install.
Claude Code CLI
# HTTP (Docker / remote)
claude mcp add --transport http postgresql http://localhost:3000/mcp \
--header "Authorization: Bearer <AUTH_TOKEN>"
# stdio (local Node.js)
claude mcp add postgresql node /path/to/src/index.js \
--env PG_HOST=localhost \
--env PG_DATABASE=mydb \
--env PG_USER=user \
--env PG_PASSWORD=password
Add --scope user to register the server globally instead of per-project.
VS Code (GitHub Copilot)
Create .vscode/mcp.json in your project root (VS Code 1.99+):
HTTP (Docker / remote)
{
"servers": {
"postgresql": {
"type": "http",
"url": "http://localhost:3000/mcp",
"headers": {
"Authorization": "Bearer <AUTH_TOKEN>"
}
}
}
}
stdio (local Node.js)
{
"servers": {
"postgresql": {
"type": "stdio",
"command": "node",
"args": ["/path/to/src/index.js"],
"env": {
"PG_HOST": "localhost",
"PG_DATABASE": "mydb",
"PG_USER": "user",
"PG_PASSWORD": "password"
}
}
}
}
OpenCode
Use the interactive opencode mcp add command (answers shown for each prompt):
HTTP (Docker / remote)
opencode mcp add
Name → postgresql
Type → remote
URL → http://localhost:3000/mcp
Header Authorization → Bearer <AUTH_TOKEN>
stdio (local Node.js)
opencode mcp add
Name → postgresql
Type → local
Command → node /path/to/src/index.js
Env PG_HOST → localhost
Env PG_DATABASE → mydb
Env PG_USER → user
Env PG_PASSWORD → password
The server is saved to opencode.json in the project root (or ~/.config/opencode/opencode.json for global config). Use opencode mcp list to verify, and opencode mcp remove <name> to delete.
Authentication
AUTH_TOKEN (env var) is the admin token — it grants access to /mcp and the token management API.
Additional file tokens can be created via the admin UI or API; they only have access to /mcp.
Token values are stored as SHA-256 hashes in a local JSON file (TOKENS_FILE); plaintext is shown only once at creation and never stored.
Each file token can optionally have its own PostgreSQL connection. When a token has no custom connection, it uses the server's default connection (PG_HOST / PG_DATABASE / … env vars).
No AUTH_TOKEN set → auth is completely disabled (local/dev only). The admin UI still works but does not require a token.
Client-supplied connections
By default a client always uses the connection of its token (or the server default). The admin can allow the
clients of a token to send their own connection parameters — e.g. so that every user logs in with a personal
database account instead of a shared technical user. The permission is the token field client_connection
(admin UI: Client-supplied connection, admincli.sh add-token … --client-connection / set-client-conn):
client_connection |
Client may send | Typical use |
|---|---|---|
none (default) |
nothing — X-Pg-* headers are rejected with HTTP 403 |
fixed connection |
credentials |
user + password; host/port/database stay the token's or server's |
personal DB accounts on one database |
full |
additionally host, port, database, ssl |
one token for many databases (the server can then reach any host the client names) |
For the AUTH_TOKEN admin token (and anonymous access when auth is disabled) PG_CLIENT_CONNECTION sets the
same permission (default none).
Call format
The parameters are sent as HTTP headers on the MCP initialize request — the request that opens the MCP
session (no mcp-session-id yet). They apply to the whole session; headers on later requests are ignored:
| Header | Field | Mode |
|---|---|---|
X-Pg-User, X-Pg-Password |
user, password — always both |
credentials, full |
X-Pg-Host |
host (the token's port and database are not inherited; missing ones come from PG_PORT / PG_DATABASE) |
full |
X-Pg-Port, X-Pg-Database |
port, database | full |
X-Pg-Ssl |
true (TLS without certificate check) or false |
full |
POST /mcp HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/json
Accept: application/json, text/event-stream
X-Pg-User: jdoe
X-Pg-Password: s3cret
X-Pg-Database: sales
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"my-client","version":"1.0"}}}
MCP clients send configured headers with every request, so a static header entry is all that is needed:
// .mcp.json (Claude Code) / any client with Streamable HTTP + headers
{
"mcpServers": {
"pg-sales": {
"type": "http",
"url": "https://mcp.example.com/mcp",
"headers": {
"Authorization": "Bearer <token>",
"X-Pg-User": "jdoe",
"X-Pg-Password": "${PGPASSWORD}"
}
}
}
}
# Claude Code CLI
claude mcp add --transport http pg-sales https://mcp.example.com/mcp \
--header "Authorization: Bearer <token>" --header "X-Pg-User: jdoe" --header "X-Pg-Password: s3cret"
# Claude Desktop and other stdio-only clients via mcp-remote
npx mcp-remote https://mcp.example.com/mcp --header "Authorization: Bearer <token>" \
--header "X-Pg-User: jdoe" --header "X-Pg-Password: s3cret"
Rules and safeguards:
- Client parameters override the token's connection:
user/passwordreplace the token's credentials; a clienthostreplaces the token's host, port and database. - Any client
host,port,databaseorsslrequires client credentials — the password of the token or server default is never sent to a host chosen by the client, and TLS cannot be switched off for it. - Tokens without an own connection keep the server's TLS settings (
PG_SSL, CA and client certificates) for client credentials; server-side certificate files cannot be set by clients. - Rejected requests get HTTP 403 (not permitted) or 400 (invalid, e.g.
userwithoutpassword) with a JSON-RPC error message, and are logged as[AUTH] result="denied" … reason="client connection: …". A wrong database password shows up as the error of the first tool call. - The permission is re-checked on every request: switching a token back to
noneends the use of client parameters in its open sessions immediately. - Sessions with client parameters get their own connection pool (
application_namestays<MCP_SERVER_NAME>:<token>), which is closed when the last such session ends. The session start log showsconnection="client" target="host:port/database" user="…"(never the password). - Passwords travel in HTTP headers — use HTTPS (
TLS_ENABLED=trueor a TLS-terminating proxy).
Admin UI
Open http://<HOST>:3000/admin in a browser. The web interface lets you manage tokens without using the command line or curl.
- Login: enter the server URL and the
AUTH_TOKENvalue. Leave the token field empty if auth is disabled. - Token list: see all tokens with status (active/inactive), connection info, and last-used timestamp.
- Create token: enter a name and optionally configure a custom PostgreSQL connection. The generated token value is shown once — copy it before closing.
- Edit token: rename a token, toggle its active state, or update its database connection.
- Delete token: permanently removes the token record and immediately revokes access.
The session is stored in sessionStorage (cleared when the browser tab is closed).
Token list — status, per-token connection (server default or own database) and last use at a glance:
| Login |
New token with its own DB connection |
Token value is shown only once |
Admin UI branding
The admin UI can be switched to a corporate design without rebuilding the image:
| Variable | Effect |
|---|---|
ADMIN_LOGO |
Replaces the database icon on the login card and in the header. A file is embedded as data URI (.svg, .png, .jpg, .gif, .webp); an http(s):// URL is used as is (the browser must be able to reach it). |
ADMIN_THEME_CSS |
Stylesheet injected after the built-in styles. Override the CSS variables below; any other CSS rule is allowed, too. |
MCP_SERVER_NAME |
Title next to the logo and in the browser tab. |
Both files are read once at startup; an unreadable file is logged as [WARN] [CONFIG] and the built-in design is
used. The design is served to everyone who opens /admin (also before sign-in), so do not put anything
confidential into it.
All colors of the UI are CSS variables in the :root block at the top of admin.html:
| Variable | Used for |
|---|---|
--primary, --primary-dk, --on-primary |
Buttons, links, toggles; hover color; text on primary |
--border-focus, --focus-ring |
Focused inputs |
--header-bg, --header-text, --header-muted, --header-border, --header-hover |
Top bar and its buttons |
--brand-from, --brand-to |
Gradient of the built-in icon (without ADMIN_LOGO) |
--bg, --surface, --surface-alt, --row-hover, --border, --border-light |
Page, cards, table header, rows |
--text, --text-muted, --text-faint |
Text colors |
--success*, --danger*, --toggle-off |
Status badges, alerts, delete dialog, switched-off toggles |
--font, --radius |
Font stack, corner radius of cards and dialogs |
--logo-height, --header-logo-height |
Logo height on the login card (40px) and in the header (28px) |
examples/admin-theme/ contains a complete example (fictional "ACME data" design: light
header with accent bar, teal primary color, own logo):
:root {
--primary: #00857c;
--primary-dk: #006b63;
--header-bg: #ffffff;
--header-text: #12333a;
--font: "Segoe UI", Arial, Helvetica, sans-serif;
}
header { border-bottom: 3px solid #f2a900; } /* additional rules are fine */
docker run -d -p 3000:3000 \
-v "$PWD/examples/admin-theme:/branding:ro" \
-e ADMIN_THEME_CSS=/branding/theme.css -e ADMIN_LOGO=/branding/logo.svg \
-e MCP_SERVER_NAME="ACME PostgreSQL MCP" … tommi2day/pg-mcp-server
With Helm put the files into a ConfigMap and set adminUi.brandingConfigMap, adminUi.themeCss and
adminUi.logo (see values.yaml):
kubectl -n mcp create configmap mcp-branding \
--from-file=examples/admin-theme/theme.css --from-file=examples/admin-theme/logo.svg
| Token list with the example theme |
Login |
Token store
Tokens are persisted in a JSON file (default ./tokens.json, configurable via TOKENS_FILE).
Mount a volume at the file's directory so tokens survive container restarts — see the Docker and Helm sections above.
Password encryption
Set STORE_ENCRYPTION_KEY to a passphrase to enable AES-256-GCM encryption of connection.password values at rest. Passwords are encrypted on save and decrypted transparently on load. On startup the server migrates any plaintext passwords it finds in the file — no manual step required.
export STORE_ENCRYPTION_KEY="my-secret-passphrase"
Without this variable the passwords are stored as plaintext (acceptable for local/dev use). Changing the key requires re-saving every token (plaintext migration runs automatically on startup only when the key is set).
Manage tokens with admincli.sh
admincli.sh reads AUTH_TOKEN and MCP_URL from environment variables or from a scripts/.env file:
# Option A – environment variables
export AUTH_TOKEN=<admin-token>
export MCP_URL=http://localhost:3000 # optional, default
# Option B – scripts/.env file
cat > scripts/.env <<EOF
AUTH_TOKEN=<admin-token>
MCP_URL=http://localhost:3000
EOF
When using
run.sh, the generated token is stored in./auth_token:export AUTH_TOKEN=$(cat auth_token)
# Server info
./scripts/admincli.sh health # server health status + TLS flag (no auth required)
# Token management
./scripts/admincli.sh list-tokens # list all tokens (CONN column shows per-token connection)
./scripts/admincli.sh show-token <id> # full details for one token, including connection config
./scripts/admincli.sh add-token "claude-desktop" # create new token (plaintext shown once)
./scripts/admincli.sh delete-token <id> # permanently delete token
./scripts/admincli.sh disable-token <id> # temporarily block
./scripts/admincli.sh enable-token <id> # re-enable
./scripts/admincli.sh rename-token <id> <new-name> # rename
# Per-token database connection — pass flags or env vars (flags take precedence)
./scripts/admincli.sh add-token "mydb-client" \
--host db.example.com --database mydb --user u --password p --ssl false
# env-var form still works:
PG_HOST=db.example.com PG_DATABASE=mydb PG_USER=u PG_PASSWORD=p \
./scripts/admincli.sh add-token "mydb-client"
./scripts/admincli.sh set-conn <id> '{"host":"db.example.com","port":5432,"database":"mydb","user":"u","password":"p"}'
./scripts/admincli.sh clear-conn <id> # reset to default admin connection
add-token connection flags: --host, --port, --database, --user, --password, --ssl.
Flags take precedence over the corresponding PG_* env vars. Omit all connection arguments to use the server's default admin connection.
Validate a token with test_token.sh
Connects to the server using the given token and lists tables — useful to confirm a newly created token works:
./scripts/test_token.sh <token> # schema: public (default)
./scripts/test_token.sh <token> myschema # specific schema
Reads MCP_URL from environment or scripts/.env. Exits with a clear error message on failure (invalid token, server unreachable, MCP tool error, etc.).
The script sends an X-Real-IP header so the server logs the real client IP. The value is taken from X_REAL_IP env var if set, otherwise auto-detected from the first local interface (hostname -I).
Manage tokens with curl
# List tokens (includes connection info; token_hash is never returned)
curl http://localhost:3000/admin/tokens \
-H "Authorization: Bearer $AUTH_TOKEN"
# Create token (default connection)
curl -X POST http://localhost:3000/admin/tokens \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "claude-desktop"}'
# Create token with a custom DB connection
curl -X POST http://localhost:3000/admin/tokens \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "mydb-client",
"connection": {
"host": "db.example.com",
"port": 5432,
"database": "mydb",
"user": "myuser",
"password": "secret",
"ssl": "false"
}
}'
# Set or update the connection on an existing token
curl -X PATCH http://localhost:3000/admin/tokens/<id> \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"connection": {"host": "db.example.com", "database": "mydb", "user": "u", "password": "p"}}'
# Clear per-token connection (fall back to default admin connection)
curl -X PATCH http://localhost:3000/admin/tokens/<id> \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"connection": null}'
# Rename / re-enable token
curl -X PATCH http://localhost:3000/admin/tokens/<id> \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "new-name", "active": true}'
# Delete token (permanently removed)
curl -X DELETE http://localhost:3000/admin/tokens/<id> \
-H "Authorization: Bearer $AUTH_TOKEN"
Logging
All log lines go to stderr and are visible in docker logs <name>. Each line carries an ISO 8601 timestamp, a level and a category:
[2026-03-28T19:32:50.100Z] [INFO] [SESSION] token="claude-desktop" action="start" session="3f2c…" ip="192.168.1.10"
[2026-03-28T19:32:51.654Z] [INFO] [MCP] token="claude-desktop" action="list_tables" ip="192.168.1.10" params={"schema":"public"}
[2026-03-28T19:32:51.700Z] [INFO] [MCP] token="claude-desktop" action="query" ip="192.168.1.10" params={"sql":"<42 chars, LOG_LEVEL=debug to show>"}
[2026-03-28T19:32:51.859Z] [ERROR] [MCP] token="claude-desktop" action="query" ip="192.168.1.10" error="column \"x\" does not exist"
[2026-03-28T19:32:51.900Z] [INFO] [SESSION] token="claude-desktop" action="stop" session="3f2c…" ip="192.168.1.10" duration=312s
[2026-03-28T19:32:51.950Z] [WARN] [AUTH] result="denied" action="POST /mcp" ip="10.1.2.3" reason="unknown token"
[2026-03-28T19:32:51.960Z] [WARN] [AUTH] result="denied" token="old-client" action="POST /mcp" ip="10.1.2.3" reason="token disabled"
[2026-03-28T19:32:51.970Z] [INFO] [ADMIN] token="admin" action="POST /admin/tokens" ip="192.168.1.10"
[2026-03-28T19:32:52.001Z] [ERROR] [ADMIN] token="admin" action="GET /admin/tokens" ip="192.168.1.10" error="ENOENT: ..."
[2026-03-28T19:32:52.100Z] [ERROR] [DB] Pool error: Connection terminated unexpectedly
[2026-03-28T19:32:52.200Z] [ERROR] [HTTP] Unhandled error for POST /mcp: socket hang up
[2026-03-28T19:33:00.000Z] [ERROR] [FATAL] Unhandled rejection: getaddrinfo ENOTFOUND db.example.com
LOG_LEVEL (debug / info / warn / error, default info) sets the minimum level that is written.
Real output of a short session at the default level info — two clients working in parallel, a write attempt rejected by the read-only DB user of reporting-team, and four rejected logins (disabled token, unknown token, wrong admin token, missing token). SQL text is replaced by its length:
With LOG_LEVEL=debug the full SQL text is logged:
| Category | Level | When |
|---|---|---|
[MCP] |
info / error | MCP tool call (success and error). Token name is "admin" for the env token, "anonymous" when auth is disabled, "stdio" in stdio mode, or the file token's name. params is omitted for tools with no arguments. The sql parameter is replaced by its length unless LOG_LEVEL=debug. |
[SESSION] |
info | HTTP MCP session start and stop (with duration). Stop is logged when the client ends the session (DELETE /mcp) or the transport closes; clients that just disappear produce no stop line. |
[AUTH] |
warn | Rejected request (401). reason is missing token, invalid admin token, unknown token or token disabled (with the token name). The presented token is never logged. |
[ADMIN] |
info / error | Admin API request. token="admin" when authenticated with AUTH_TOKEN, token="anonymous" when auth is disabled. Stack traces of admin errors at debug. |
[STORE] |
info / warn | Password migration and missing STORE_ENCRYPTION_KEY. |
[DB] |
error | Idle pool error (e.g. dropped connection) from the default or a per-token pool. |
[HTTP] |
error | Unhandled error in the HTTP request handler. |
[FATAL] |
error | Unhandled promise rejection or uncaught exception — the process exits after logging. |
Shipping logs to Elasticsearch: the exact line format, tested Filebeat configurations (Docker and Kubernetes) and a Logstash pipeline that turns each line into structured fields are in docs/logging.md.
The client IP is resolved in order: x-real-ip header → first entry of x-forwarded-for → TCP socket address. When running Docker without a reverse proxy, the socket address is the Docker bridge IP — deploy behind nginx or Traefik to log the real client IP.
Token file format
The token store is a plain JSON file. The server reads and writes it automatically — do not edit it while the server is running.
{
"tokens": [
{
"id": 1,
"name": "claude-desktop",
"token_hash": "<sha256-hex>",
"created_at": "2026-04-03T10:00:00.000Z",
"last_used_at": "2026-04-03T12:34:56.789Z",
"active": true,
"connection": null
},
{
"id": 2,
"name": "mydb-client",
"token_hash": "<sha256-hex>",
"created_at": "2026-04-03T10:05:00.000Z",
"last_used_at": null,
"active": true,
"connection": {
"host": "db.example.com",
"port": 5432,
"database": "mydb",
"user": "myuser",
"password": "enc:v1:<iv-hex>:<tag-hex>:<ciphertext-hex>",
"ssl": "false"
}
}
],
"next_id": 3
}
connection: null means the token uses the server's default PostgreSQL connection. An optional
"client_connection": "credentials" or "full" allows its clients to send their own connection parameters
(client-supplied connections). The token_hash field is a SHA-256 hex digest — the plaintext token is never stored. When STORE_ENCRYPTION_KEY is set, connection.password is stored as enc:v1:<iv>:<tag>:<ciphertext> (AES-256-GCM); otherwise it is stored in plaintext.
Automated Updates
Dependabot is configured to check for updates weekly for:
- npm dependencies (grouped into
productionanddev) - Docker base images
- GitHub Actions
The Dependabot Automerge workflow automatically enables auto-merge for Dependabot PRs.
Development
# Install dependencies
npm install
# Run tests
./scripts/test.sh # all tests
./scripts/test.sh tests/lib.test.js # single file
# Coverage report
./scripts/coverage.sh # report written to ./coverage/
./scripts/coverage.sh --open # open HTML report in browser
# Linting
./scripts/lint.sh # check all files
./scripts/lint.sh --fix # auto-fix issues
All scripts require only Docker — no local Node.js needed.
Available MCP Tools
| Tool | Description |
|---|---|
test_connection |
Check connection and TLS status |
list_schemas |
List all schemas |
list_tables |
List tables in a schema |
describe_table |
Show columns, types and constraints |
query |
Execute a read-only SQL query (max 200 rows, wrapped in BEGIN READ ONLY / COMMIT) |
execute |
Execute INSERT / UPDATE / DELETE / DDL (wrapped in BEGIN / COMMIT) |
explain_query |
Execution plan of one statement; options analyze, buffers, verbose, settings, generic_plan, format (text/json). Runs in BEGIN READ ONLY and is always rolled back |
top_queries |
Most expensive statements of the current database from pg_stat_statements (sort by total/mean time, calls, rows, blocks read, temp written; filter by text or role) |
table_stats |
Overview: sizes, seq vs. index scans, dead rows, last vacuum/analyze. With table: stale-statistics warnings, indexes with usage, per-column planner statistics |
index_health |
Unused, duplicate and invalid indexes |
active_queries |
Running and idle-in-transaction sessions (blocked first) with duration, wait event, blocking PIDs and query_id, plus a wait-event snapshot |
performance_overview |
Cache hit ratio, connections, temp files, deadlocks and key tuning settings |
The performance tools work best with GRANT pg_monitor TO <user> and the pg_stat_statements extension — details, workflows and troubleshooting in Performance analysis.
Endpoints
| Path | Auth | Description |
|---|---|---|
POST /mcp |
Admin or file token | MCP Streamable-HTTP (uses token's connection if set) |
GET /health |
none | Health check ({"status":"ok","tls":<bool>}) |
GET /admin |
none | Web-based token administration UI |
GET /admin/tokens |
Admin token only | List tokens with connection info (no hashes) |
POST /admin/tokens |
Admin token only | Create token; optional connection object |
PATCH /admin/tokens/:id |
Admin token only | Update name, active, and/or connection |
DELETE /admin/tokens/:id |
Admin token only | Permanently delete token |
A full OpenAPI 3.1 specification is available in openapi.json.
Release
The release workflow (.github/workflows/release.yml) runs lint, tests, builds and pushes the Docker image, and creates a GitHub Release with auto-generated notes.
Option 1 — Push a git tag
Use npm version to bump all version files together, then push the tag:
npm version 1.2.3 # bumps package.json, openapi.json and Chart.yaml, commits, creates git tag
git push origin main 1.2.3
The version lifecycle script keeps openapi.json and helm/pg-mcp-server/Chart.yaml in sync automatically. The tag must match [0-9]+.[0-9]+.[0-9]+ (e.g. 1.2.3, no v prefix).
Option 2 — Manual dispatch (no local git required)
Go to Actions → Release → Run workflow, enter a version number (e.g. 1.2.3), and click Run workflow.
The workflow will:
- Bump
package.json,openapi.jsonandhelm/pg-mcp-server/Chart.yamlto the entered version, commit and push tomain - Run lint and tests
- Build and push the Docker image (
tommi2day/pg-mcp-server:1.2.3,:1.2,:1,:latest,:sha-<short>) - Create and push the git tag automatically
- Publish a GitHub Release with auto-generated notes
Docker image tags per release
| Tag | Example |
|---|---|
| Full version | 1.2.3 |
| Major.minor | 1.2 |
| Major | 1 |
| Latest | latest |
| Commit SHA | sha-abc1234 |