Repository navigation
Releases
v0.4.1
A conformance release. No behavioural change to any tool.
All 34 tools declare the four behaviour hints as explicit booleans
The 25 read-only tools declared readOnlyHint, idempotentHint and openWorldHint but omitted destructiveHint. That is valid under the MCP specification — the hint only carries meaning once readOnlyHint is false — but tool directories do not read it that way: OpenAI's rejects a tool where any of the four is missing or non-boolean, and an external audit marked 25 of these 34 tools down for it.
destructiveHint: false states what a client already inferred from readOnlyHint: true, so nothing a caller sees changes except the annotation itself. A new test in test/server.test.ts asserts the four hints across every tool set at once, so the omission cannot come back unnoticed.
Also in this release
CI moved to the node24 action majors. The runner had been forcing the node20 actions onto node24 and warning on every run; that forcing ends with the deprecation window, so the bump is the fix rather than a way to silence the warning. checkout v4→v7, setup-node v4→v7, pnpm/action-setup v4→v6, and the five docker actions to their current majors. Two breaking changes were checked and neither bites: setup-node v7 drops the dummy NODE_AUTH_TOKEN export, and the publish step sets the real value itself; setup-node v5 caches automatically when packageManager is present, which only duplicates the cache: pnpm already spelled out. pnpm/action-setup v6 is a correction rather than a bump — it is the first release that knows pnpm 11, which packageManager pins to 11.22.0.
The Dockerfile secret check now reads values instead of names. SecretsUsedInArgOrEnv judges a variable by its name, so it flagged five declarations on every build: ES_API_KEY, ES_PASSWORD, ES_OAUTH_CLIENT_SECRET, ES_OAUTH_TOKEN_URL and ES_OAUTH_AUTH_STYLE. Nothing is baked into the image — all five are declared empty, and the loader treats an empty value as unset throughout. They are declared rather than merely documented so docker inspect and --env-file show an operator the surface the image accepts, so the fix is to correct the check rather than delete the declarations. A repository-wide skip would also hide a real secret typed in later, so CI gains a stricter rule in its place: any credential-shaped ENV or ARG whose value is not exactly ="" fails the build. Verified by mutation — the current Dockerfile passes, and baking a key into ES_API_KEY is caught.
README: the fork's Elasticsearch 7.x targeting is now called out up front, plus header formatting and a live-monitoring badge.
Full Changelog: v0.4.0...v0.4.1
v0.4.0
Two additions, both behind flags. The default surface is unchanged at 17 tools.
get_node_stats — diagnostic set (ES_ADMIN_TOOLS)
list_nodes reports heap, CPU and disk from _cat/nodes, which leaves three things invisible: whether the JVM is spending its time collecting garbage, whether work is queueing or being rejected, and whether a circuit breaker has fired. A non-zero rejected on the write pool is where an ingest silently loses documents, and nothing in this server could show it.
- One tool, one call —
_nodes/stats/jvm,thread_pool,breaker. The metric filter is what keeps it cheap: a bare_nodes/statsalso returnsindices, which is most of the payload and answers none of these questions. - Uptime is printed on every node's line, because every counter here is cumulative since the node started. Without it, 128 rejections reads as "the cluster is rejecting writes" to a calling model rather than "it did, at some point in the last six weeks" — a different answer, and the wrong one.
- The filtering is asymmetric on purpose. Nodes are never dropped: they number in the tens, and a node missing from the answer reads as a node that is fine. Idle thread pools and breakers are dropped, since around twenty pools per node would swamp the result. Pools are selected by activity rather than by a whitelist of names, so a saturated
force_mergeor snapshot pool stays visible. - No
indexing_pressure: it arrived in 7.9 and the target cluster is 7.8. - Cost: 746 bytes of
tools/list, and only whenES_ADMIN_TOOLSis on. The diagnostic set now holds eight read-only tools.
trace_request and an environment filter — ECS set (ES_ECS_TOOLS)
Two gaps found by running 0.3.0 against a 7.8 cluster hosting three environments and 202 daily indices.
The environment filter. service.environment was already named in fields.ts but nothing filtered on it, so a question about a service was answered with the sum of all environments, and nothing in the response said it was a sum. Measured on that cluster: a service reporting 390 errors over 24 h was 210 in integration, 179 in staging and 1 in QA. env joins the shared filter block, so all five tools take it.
trace_request. The other four tools aggregate across requests, so none can attribute a failure to a call two services down — which is exactly the question asked once the error is in hand. The new tool takes a correlation id and returns the chain of services that handled the request, its failed events and its timeline, oldest first.
- It matches
trace.idorhttp.request.id, and the pair is the point: a stack without APM emits notrace.id. On the cluster above, over 24 h,http.request.idwas set on 3 210 868 documents,trace.idon 9 205 andspan.idon none — so trustingtrace.idalone answers "no trace" for 99.7 % of requests.requestIdjoins the shared filters too, so the other tools can restrict on it. - The chain and the failed events come from aggregations, not from the returned hits. Derived from hits they are bounded by
limit, and on a real 385-event trace the single ERROR sat past line 200: the tool rendered a chain without the cause of the failure, while its own description promised otherwise. - Failure levels come from
minLevelTerms("error")rather than a hardcoded list, soSEVEREandcritcount asERROR. - The id goes in
shouldwithminimum_should_match: 1. Without it the clause only weighed on the score, and the tool returned the whole window. - Defaults differ on purpose:
limitis 200 against 20 elsewhere, andsinceis 24h against 15m. You arrive at this tool with an id taken from an error that may be hours old, and half a chain is not an answer. - Cost: the ECS
tools/listgoes from 21 553 to 25 416 bytes, +3 863 — 2 654 for the tool and 1 209 for the two filters joining a block that five tools each describe.
Proven end to end over MCP against the real 7.8 cluster: a 385-event trace across 5 services in 103 ms.
Full Changelog: v0.3.0...v0.4.0
v0.3.0 — ECS log search
Four tools for clusters whose application logs are in
ECS, plus two
that answer questions the existing tools could not.
Upgrading from 0.2.0 adds two tools to the default set. The ECS tools stay off
until you set ES_ECS_TOOLS.
The ECS log tools
The 26 existing tools talk to a cluster. These talk to a schema: because the
fields are known in advance, they take named parameters instead of a query DSL,
answer in log lines instead of JSON documents, and request only the fields they
print. That last part is not tidiness — on five hundred events, asking for the
whole _source is the difference between an answer and an exhausted context.
| Tool | The question |
|---|---|
search_logs |
What happened — newest first, one line each |
log_histogram |
When it started, peaked, and whether it stopped |
error_summary |
What is broken, grouped and counted |
top_values |
Who, where, and what this cluster actually indexes |
Filters are named and exact: service, levels / minLevel, host, logger,
dataset, traceId, and free text over message and error.message. The
window is a bare duration — 15m, 2h, 7d — and date math still works where
you need it.
{
"mcpServers": {
"elasticsearch": {
"command": "npx",
"args": ["-y", "@agrica/elasticsearch7-mcp"],
"env": {
"ES_HOST": "https://es.internal:9200",
"ES_ECS_TOOLS": "true",
"ES_ECS_INDEX_PATTERN": "logs-app-*"
}
}
}
}ES_ECS_INDEX_PATTERN is required and has no default: with the flag on and
the pattern missing, the server refuses to start. A guess like logs-* is not a
default — it would sweep whichever indices happen to match on your cluster and
answer confidently from the wrong data.
Why 7.8 decides how errors are grouped
match_only_text arrived in Elasticsearch 7.14 and wildcard in 7.9, so a
mapping pushed to a 7.8 cluster necessarily predates ECS 1.12 — the revision
where error.message and error.stack_trace moved to those types. The tools are
built for what 7.8 can actually express, and two consequences are visible in the
answers:
error.messageistextwith nokeywordsub-field, so errors cannot be
grouped by message.error_summarygroups byerror.type, and gives events
carrying noerror.typetheir own reported bucket — a vendor who logs
failures with onlylog.levelandmessagewould otherwise make the tool
answer "no errors" about a cluster full of them.error.stack_traceis never aggregated, although ECS 1.x types it as
keywordand it looks like the better fingerprint. ECS sets
ignore_above: 1024on keywords, so a longer trace is not indexed and would
vanish from the count — a wrong number that looks right.
Two tools for questions that had none
field_caps— which fields exist across an index pattern and whether
each is searchable and aggregatable.get_mappingsneeds a concrete index,
which is the wrong shape over a year of daily indices. It is also the only way
to see a field mapped as two different types across indices, which makes an
aggregation over it silently partial rather than failing.analyze— the terms a text is broken into, which is what a query must
produce to match. The answer to "my search returns nothing and I cannot see
why".
Both are in the default set, beside get_mappings: splitting the same question
across two gates would be incoherent. The default tool list goes from 15 to 17.
What it costs, measured
tools/list is 13 186 bytes for the default 17 tools and 21 553 with the
ECS set enabled. So the flag buys back 8.4 KB of context per session for any
deployment whose logs are not in ECS — which is the whole reason the set is
gated rather than always on. Most of that is structural, ten shared filters
described once per tool; trimming the repeated prose reclaimed 724 bytes of it.
Two things measurement changed
log_histogramgroups its rows fifty to a fragment. One fragment per bucket
followed the project's own rule about chunked detail, but the bucket count is
unbounded — a 1-minute interval over 30 days is 43 200 — and every fragment
carries its own envelope, so the result outgrew the byte budget by the count of
its own fragments.top_valuesdetects the "fielddata is disabled" refusal on the error body,
not its message:ResponseError.messagecarries only the error type unless
root_causeis an array. It then names the fix —<field>.keyword, or the ECS
keyword fields — rather than forwarding a message that says what Elasticsearch
will not do without saying what to do instead.
Also in this release
320 tests, up from 225. The design record, including the ECS revisions the field
types were checked against:
docs/superpowers/specs/2026-08-25-ecs-log-search-design.md.
The last French strings in scripts/smoke.mjs are translated, and the script now
exercises all six new tools.
The image is published for linux/amd64 and linux/arm64 as
ghcr.io/agrica/elasticsearch7-mcp:0.3.0.
v0.2.0 — OAuth2 client_credentials
A third authentication factor beside ES_API_KEY and ES_USERNAME/ES_PASSWORD:
an OAuth2 client_credentials token from an external identity provider, renewed
before it expires and sent as Authorization: Bearer … on every request.
Upgrading from 0.1.0 changes nothing unless you set the new variables.
What it is for
An Elasticsearch 7.x cluster cannot validate a third-party OAuth2 token
itself: the JWT realm arrived in 8.2, and the 7.x OIDC realm needs a platinum
licence and a browser flow that a stdio server has no way to run. So this targets
the deployment where a gateway in front of the cluster validates the token and
forwards the request. Point ES_HOST at the gateway.
This is also, by its own terms, what the MCP specification asks of a server in
this position — the authorization spec says stdio implementations SHOULD NOT
follow it and should read credentials from the environment, and separately
provides for a server acting as an OAuth client to an upstream API with a token
of its own.
Configuration
| Variable | Role |
|---|---|
ES_OAUTH_TOKEN_URL |
Token endpoint. Setting it turns the factor on. Must be https://, except on loopback |
ES_OAUTH_CLIENT_ID |
Client id |
ES_OAUTH_CLIENT_SECRET |
Client secret |
ES_OAUTH_CLIENT_SECRET_FILE |
Or a file holding it, for a mounted Docker secret |
ES_OAUTH_SCOPE |
Scope, if the provider needs one |
ES_OAUTH_AUDIENCE |
Audience — Auth0 needs it to issue a JWT |
ES_OAUTH_AUTH_STYLE |
post (default) or basic client authentication |
Keep the secret out of a project .mcp.json, which is version-controlled:
{
"mcpServers": {
"elasticsearch": {
"command": "npx",
"args": ["-y", "@agrica/elasticsearch7-mcp"],
"env": {
"ES_HOST": "https://es-gateway.internal",
"ES_OAUTH_TOKEN_URL": "https://idp.internal/realms/data/protocol/openid-connect/token",
"ES_OAUTH_CLIENT_ID": "mcp-elasticsearch",
"ES_OAUTH_CLIENT_SECRET": "${ES_OAUTH_CLIENT_SECRET}",
"ES_OAUTH_SCOPE": "es:read"
}
}
}
}Five things it refuses to do
Each is a decision with a test that fails if the decision is removed.
- When OAuth2 wins, the base client carries no credential at all. If the
bearer path breaks, the request fails with a 401 rather than quietly
succeeding as whatever identityES_API_KEYnames. A silently substituted
identity is worse than an outage. - A half-configured block refuses to start. The block is built as soon as
anyES_OAUTH_*variable is set, so a forgotten one is a startup error, not
a silent fallback to another factor. ES_OAUTH_TOKEN_URLmust be HTTPS, loopback excepted, with no override
flag. What is at stake is the client secret crossing the network in the clear.- The secret is trimmed from both sources. A secret pasted into a config
arrives with a newline, and a file written withechoalways has one —
untrimmed it yieldsinvalid_client, an error that says nothing about its
cause. - Neither the token nor the secret reaches a log line or a tool result. A
token-endpoint failure reports only the fields RFC 6749 defines: an identity
provider that echoes the request back in its error would otherwise carry the
secret into the calling model's context.
Diagnostics instead of a retry
A rejected token is not retried, deliberately: with renewal happening a minute
before expiry, a 401 means a revoked token, clock skew, or the wrong scope — and
a retry fixes none of those. What happens instead is that the WWW-Authenticate
header is read, and its error and scope are named in the tool's error. So an
insufficient scope reads as
Error: … The target answered 403 with error="insufficient_scope",
and asks for scope "es:write" — ES_OAUTH_SCOPE has to include it.
Startup also says on stderr which factor is in force and which were ignored,
because a silent precedence is how the wrong identity ends up in production.
Under the hood
Token rotation costs no network round trip. The bearer is attached with the 7.17
client's child(), which shares the parent's connection pool and copies the
product-check symbol — so a new token opens no socket and repeats no GET /.
Verified before being relied on, and asserted by a test.
Ask for the scope you can actually use: if the deployment runs without
ES_ALLOW_DESTRUCTIVE, a read-only scope is the right request — a write scope
buys nothing when no write tool is registered, and widens what the secret is
worth if it leaks.
Also in this release
The design record, audited against MCP 2025-06-18 and 2025-11-25, OAuth 2.1 and
RFC 9700, with the sources listed so the audit can be re-run:
docs/superpowers/specs/2026-08-24-oauth2-client-credentials-design.md.
225 tests. The image is published for linux/amd64 and linux/arm64 as
ghcr.io/agrica/elasticsearch7-mcp:0.2.0.
v0.1.0 — Elasticsearch 7.x MCP server
First release of this fork: an MCP server that exposes an Elasticsearch 7.x
cluster to an MCP client (Claude Code, Claude Desktop, Codex, Cursor) over stdio.
Renamed fork of @awesome-ai/elasticsearch-mcp, retargeted at 7.x. The client is
pinned to @elastic/elasticsearch@^7.17 on purpose: the 8.x client hardcodes a
product check requiring the x-elastic-product header and compatible-with=8
media types, neither of which a 7.x cluster sends or accepts, and neither of
which is reachable through ClientOptions. Against a 7.8 cluster the 8.x client
fails in a way that looks like an unrelated connection error.
Install
npm install -g @agrica/elasticsearch7-mcp --registry=https://npm.pkg.github.comThe package is public, but GitHub Packages requires authentication anyway:
without a ~/.npmrc carrying a read:packages token, npx answers 401. The
README spells out the two lines that need.
A multi-arch image is published alongside it, for linux/amd64 and
linux/arm64:
ghcr.io/agrica/elasticsearch7-mcp:0.1.0
Three tool sets, gated at registration
| Set | Flag | Tools |
|---|---|---|
| Data | always on | 15 read/write: search, count, get_document, bulk, list_indices, get_mappings, create_index, create_mapping, reindex, the template tools, health and cluster info |
| Diagnostics | ES_ADMIN_TOOLS |
7 read-only: explain_allocation, list_shards, list_nodes, get_index_stats, get_index_settings, get_cluster_settings, list_tasks |
| Destructive | ES_ALLOW_DESTRUCTIVE |
4 irreversible: delete_index, delete_document, delete_by_query, delete_index_template |
The gate is registration, not a runtime check: an unregistered tool is absent
from tools/list, so the model cannot call it and its schema costs nothing in
the caller's context. The diagnostic set is read-only by design, which is what
makes it safe to enable in production — an agent can explain an unhealthy index
without anyone opening a cluster console.
delete_by_query and the other destructive tools additionally refuse a
wildcard, a comma-separated list, * and _all even with the flag on: a
model that mistakes logs-* for one index gets a refusal and no request is sent.
What one call costs the caller's context
The design constraint this release is built around. Measured on a cluster shaped
like a year of daily indices — 365 indices, 2190 shards:
| Call | Before | Now |
|---|---|---|
list_shards |
385 469 B (~96 000 tokens) | 92 B, or 32 826 B with verbose |
search, size: 500 |
127 716 B | 32 886 B (size capped at 100, with the from to page with) |
list_indices, 365 indices |
54 405 B | 32 606 B |
get_mappings, 1000 fields |
~80 000 B | 32 803 B |
Every result is capped at ES_MAX_RESULT_BYTES (32 KiB default). A trimmed
answer always says it was trimmed, says how much went, and says how to ask a
smaller question — silence is the real hazard, because a model handed a
shortened list with no notice concludes the entry it wanted does not exist.
Protocol conformance
Audited against the 2025-06-18 specification.
isError: trueon every failure, guardrail refusals included — a refusal
reported as a success would let a model conclude a delete had happened.- Every tool carries
annotationsand atitle, viaregisterTool.
idempotentHintis claimed only where repeating the identical call really
leaves the same state. notifications/cancelledaborts the in-flight Elasticsearch request, bound to
the client rather than threaded through 26 tool signatures.- Four genuinely tabular tools —
list_indices,list_shards,
get_index_settings,get_mappings— return typedstructuredContent
alongside the text, assembled from whatever room the readable answer left. - Knowing deviation: no rate limiting, which the specification states as a
MUST. This server is spawned by, and serves, one local client over stdio, so
the client is the trust boundary and a limiter would throttle the operator
rather than an attacker. Revisit if a non-stdio transport is ever added.
Configuration
ES_HOST (comma-separated for several nodes), ES_API_KEY or
ES_USERNAME/ES_PASSWORD, ES_CA_CERT, ES_REQUEST_TIMEOUT,
ES_MAX_RETRIES, ES_MAX_RESULT_BYTES, ES_INSTANCE_LABEL, ES_ADMIN_TOOLS,
ES_ALLOW_DESTRUCTIVE. A malformed numeric value is reported to stderr and
ignored rather than fatal — refusing to start over ES_MAX_RETRIES=three would
take the session down for a setting with a sane fallback.
ES_INSTANCE_LABEL is cosmetic and exists for one real problem: an
mcpServers block usually declares this server once per cluster, and without a
label a client shows two identically named servers. It becomes the server title
(Elasticsearch 7.x — production). Nothing depends on it — in particular not
the destructive gate, which is ES_ALLOW_DESTRUCTIVE and nothing else.
See README.md for the
per-client setup blocks.