diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 300c73c..2d81868 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -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 `` 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 | | diff --git a/MCP-STACK.md b/MCP-STACK.md index e8c98a5..2262a0a 100644 --- a/MCP-STACK.md +++ b/MCP-STACK.md @@ -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, ``, 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, ``, 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. diff --git a/mcp-stack.Caddyfile b/mcp-stack.Caddyfile index 7a22758..f286c9f 100644 --- a/mcp-stack.Caddyfile +++ b/mcp-stack.Caddyfile @@ -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 //... 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 + } +} diff --git a/mcp-stack.compose.yml b/mcp-stack.compose.yml index fc2f0f6..de3ddc0 100644 --- a/mcp-stack.compose.yml +++ b/mcp-stack.compose.yml @@ -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 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: diff --git a/mcp-stack.env.example b/mcp-stack.env.example index bf3e7d7..2e31abb 100644 --- a/mcp-stack.env.example +++ b/mcp-stack.env.example @@ -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 +# 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 diff --git a/mcp-stack.vm.Caddyfile b/mcp-stack.vm.Caddyfile index fab4005..0e5ad82 100644 --- a/mcp-stack.vm.Caddyfile +++ b/mcp-stack.vm.Caddyfile @@ -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 //... and the # application behind it sees the path it would see at the root.