8.8 KiB
esmole + monorepo design
Date: 2026-06-16 Status: approved, pending implementation plan
Goal
Add Elasticsearch as a second MCP server (esmole-mcp) alongside the existing
dbmole-mcp (PostgreSQL/MySQL). Restructure the repo into an npm-workspaces
monorepo so both servers share infrastructure (connection store, manager cache,
SSH tunnel, MCP plumbing) without forcing Elasticsearch into the SQL-shaped
Driver interface.
The agent sees two distinct MCP servers (two entries in its MCP config, each its
own bin). They live in one repo and share a private core package.
Why not reuse the SQL Driver for ES
Driver is relational (query(sql), listDatabases, describeTable with
PK/FK/indexes). Elasticsearch is a document/search store. Mapping _search→query,
indices→tables, mappings→describe gives a crippled, dishonest contract. ES gets
its own thin Backend abstraction instead; only the generic plumbing is shared.
Reference
../homelab/es-mcp (Python/FastMCP) is the tool-surface baseline: a generic REST
passthrough plus four helpers, all returning {status, body} and never raising
on 4xx/5xx. esmole keeps that tool surface but swaps the obvious differences:
stdio transport (not HTTP+bearer), multi-connection named connections + store +
SSH tunnel inherited from dbmole (not single-connection from env).
Scope decisions
- ES versions: 7.x and 8.x. Passthrough core is version-agnostic; helpers work
on both. No ESQL (
_queryis 8.11+, absent in 7.x). - Use cases: read/debug + full CRUD + cluster ops, all reachable through the generic passthrough; helpers cover the common read paths.
- Improvements over the reference (all in scope): output truncation / token
budget, per-connection
readonlyguard, mapping flatten (field:type list), search projection (_sourcefilter) + aggs-only mode. - Restructure: full workspaces immediately — move existing
srcintopackages/dbmole-mcp, extractcore.
Architecture (Approach A: generic core + injected schema)
core owns the hard, backend-agnostic machinery; each leaf package supplies a
thin backend factory, its own connection schema, and its own tool set.
§1. Repo layout
dbmole-mcp/ # repo root, private, workspaces: ["packages/*"]
package.json # shared devDeps + scripts (lint/test/build all)
biome.json # shared
tsconfig.base.json # shared compiler options
packages/
core/ # @dbmole/core — private, NOT published
dbmole-mcp/ # public npm, bin: dbmole-mcp
esmole-mcp/ # public npm, bin: esmole-mcp
core is not published. It is bundled into each leaf package via tsup
(noExternal: [/@dbmole\/core/]) so the published packages are self-contained,
with no inter-package version coupling and no third publish. Two public npm names
(dbmole-mcp unchanged, esmole-mcp new); core exists only inside the repo.
§2. core public surface (the generic seam)
The current registry/store/sources import connectionConfigSchema directly
and hardcode dbmole: log prefixes and DBMOLE_STORE / DBMOLE_CONNECTIONS
env-var names. Generalization = inject the schema and the
storePath/envVar/logPrefix as dependencies.
createRegistry({ storePath, configPath, env, schema, logPrefix, envVar })— schema injected; no direct import of any concrete schema.createManager<TBackend extends { dispose(): Promise<void> }>(registry, { createBackend, createTunnel })— generic over the backend; does not knowDriver.baseConnectionShape— a zod raw shape withouttype. Each package spreads it, adds its owntypeenum and engine-specific fields, then calls.strict().openTunnel/Tunnel— unchanged (pure TCP).withManaged,respond— unchanged, already generic.registerConnectionTools(server, { manager, registry, schema, ping })— generic connection CRUD (list / add / remove / update / test_connection);ping(backend)is the per-backend hook backingtest_connection.- format primitives:
clampLimit,truncateRows,truncateJsonBudget.
§3. Manager generalization
The manager needs only dispose() from a backend. All the hard logic — cache,
rotation, dispose-race handling, tunnel guards, retry-on-stale (manager.ts:82-129)
— moves verbatim into core. The only change: defaultCreateDriver becomes the
injected createBackend(target). DriverTarget → BackendTarget { config, host, port } (generic config type parameter). The tunnel?.isClosed() recheck stays.
The SQL Driver interface stays in dbmole-mcp. ES implements its own Backend.
Both satisfy { dispose(): Promise<void> }, so both ride the same manager — ES
inherits multi-connection, named connections, SSH tunnel, runtime add_connection,
and the store for free (an upgrade over the single-connection reference).
§4. Connection schema split
- base (core):
name,host,port?,user(required),password?,readonly,ssh— exactly dbmole's current fields minusdatabase, so dbmole's behavior is unchanged. (databaseleaves the base — it is SQL-specific.) - dbmole: base +
type: enum(['postgres','mysql'])+database?;defaultPort5432 / 3306. No override of base fields. - esmole: base with
useroverridden to optional, +type: enum(['elasticsearch'])+scheme: enum(['http','https']).default('https')+verifyTls: boolean.default(true)+apiKey?(sent asAuthorization: ApiKey <value>), plus a.refinerequiring user/password or apiKey;defaultPort9200. registry.update's engine-switch port-drop (registry.ts:130) is already generic (anytypechange without an explicitportdrops the old port).
§5. esmole backend + tools
Backend = an HTTP client (undici, keep-alive + dispose()) bound to
scheme://tunnelHost:tunnelPort, with auth (basic or apiKey) and verifyTls.
request(method, path, { body, params }) → {status, body}, never throwing on
4xx/5xx; body parsed as JSON when possible, else text. A string body is sent
as-is (for NDJSON _bulk); dict/list is JSON-serialized.
readonly guard (es/guard.ts, role analogous to sqlGuard): when the
connection is readonly, allow only GET/HEAD plus POST to a read-suffix allowlist
(_search, _msearch, _count, _field_caps, _cat, _mapping, scroll).
Block all PUT/DELETE and any other POST. Allowlist (not blocklist) so unknown
endpoints fail safe.
Tools (5 ES-specific + connection CRUD from core):
| tool | wraps | improvement |
|---|---|---|
es_request |
generic passthrough | readonly guard + truncation |
es_search |
POST /{index}/_search |
_source projection + aggs-only (size:0) + truncation |
es_list_indices |
GET /_cat/indices |
— |
es_get_mapping |
GET /{index}/_mapping |
flatten to field:type list (default); raw? for nested JSON |
es_cluster_health |
GET /_cluster/health |
— |
Index is always explicit; there is no default index. es_request is the primary
tool and covers the entire ES REST surface; helpers are sugar for common reads.
truncation: cap response by byte budget and hit count, set truncated: true,
mirroring dbmole's row truncation.
§6. Distribution / entry / docker
- Each leaf package has its own stdio entry and
bin.esmole-mcp→dist/index.js. - Docker: per-package Dockerfile, self-contained (core is bundled in). A root multi-image build is optional/later.
- npm:
dbmole-mcp(unchanged),esmole-mcp(new), both public;coreprivate.
§7. Testing
- Per-package vitest projects: unit (mocked IO) + integration (testcontainers). esmole integration runs against ES 7.x and 8.x containers. Coverage ≥90% lines/functions per package — thresholds never lowered.
- Manager concurrency tests move into
corealongside the manager.
Defaults
- ES
readonlydefaultfalse(matches dbmole). - Auth: user/password primary,
apiKeyoptional. schemedefaulthttps(8.x-friendly); 7.x-over-http setshttpexplicitly.
Migration order (high level; detailed plan via writing-plans)
- Workspaces skeleton;
git mv srcintopackages/dbmole-mcp(history preserved); tests green. - Extract
core(config/manager/tunnel/respond/format/connection-tools), inject the schema; dbmole depends on core; tests green. - Scaffold esmole: schema → backend → guard → tools → entry, TDD.
- Docker + publish config.
Out of scope
- ESQL (
_query) helper — 8.x-only, deferred. - OpenSearch-specific testing — passthrough likely works, but helpers are not validated against it.
- Publishing
coreas a standalone package. - Cross-server unified config (each server keeps its own store namespace:
ESMOLE_STORE/ESMOLE_CONNECTIONSvsDBMOLE_STORE/DBMOLE_CONNECTIONS).