add_connection advertised an empty `{ properties: {} }` while the server
validated strictly, so clients could not see field types or the required
`type` literal and sent port/verifyTls as strings.
Cause: the connection schema was wrapped in `.superRefine`, producing a
ZodEffects with no `.shape`. The MCP SDK's normalizeObjectSchema returns
undefined for it and falls back to EMPTY_OBJECT_JSON_SCHEMA. update_connection
passed a raw shape, so it serialized fine - hence the asymmetry.
Split the schema: a strict z.object (connectionObjectSchema) is advertised as
the wire schema, and the refined connectionConfigSchema is passed as a new
optional parseSchema applied in the handler. Strict unknown-key rejection and
the apiKey-or-user+password check are both preserved. fullSchema is now typed
as a ZodObject so a ZodEffects can no longer be passed by mistake.
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, 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:
{
"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 <path> 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:
{
"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:
GETandHEADare always allowed.POSTis allowed only to read-intent endpoints —_search,_msearch,_count,_field_caps,_mapping,_search/scroll,_pit, and_cat.DELETEis 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:
"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):
docker build -f packages/esmole-mcp/Dockerfile -t esmole-mcp .
Run it from your client, persisting the store in a named volume:
{
"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
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 for the contributor style contract: code style, TypeScript discipline, testing rules, and commit conventions.