diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d229262 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +coverage +.git +docs +test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..174abec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY tsconfig.json tsup.config.ts ./ +COPY src ./src +RUN npm run build && npm prune --omit=dev + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +ENTRYPOINT ["node", "dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9b36d8 --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# dbmole-mcp + +dbmole-mcp is a stdio MCP server for PostgreSQL and MySQL. It exposes named +connections, optional SSH tunnels, and runtime connection management: you add, +edit, and remove connections through MCP tools without restarting the harness. +It ships as both an npx package and a Docker image, so it runs anywhere your MCP +client does. + +## Quick start (npx) + +Add the server to your client's `.mcp.json`: + +```json +{ + "mcpServers": { + "dbmole": { + "command": "npx", + "args": ["-y", "dbmole-mcp"] + } + } +} +``` + +That is enough to start the server. With no connections configured it still runs; +use `add_connection` to register a database, or supply connections up front +through one of the sources below. + +## Connection sources + +Connections are merged from three layers. Higher layers win on name collisions. + +| Priority | Source | How to set | Mutable at runtime | +| --- | --- | --- | --- | +| High | `DBMOLE_CONNECTIONS` env | JSON array of connection objects | No (read-only) | +| Medium | Config file | `--config ` or `DBMOLE_CONFIG`, shape `{ "connections": [...] }` | No (read-only) | +| Low | Persistent store | `~/.config/dbmole/connections.json` (mode `0600`); override with `DBMOLE_STORE` | Yes (via tools) | + +Rules: + +- When the same name appears in more than one layer, the higher layer wins. +- `add_connection`, `update_connection`, and `remove_connection` only touch the + store layer. Editing a connection that came from the env or a config file + returns an error naming its source. +- The store is re-read on every operation, so several dbmole instances sharing + the same store file see each other's changes immediately. + +## Connection config reference + +Each connection is an object with the following fields. Unknown fields are +rejected (the schemas are strict). + +| Field | Type | Notes | +| --- | --- | --- | +| `name` | string | matches `[a-zA-Z0-9_-]+`; unique across all layers | +| `type` | `postgres` \| `mysql` | the database engine | +| `host` | string | database host | +| `port` | number | defaults to `5432` (postgres) or `3306` (mysql) | +| `user` | string | database user | +| `password` | string | database password | +| `database` | string | default database for the schema and query tools | +| `readonly` | boolean | defaults to `false`; see Readonly mode below | +| `ssh` | object | optional SSH tunnel (see below) | + +The `ssh` object accepts: + +| Field | Type | Notes | +| --- | --- | --- | +| `host` | string | bastion host | +| `port` | number | defaults to `22` | +| `user` | string | SSH user | +| `password` | string | password auth | +| `privateKey` | string | key contents | +| `privateKeyPath` | string | path to a key file; a leading `~` is expanded | +| `agent` | boolean | use the SSH agent at `SSH_AUTH_SOCK` | +| `passphrase` | string | passphrase for an encrypted key | + +A full example — a PostgreSQL database reachable only through a bastion, opened +read-only: + +```json +{ + "name": "prod-replica", + "type": "postgres", + "host": "10.0.0.12", + "port": 5432, + "user": "readonly", + "password": "s3cret", + "database": "app", + "readonly": true, + "ssh": { + "host": "bastion.example.com", + "port": 22, + "user": "deploy", + "privateKeyPath": "~/.ssh/id_ed25519" + } +} +``` + +## Tools + +| Tool | What it does | +| --- | --- | +| `list_connections` | list configured connections and their source layer | +| `add_connection` | register a new connection in the store | +| `update_connection` | patch a stored connection; a `null` value removes that field | +| `remove_connection` | delete a stored connection | +| `test_connection` | open a connection and report success or the failure reason | +| `execute_sql` | run a single statement with positional params (`$1..` for postgres, `?` for mysql); `rowLimit` defaults to `100`, max `1000` | +| `list_databases` | list databases on the server | +| `list_tables` | list tables in a database | +| `describe_table` | describe a table's columns | + +## SQL guardrails + +`execute_sql` runs exactly one statement per call. Multi-statement input is +rejected before anything is sent to the database. Session-level statements +(`BEGIN`, `SET`, `USE`, and similar) are also rejected, because every call runs +on a connection drawn from a pool and must not leave session state behind. + +## Readonly mode + +Readonly is server-enforced, not advisory: + +- PostgreSQL sessions start with `default_transaction_read_only=on`, set through + the libpq startup options. +- MySQL sessions run `SET SESSION TRANSACTION READ ONLY` on first checkout. + +Both block DML and DDL; this is verified against `postgres:17` and `mysql:8.4` +in the integration suite. Trying to flip the flag back with a `SET` is also +blocked by the SQL guard. For a hard guarantee in untrusted contexts, still +connect with a read-only database user — readonly mode is defence in depth, not +a substitute for database permissions. + +## Security notes + +- The store is a plaintext JSON file written with `0600` permissions. The trust + model is the same as `~/.pgpass`: whoever can read the file owns the + credentials in it. +- For secrets you do not want written to disk by the tools, prefer + `DBMOLE_CONNECTIONS` or `--config`. Those layers are read-only at runtime, so + the tools never persist them. +- Database error messages — codes, hints, and object names — are returned to the + MCP client by design. The caller is the database client and is meant to see + them. + +## Docker + +Build the image: + +```bash +docker build -t dbmole-mcp . +``` + +Run it from your client, persisting the store in a named volume: + +```json +{ + "mcpServers": { + "dbmole": { + "command": "docker", + "args": ["run", "-i", "--rm", "-v", "dbmole-store:/root/.config/dbmole", "dbmole-mcp"] + } + } +} +``` + +Note: when an SSH tunnel targets a bastion that is only reachable from the host, +the usual Docker networking caveats apply. Use `host.docker.internal` (or host +networking) so the container can reach it. + +## Development + +```bash +npm run test:unit # fast unit tests (mocked IO) +npm run test:int # integration tests (needs Docker) +npm run coverage # unit tests with coverage +npm run lint # Biome check +npm run build # bundle to dist/ +``` + +See [AGENTS.md](AGENTS.md) for the contributor style contract: code style, +TypeScript discipline, testing rules, and commit conventions.