Deploy postgres-mcp beside the other servers

A postgres-mcp service on its own internal control network and an egress one, a /postgres/* route in both
Caddyfiles (and :3105 in the loopback one), and the variables, documented in ENVIRONMENT.md and the
example environment. Its external mode is off until POSTGRES_MCP_ALLOWED_HOSTS lists the hosts it may use.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-10-02 20:04:52 +02:00
parent 4f0d78b81d
commit ea6e6ac443
6 changed files with 122 additions and 4 deletions

View File

@ -42,7 +42,7 @@ Generate a token with `openssl rand -hex 32`.
| Variable | Default | Meaning |
|---|---|---|
| `MCP_IMAGE_TAG` | the hash named in `mcp-stack.compose.yml` | Which build of the four `luciolelii/*` images to run: a commit hash of the `mcps` repository, or `latest`. Bumping it is a deploy. |
| `MCP_IMAGE_TAG` | the hash named in `mcp-stack.compose.yml` | Which build of the five `luciolelii/*` images to run: a commit hash of the `mcps` repository, or `latest`. Bumping it is a deploy. |
**MinIO MCP**
@ -59,6 +59,28 @@ The internal MinIO's endpoint and keys are **not** variables here: they are head
`minio-mcp-internal` entry in the workflow manager's catalog (`x-minio-endpoint`,
`x-minio-access-key`, `x-minio-secret-key`, optionally `x-minio-region`).
**PostgreSQL MCP**
| Variable | Default | Meaning |
|---|---|---|
| `POSTGRES_MCP_API_KEYS` | none, **required** | Bearer tokens, as for the other servers; at least 32 characters each. |
| `POSTGRES_MCP_INTERNAL_DATABASE_PREFIX` | `exec_` | Every internal-mode database, and its user, is `<prefix><execution id>` with the id's dashes as underscores. Cannot be empty; at most 26 lowercase letters, digits or `_`, starting with a letter. It is the boundary that keeps the API key inside databases this server made. |
| `POSTGRES_MCP_ALLOWED_HOSTS` | empty | Comma-separated host names this server may connect to. **The external mode is off while it is empty**: the model could otherwise aim the server at any address it reaches. For the internal mode it is optional; when set, the platform's PostgreSQL host has to be on it. |
| `POSTGRES_MCP_MAX_ROWS` | `1000` | Most rows one query returns, whatever the model asks. Ceiling 10000. |
| `POSTGRES_MCP_MAX_RESULT_BYTES` | `1048576` (1 MiB) | Most bytes of rows one statement returns; the rest is cut and the answer says so. Ceiling 16 MiB. |
| `POSTGRES_MCP_STATEMENT_TIMEOUT_MS` | `30000` | A statement running longer is stopped by the server. Ceiling 10 minutes. |
| `POSTGRES_MCP_MAX_CONNECTIONS_PER_SESSION` | `4` | External mode only. Ceiling 32. |
The internal PostgreSQL's host and credentials are **not** variables here: they are headers of the
`postgres-mcp-internal` entry in the workflow manager's catalog (`x-postgres-host`, `x-postgres-port`,
`x-postgres-database`, `x-postgres-user`, `x-postgres-password`, `x-postgres-sslmode`, and
`x-postgres-role-secret`). The user has to be able to make databases and users - `CREATEDB` and
`CREATEROLE`, and nothing more - and the secret, at least 32 characters, is what the password of each
execution's own user is derived from. **It has to be the same as the `executionRoleSecret` of the
workflow manager's own `storages.json` entry for that PostgreSQL**, and so does the prefix, or the
storage nodes and the models would not see the same database. The databases are dropped when they are
older than the entry's `executionDatabaseExpireDays` by the workflow manager, not by this server.
**Dev server**
| Variable | Default | Meaning |
@ -197,3 +219,20 @@ cannot be set through `.env`. Defaults shown are the ones in the code.
| `MINIO_MCP_REQUEST_MAX_BYTES` | derived from `MAX_OBJECT_BYTES` | HTTP body limit; base64 adds a third. |
| `MINIO_MCP_HOST` / `_PORT` | `0.0.0.0` / `3000` | |
| `MINIO_MCP_CORS_ORIGINS` | empty | |
### PostgreSQL MCP
| Variable | Default | |
|---|---|---|
| `POSTGRES_MCP_API_KEYS` | none, **required** | |
| `POSTGRES_MCP_INTERNAL_DATABASE_PREFIX` | `exec_` | See above. |
| `POSTGRES_MCP_ALLOWED_HOSTS` | empty | See above: empty turns the external mode off. |
| `POSTGRES_MCP_MAX_ROWS` | `1000` | Ceiling 10000. |
| `POSTGRES_MCP_MAX_RESULT_BYTES` | `1048576` | Ceiling 16 MiB. |
| `POSTGRES_MCP_STATEMENT_TIMEOUT_MS` | `30000` | |
| `POSTGRES_MCP_MAX_CONNECTIONS_PER_SESSION` | `4` | Ceiling 32. |
| `POSTGRES_MCP_MAX_SESSIONS` | `16` | |
| `POSTGRES_MCP_SESSION_TTL_SECONDS` | `1800` | |
| `POSTGRES_MCP_REQUEST_MAX_BYTES` | `1048576` | HTTP body limit. |
| `POSTGRES_MCP_HOST` / `_PORT` | `0.0.0.0` / `3000` | |
| `POSTGRES_MCP_CORS_ORIGINS` | empty | |

View File

@ -11,6 +11,7 @@ them, so a server running it needs neither the sources nor a build toolchain.
| `luciolelii/dev-server-mcp` | Authenticated facade for a private, allowlisted development-server worker. One instance per execution, each in a throwaway copy of that execution's tree and on its own port. The same image runs the facade and the worker. |
| `luciolelii/browser-mcp` | Playwright automation restricted to exact allowed origins. |
| `luciolelii/minio-mcp` | Bounded list/read/stat/write access to object storage, with no delete tool. In internal mode (catalog entry `minio-mcp-internal`, header `x-minio-scope`) it uses the platform's MinIO whose endpoint and keys the catalog entry sends as headers, and one bucket per execution, `<MINIO_MCP_INTERNAL_BUCKET_PREFIX><execution id>`, created when the session opens; in external mode the model opens session-scoped connections to MinIO deployments itself. |
| `luciolelii/postgres-mcp` | SQL access to PostgreSQL: `postgres_query` (read-only, bounded rows), `postgres_execute`, `postgres_list_tables` and `postgres_describe_table`. In internal mode (catalog entry `postgres-mcp-internal`, header `x-postgres-scope`) each execution gets a database and a user of its own, `<POSTGRES_MCP_INTERNAL_DATABASE_PREFIX><execution id>`, created when the session opens, which nobody else can connect to; the model may do anything inside it and nothing outside. In external mode the model opens a connection to a host the server allows, and the mode is off until `POSTGRES_MCP_ALLOWED_HOSTS` names one. |
`egress-proxy` is the development worker's only route to package registries: it allows their CONNECT
requests and refuses everything else.
@ -72,11 +73,15 @@ bearer_token_env_var = "BROWSER_MCP_TOKEN"
[mcp_servers.minio]
url = "http://127.0.0.1:3104/mcp"
bearer_token_env_var = "MINIO_MCP_TOKEN"
[mcp_servers.postgres]
url = "http://127.0.0.1:3105/mcp"
bearer_token_env_var = "POSTGRES_MCP_TOKEN"
```
Set those four variables in the MCP client's environment to the token portions configured for the corresponding servers. Do not reuse a token between servers.
Set those variables in the MCP client's environment to the token portions configured for the corresponding servers. Do not reuse a token between servers.
Only the minimal Caddy gateway publishes loopback ports. The coding, development and browser services stay exclusively on internal networks. `minio-mcp` has outbound connectivity because each MCP session can open connections to remote S3 endpoints. Set `MINIO_MCP_ALLOWED_ENDPOINTS` whenever those endpoints are known; leaving it empty gives authenticated MCP clients an intentional network-request capability. For remote use, use the included VM overlay so Caddy terminates TLS, and add rate limiting at the network edge when appropriate.
Only the minimal Caddy gateway publishes loopback ports. The coding, development and browser services stay exclusively on internal networks. `minio-mcp` has outbound connectivity because each MCP session can open connections to remote S3 endpoints. Set `MINIO_MCP_ALLOWED_ENDPOINTS` whenever those endpoints are known; leaving it empty gives authenticated MCP clients an intentional network-request capability. `postgres-mcp` is the same, and stricter by default: it has outbound connectivity because the internal mode reaches the platform's PostgreSQL and the external mode a host the model names, but the external mode is off until `POSTGRES_MCP_ALLOWED_HOSTS` lists the hosts it may use. For remote use, use the included VM overlay so Caddy terminates TLS, and add rate limiting at the network edge when appropriate.
The worker and browser networks are marked internal, no service uses host networking or the Docker socket, and the development-server workspace mount is read-only. Container isolation reduces the blast radius but is not a substitute for an ephemeral VM/microVM when executing hostile multi-tenant code.

View File

@ -31,6 +31,12 @@
}
}
handle_path /postgres/* {
reverse_proxy postgres-mcp:3000 {
flush_interval -1
}
}
# The one door a person opens, as opposed to the MCP routes above, which a machine opens with an API
# key. handle_path strips the prefix, so the worker's proxy sees /<execution-key>/... and the
# application behind it sees the path it would see at the root.
@ -72,3 +78,9 @@
flush_interval -1
}
}
:3105 {
reverse_proxy postgres-mcp:3000 {
flush_interval -1
}
}

View File

@ -222,6 +222,40 @@ services:
restart: unless-stopped
logging: *default-logging
postgres-mcp:
image: luciolelii/postgres-mcp:${MCP_IMAGE_TAG:-921d54c}
environment:
POSTGRES_MCP_API_KEYS: ${POSTGRES_MCP_API_KEYS}
POSTGRES_MCP_MAX_ROWS: ${POSTGRES_MCP_MAX_ROWS:-1000}
POSTGRES_MCP_MAX_RESULT_BYTES: ${POSTGRES_MCP_MAX_RESULT_BYTES:-1048576}
POSTGRES_MCP_STATEMENT_TIMEOUT_MS: ${POSTGRES_MCP_STATEMENT_TIMEOUT_MS:-30000}
POSTGRES_MCP_MAX_CONNECTIONS_PER_SESSION: ${POSTGRES_MCP_MAX_CONNECTIONS_PER_SESSION:-4}
# Comma-separated host names the server may connect to. For the external mode it is the whole of its
# permission: empty, and the model cannot open a connection of its own. For the internal mode it is
# optional, and when set the platform's PostgreSQL has to be on it.
POSTGRES_MCP_ALLOWED_HOSTS: ${POSTGRES_MCP_ALLOWED_HOSTS:-}
# Internal mode: the workflow manager's catalog entry postgres-mcp-internal sends the platform
# PostgreSQL's host and the credentials of the user that makes databases (CREATEDB, CREATEROLE) with
# x-postgres-scope, and the database and user <prefix><execution id> are created on first use. Only the
# prefix is this server's. The host is reached over postgres-egress.
POSTGRES_MCP_INTERNAL_DATABASE_PREFIX: ${POSTGRES_MCP_INTERNAL_DATABASE_PREFIX:-exec_}
user: "${MCP_UID:-10001}:${MCP_GID:-10001}"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
pids_limit: 64
mem_limit: 256m
cpus: 0.5
init: true
expose: ["3000"]
# postgres-control is the private path from Caddy. postgres-egress is needed because each logical
# session may point at a different PostgreSQL host, and the internal one is reached the same way.
networks: [postgres-control, postgres-egress]
restart: unless-stopped
logging: *default-logging
mcp-gateway:
image: caddy:2
user: "${MCP_UID:-10001}:${MCP_GID:-10001}"
@ -265,7 +299,8 @@ services:
- "127.0.0.1:${DEV_SERVER_MCP_PORT:-3102}:3102"
- "127.0.0.1:${BROWSER_MCP_PORT:-3103}:3103"
- "127.0.0.1:${MINIO_MCP_PORT:-3104}:3104"
networks: [gateway, coding-control, dev-server-control, workspace-browser, minio-control]
- "127.0.0.1:${POSTGRES_MCP_PORT:-3105}:3105"
networks: [gateway, coding-control, dev-server-control, workspace-browser, minio-control, postgres-control]
depends_on:
caddy-data-init:
condition: service_completed_successfully
@ -277,6 +312,8 @@ services:
condition: service_started
minio-mcp:
condition: service_started
postgres-mcp:
condition: service_started
restart: unless-stopped
logging: *default-logging
@ -312,6 +349,9 @@ networks:
minio-control:
internal: true
minio-egress:
postgres-control:
internal: true
postgres-egress:
# The worker and the proxy meet here, and nothing else does. Internal, so joining it grants no
# route out: the proxy's own egress comes from the separate network below, which only it joins.
proxy-control:

View File

@ -7,6 +7,7 @@ CODING_AGENT_MCP_API_KEYS=agent=replace-with-at-least-32-random-characters
DEV_SERVER_MCP_API_KEYS=agent=replace-with-a-different-32-char-token
BROWSER_MCP_API_KEYS=agent=replace-with-a-third-32-char-token
MINIO_MCP_API_KEYS=agent=replace-with-a-fourth-32-char-token
POSTGRES_MCP_API_KEYS=agent=replace-with-a-fifth-32-char-token
DEV_SERVER_WORKER_TOKEN=replace-with-a-private-worker-token-32-chars
# External mode: URL, bucket and credentials are passed once to minio_open_session, not configured
@ -30,6 +31,21 @@ MINIO_MCP_INTERNAL_BUCKET_PREFIX=exec-
# bucket is created). 0 keeps them for ever. The empty bucket itself stays.
MINIO_MCP_INTERNAL_BUCKET_EXPIRE_DAYS=7
# PostgreSQL MCP. External mode: host, database and credentials are passed once to postgres_open_session.
# It is OFF while this list is empty - the model could otherwise aim the server at any address it can reach -
# so name the hosts it may use, comma-separated. For the internal mode the list is optional.
POSTGRES_MCP_ALLOWED_HOSTS=
POSTGRES_MCP_MAX_ROWS=1000
POSTGRES_MCP_MAX_RESULT_BYTES=1048576
POSTGRES_MCP_STATEMENT_TIMEOUT_MS=30000
#
# The internal mode is chosen by the catalog entry postgres-mcp-internal, and its host and credentials are
# that entry's headers in the workflow manager's catalog - not variables here. The session arrives with
# x-postgres-scope set to the execution's id, and this server makes the database and the user
# <prefix><execution id> on first use. The catalog's user needs CREATEDB and CREATEROLE, and the secret in
# its headers has to be the same as the executionRoleSecret of the manager's own storages.json entry.
POSTGRES_MCP_INTERNAL_DATABASE_PREFIX=exec_
# Use a dedicated project directory, never a home directory or filesystem root.
MCP_WORKSPACE_HOST_PATH=/absolute/path/to/project
# Whoever owns the workspace directory on the host. This file is read literally - no shell runs

View File

@ -46,6 +46,12 @@
}
}
handle_path /postgres/* {
reverse_proxy postgres-mcp:3000 {
flush_interval -1
}
}
# The one door a person opens, as opposed to the MCP routes above, which a machine opens with an API
# key. handle_path strips the prefix, so the worker's proxy sees /<execution-key>/... and the
# application behind it sees the path it would see at the root.