diff --git a/README.md b/README.md index 11c590e..b9bb98f 100644 --- a/README.md +++ b/README.md @@ -1,207 +1,102 @@ # 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. +A monorepo of stdio MCP servers for databases, built on one shared connection +core. Each server exposes named connections, optional SSH tunnels, and runtime +connection management: you add, edit, and remove connections through MCP tools +without restarting the harness. Both ship as an npx package and a Docker image, +so they run anywhere your MCP client does. -## Quick start (npx) +## Packages -Add the server to your client's `.mcp.json`: +| Package | Backend | Install | +| --- | --- | --- | +| [dbmole-mcp](packages/dbmole-mcp/README.md) | PostgreSQL, MySQL | `npx -y dbmole-mcp` | +| [esmole-mcp](packages/esmole-mcp/README.md) | Elasticsearch 7.x and 8.x | `npx -y esmole-mcp` | + +Each package README documents its tools, connection fields, and Docker image in +full. This page covers what the two servers share. + +## Quick start + +Add either server — or both — to your client's `.mcp.json`: ```json { "mcpServers": { - "dbmole": { - "command": "npx", - "args": ["-y", "dbmole-mcp"] - } + "dbmole": { "command": "npx", "args": ["-y", "dbmole-mcp"] }, + "esmole": { "command": "npx", "args": ["-y", "esmole-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 +That is enough to start a server. With no connections configured it still runs; +register one at runtime with `add_connection`, or supply connections up front through one of the sources below. -## Connection sources +## Shared design -Connections are merged from three layers. Higher layers win on name collisions. +Both servers inherit the same connection model from `@dbmole/core`. + +**Connection sources.** Connections merge from three layers, highest priority +first. The prefix is `DBMOLE_` for dbmole and `ESMOLE_` for esmole. | 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) | +| High | `_CONNECTIONS` env | JSON array of connection objects | No (read-only) | +| Medium | Config file | `--config ` or `_CONFIG`, shape `{ "connections": [...] }` | No (read-only) | +| Low | Persistent store | `~/.config//connections.json` (mode `0600`); override with `_STORE` | Yes (via tools) | -Rules: +Reads resolve name collisions by layer priority, and `list_connections` reports +the source layer that won. The write tools — `add_connection`, +`update_connection`, `remove_connection` — touch only the store layer; editing a +connection from the env or a config file returns an error naming its source. -- Reads resolve name collisions by layer priority: env > config > store, so a - higher layer shadows a lower one with the same name. `list_connections` - reports the source layer that won. -- Writes never create a new shadow: `add_connection` refuses a name that already - exists in any layer (env, config, or store), even one it cannot edit. -- `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. +**SSH tunnels.** Any connection may carry an `ssh` block to reach a host through +a bastion. Authentication accepts a password, a private key (inline or by path, +with `~` expansion), or the SSH agent at `SSH_AUTH_SOCK`. -## Connection config reference +**Readonly mode.** A `readonly: true` connection blocks writes at the server, not +by trusting the caller. dbmole pins read-only transactions; esmole enforces an +HTTP method-and-path allowlist. Treat it as defence in depth — still connect with +a least-privilege user for a hard guarantee. -Each connection is an object with the following fields. Unknown fields are -rejected (the schemas are strict). +**Persistent store.** The store is a plaintext JSON file written with `0600` +permissions; whoever can read it owns the credentials in it. It is re-read on +every operation, so instances sharing one store file see each other's changes at +once. For secrets you do not want the tools to persist, prefer the env or config +layer. -| Field | Type | Notes | -| --- | --- | --- | -| `name` | string | matches `[a-zA-Z0-9_-]+`; `add_connection` refuses a name already present in any layer | -| `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) | +See each package README for the backend-specific tools, field reference, and +defaults (ports, schemes, and so on). -For PostgreSQL, a connection without a `database` falls back to the conventional -`postgres` maintenance database for `list_databases` and `test_connection`. On -servers that lack it, set `database` explicitly. +## Repository layout -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" - } -} +``` +packages/ + core/ @dbmole/core — shared Manager, registry, SSH tunnels, MCP plumbing (private) + dbmole-mcp/ PostgreSQL and MySQL server + esmole-mcp/ Elasticsearch server ``` -## 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. - -The PostgreSQL mechanism is a libpq startup option, which PgBouncer in -transaction or statement pooling mode does **not** forward to the server. Behind -such a pooler the readonly flag is silently ineffective; use a read-only -database user there instead. - -## 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:/home/node/.config/dbmole", - "dbmole-mcp" - ] - } - } -} -``` - -The image runs as the non-root `node` user, so the store lives at -`/home/node/.config/dbmole` — mount the volume there (as above). - -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. On Docker Desktop that name resolves -automatically; on plain Linux Docker Engine you must add -`--add-host=host.docker.internal:host-gateway` to the `docker run` args. +`@dbmole/core` is private and never published. Each leaf package bundles it at +build time, so the published artifacts have no `@dbmole/core` dependency. ## Development +The repo is an npm workspace; run these from the root to cover every package: + ```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 typecheck # tsc across all packages npm run lint # Biome check -npm run build # bundle to dist/ +npm run build # bundle each package to dist/ ``` See [AGENTS.md](AGENTS.md) for the contributor style contract: code style, TypeScript discipline, testing rules, and commit conventions. + +## License + +[MIT](LICENSE) diff --git a/packages/dbmole-mcp/README.md b/packages/dbmole-mcp/README.md new file mode 100644 index 0000000..b295355 --- /dev/null +++ b/packages/dbmole-mcp/README.md @@ -0,0 +1,210 @@ +# 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. It shares its connection core with +[esmole-mcp](../esmole-mcp/README.md), so connection sources, the persistent +store, and SSH tunnelling behave identically across both servers. + +## 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: + +- Reads resolve name collisions by layer priority: env > config > store, so a + higher layer shadows a lower one with the same name. `list_connections` + reports the source layer that won. +- Writes never create a new shadow: `add_connection` refuses a name that already + exists in any layer (env, config, or store), even one it cannot edit. +- `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_-]+`; `add_connection` refuses a name already present in any layer | +| `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) | + +For PostgreSQL, a connection without a `database` falls back to the conventional +`postgres` maintenance database for `list_databases` and `test_connection`. On +servers that lack it, set `database` explicitly. + +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. + +The PostgreSQL mechanism is a libpq startup option, which PgBouncer in +transaction or statement pooling mode does **not** forward to the server. Behind +such a pooler the readonly flag is silently ineffective; use a read-only +database user there instead. + +## 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 + +The image is built from the repository root (it needs the workspace so +`@dbmole/core` resolves): + +```bash +docker build -f packages/dbmole-mcp/Dockerfile -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:/home/node/.config/dbmole", + "dbmole-mcp" + ] + } + } +} +``` + +The image runs as the non-root `node` user, so the store lives at +`/home/node/.config/dbmole` — mount the volume there (as above). + +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. On Docker Desktop that name resolves +automatically; on plain Linux Docker Engine you must add +`--add-host=host.docker.internal:host-gateway` to the `docker run` args. + +## 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.