diff --git a/packages/dbmole-mcp/Dockerfile b/packages/dbmole-mcp/Dockerfile index a9e9d94..8a83253 100644 --- a/packages/dbmole-mcp/Dockerfile +++ b/packages/dbmole-mcp/Dockerfile @@ -1,16 +1,26 @@ +# build stage — install the whole workspace so @dbmole/core resolves, build dbmole FROM node:22-alpine AS build -WORKDIR /app -COPY package.json package-lock.json ./ +WORKDIR /repo +COPY package.json package-lock.json tsconfig.base.json ./ +COPY packages/core/package.json packages/core/ +COPY packages/dbmole-mcp/package.json packages/dbmole-mcp/ +COPY packages/esmole-mcp/package.json packages/esmole-mcp/ RUN npm ci -COPY tsconfig.json tsup.config.ts ./ -COPY src ./src -RUN npm run build && npm prune --omit=dev +COPY packages/core packages/core +COPY packages/dbmole-mcp packages/dbmole-mcp +# tsup bundles @dbmole/core into dist (noExternal); the remaining runtime deps +# (pg, mysql2, ssh2, MCP SDK, zod) stay external and are served from node_modules. +RUN npm run build --workspace dbmole-mcp && npm prune --omit=dev -FROM node:22-alpine +# runtime stage — dist (core bundled in) + pruned production node_modules +FROM node:22-alpine AS runtime WORKDIR /app ENV NODE_ENV=production -COPY --from=build /app/node_modules ./node_modules -COPY --from=build /app/dist ./dist +# Every dbmole runtime external (pg, mysql2, ssh2, MCP SDK, zod) hoists to the +# root node_modules after the dev prune, so the root copy is sufficient — no +# per-package node_modules is emitted (unlike esmole, which nests undici). +COPY --from=build /repo/node_modules ./node_modules +COPY --from=build /repo/packages/dbmole-mcp/dist ./dist RUN mkdir -p /home/node/.config/dbmole && chown -R node:node /home/node/.config USER node ENTRYPOINT ["node", "dist/index.js"] diff --git a/packages/dbmole-mcp/tsup.config.ts b/packages/dbmole-mcp/tsup.config.ts index 66e79e3..35583c7 100644 --- a/packages/dbmole-mcp/tsup.config.ts +++ b/packages/dbmole-mcp/tsup.config.ts @@ -6,6 +6,11 @@ export default defineConfig({ target: 'node20', clean: true, noExternal: [/@dbmole\/core/], + // ssh2 (pulled in transitively by the bundled @dbmole/core) ships a native + // crypto addon with a pure-JS fallback. Keep it external so esbuild never + // tries to inline its optional .node binary, which need not exist on the + // target. ssh2 resolves from node_modules at runtime and falls back to JS. + external: ['ssh2'], banner: { js: '#!/usr/bin/env node' } diff --git a/packages/esmole-mcp/README.md b/packages/esmole-mcp/README.md new file mode 100644 index 0000000..1fe88e3 --- /dev/null +++ b/packages/esmole-mcp/README.md @@ -0,0 +1,185 @@ +# esmole-mcp + +esmole-mcp is a stdio MCP server for Elasticsearch (7.x and 8.x). 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 is built on the same core as +[dbmole-mcp](../dbmole-mcp/README.md), so connection sources, the persistent +store, and SSH tunnelling behave identically. 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`: + +```jsonc +{ + "mcpServers": { + "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 cluster, 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 | `ESMOLE_CONNECTIONS` env | JSON array of connection objects | No (read-only) | +| Medium | Config file | `--config ` or `ESMOLE_CONFIG`, shape `{ "connections": [...] }` | No (read-only) | +| Low | Persistent store | `~/.config/esmole/connections.json` (mode `0600`); override with `ESMOLE_STORE` | Yes (via tools) | + +Reads resolve name collisions by layer priority (env > config > store), and +`list_connections` reports the source layer that won. `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. + +## 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` | `elasticsearch` | the cluster type | +| `host` | string | cluster host | +| `port` | number | defaults to `9200` | +| `scheme` | `http` \| `https` | defaults to `https` | +| `verifyTls` | boolean | defaults to `true`; set `false` to skip TLS certificate verification | +| `user` | string | basic-auth user (pair with `password`) | +| `password` | string | basic-auth password | +| `apiKey` | string | API key, as an alternative to `user`+`password` | +| `readonly` | boolean | defaults to `false`; see Readonly mode below | +| `ssh` | object | optional SSH tunnel (see below) | + +Authentication is required: provide either an `apiKey` **or** both `user` and +`password`. 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 — an HTTPS cluster reachable only through a bastion, opened +read-only with an API key: + +```json +{ + "name": "prod-es", + "type": "elasticsearch", + "host": "10.0.0.20", + "port": 9200, + "scheme": "https", + "verifyTls": true, + "apiKey": "VnVhQ2ZHY0JDZGJrUW0t...", + "readonly": true, + "ssh": { + "host": "bastion.example.com", + "port": 22, + "user": "deploy", + "privateKeyPath": "~/.ssh/id_ed25519" + } +} +``` + +## Tools + +Every ES tool returns `{ status, body }` — the HTTP status code and the parsed +response body. A 4xx or 5xx from the cluster never throws: the error body is +returned so the caller can inspect it. + +| 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 | +| `es_request` | generic passthrough: any method/path/body, readonly-guarded, response truncated to a size budget | +| `es_search` | run a `query`/`aggs` against an index; `_source` projection; pass aggs only with `size: 0` for aggregation-only requests | +| `es_list_indices` | list indices on the cluster | +| `es_get_mapping` | mapping for an index, flattened to `field: type`; `raw: true` returns the unflattened mapping | +| `es_cluster_health` | cluster health | + +## Readonly mode + +The `readonly` flag blocks all writes. It is enforced by an allowlist, not by +trusting the caller: + +- `GET` and `HEAD` are always allowed. +- `POST` is allowed only to read-intent endpoints — `_search`, `_msearch`, + `_count`, `_field_caps`, `_mapping`, `_search/scroll`, `_pit`, and `_cat`. +- `DELETE` is allowed only for read-session teardown (`_pit`, `_search/scroll`). +- Everything else (index, update, bulk, delete-by-query, settings changes, …) is + rejected before any request reaches the cluster. + +For a hard guarantee in untrusted contexts, still connect with a least-privilege +ES role — readonly mode is defence in depth, not a substitute for cluster +permissions. + +## MCP client config + +Run dbmole and esmole side by side: + +```jsonc +"mcpServers": { + "dbmole": { "command": "npx", "args": ["-y", "dbmole-mcp"] }, + "esmole": { "command": "npx", "args": ["-y", "esmole-mcp"] } +} +``` + +## Docker + +The image is built from the repository root (it needs the workspace so +`@dbmole/core` resolves): + +```bash +docker build -f packages/esmole-mcp/Dockerfile -t esmole-mcp . +``` + +Run it from your client, persisting the store in a named volume: + +```jsonc +{ + "mcpServers": { + "esmole": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-v", "esmole-store:/home/node/.config/esmole", + "esmole-mcp" + ] + } + } +} +``` + +The image runs as the non-root `node` user, so the store lives at +`/home/node/.config/esmole` — mount the volume there (as above). + +## 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.