From 7cf275417097677dadea63e86ccf1a7f2ccedd93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Thu, 17 Sep 2026 09:48:29 +0200 Subject: [PATCH 1/4] new example --- .claude/plans/mcp-server-example.md | 332 ++++++++++++++++++ README.md | 1 + examples/mcp-server/.env | 18 + examples/mcp-server/README.md | 132 +++++++ examples/mcp-server/confs/elasticsearch.yml | 19 + examples/mcp-server/confs/kibana.yml | 16 + examples/mcp-server/confs/readonlyrest.yml | 46 +++ .../mcp-server/docker-compose.override.yml | 45 +++ .../images/mcp-initializer/Dockerfile | 9 + .../images/mcp-initializer/entrypoint.sh | 78 ++++ examples/mcp-server/scripts/init.sh | 21 ++ examples/mcp-server/scripts/post-start.sh | 22 ++ runner/utils/boot/extract_license_edition.sh | 2 +- 13 files changed, 740 insertions(+), 1 deletion(-) create mode 100644 .claude/plans/mcp-server-example.md create mode 100644 examples/mcp-server/.env create mode 100644 examples/mcp-server/README.md create mode 100644 examples/mcp-server/confs/elasticsearch.yml create mode 100644 examples/mcp-server/confs/kibana.yml create mode 100644 examples/mcp-server/confs/readonlyrest.yml create mode 100644 examples/mcp-server/docker-compose.override.yml create mode 100644 examples/mcp-server/images/mcp-initializer/Dockerfile create mode 100755 examples/mcp-server/images/mcp-initializer/entrypoint.sh create mode 100755 examples/mcp-server/scripts/init.sh create mode 100644 examples/mcp-server/scripts/post-start.sh diff --git a/.claude/plans/mcp-server-example.md b/.claude/plans/mcp-server-example.md new file mode 100644 index 0000000..e5cfbc2 --- /dev/null +++ b/.claude/plans/mcp-server-example.md @@ -0,0 +1,332 @@ +# Plan: `examples/mcp-server` — Elasticsearch MCP Server behind ReadonlyREST + +**Audience:** the implementation agent. Everything below was verified against the actual sources +(ROR core Scala, elastic/mcp-server-elasticsearch Rust, ROR docs, this repo's runner) on 2026-09-17. +Where something could not be verified without running the stack, it is marked **VERIFY**. + +**Goal:** a runnable example (`./run.sh mcp-server`) proving that the Elasticsearch MCP server works +against a ROR-secured cluster with **Elasticsearch API-key authentication**, plus a README that +answers the customer question honestly, including the limitations. + +--- + +## 1. Background / why this shape + +The customer (Zdenek) asked about the **Kibana Agent Builder MCP endpoint**. That is a Kibana +premium feature, so ROR cannot support it — say so once in the README and move on. The plan targets +[`elastic/mcp-server-elasticsearch`](https://github.com/elastic/mcp-server-elasticsearch), which talks +to **Elasticsearch only** and therefore sits entirely behind ROR's ACL. + +Note for the README: that server carries an upstream deprecation notice ("critical security updates +only", superseded by Agent Builder). It still works and is the only ES-native MCP server that ROR can +secure today. State this plainly rather than hiding it. + +## 2. Verified facts about the MCP server (v0.4.6, Rust) + +| Fact | Source | +|---|---| +| Image `docker.elastic.co/mcp/elasticsearch`, multi-arch, entrypoint `/usr/local/bin/elasticsearch-core-mcp-server`, base `cgr.dev/chainguard/wolfi-base` | `Dockerfile` | +| Subcommands: `stdio` and `http`. HTTP listens on `:8080`, MCP endpoint `/mcp`, health `/ping` | `README.md`, `src/cli.rs` | +| Args can also come from the `CLI_ARGS` env var (whitespace-split) | `src/bin/elasticsearch-core-mcp-server.rs` | +| Config env: `ES_URL`, `ES_API_KEY`, `ES_USERNAME`/`ES_PASSWORD`, `ES_SSL_SKIP_VERIFY` | `elastic-mcp.json5`, `README.md` | +| **Reads a `.env` file** at startup via `dotenvy::dotenv()` (searches CWD upwards). Real env vars take precedence over `.env`. | `src/bin/elasticsearch-core-mcp-server.rs` | +| **In `http` mode the incoming `Authorization` header is forwarded to ES per request**, overriding the configured credentials. Accepts `ApiKey …`, `Basic …`, and strips a leading `Bearer ` prefix. | `src/servers/elasticsearch/mod.rs:81-103` | +| No custom-CA option — only `ES_SSL_SKIP_VERIFY=true` | `src/servers/elasticsearch/mod.rs` | +| 5 tools and the ES calls they make | `src/servers/elasticsearch/base_tools.rs` | + +Tool → ES request → ES action string: + +| Tool | Request | Action | +|---|---|---| +| `list_indices` | `GET /_cat/indices/?format=json&h=index,status,docs.count` | `indices:monitor/stats`, `indices:monitor/settings/get`, `cluster:monitor/state` | +| `get_mappings` | `GET //_mapping` | `indices:admin/mappings/get` | +| `search` | `POST //_search` | `indices:data/read/search` | +| `esql` | `POST /_query` | `indices:data/read/esql` (+ `…/resolve_fields`, `…/compute`) | +| `get_shards` | `GET /_cat/shards[/]?format=json` | `cluster:monitor/state`, `indices:monitor/stats` | + +## 3. Verified facts about ROR + API keys + +- `token_authentication` with `type: "api-key"` asks **Elasticsearch** to validate the key + (`ApiKeyService.validateToken`, result cached) and, on success, logs the request in as the + **single fixed `username` from the config** + (`core/.../blocks/rules/auth/TokenAuthenticationRule.scala`). +- The header prefix is **strictly `ApiKey`** (case-insensitive), header name defaults to + `Authorization`, overridable with `header:` + (`core/.../factory/decoders/rules/auth/TokenAuthenticationRuleDecoder.scala`, + `AuthorizationTokenPrefix.api = "ApiKey"`). This matches exactly what the MCP server sends. +- Requires ES ≥ 7.14 and `xpack.security.enabled: true` with HTTP TLS (API key service). + ROR ≥ **1.69.0** (that release added Fleet/API-key/service-token support). +- **Key limitation to document:** every valid ES API key maps to the *same* ROR user, so you cannot + give key A and key B different index permissions with `token_authentication` alone. API keys are a + *service identity*, not a per-user identity. Per-user ACLs need basic-auth passthrough + (see §5, scenario B) or one MCP server process per identity. +- ROR applies the ACL to ES|QL (1.71.0 also covers `LOOKUP JOIN`), and treats `/_cat/indices` as an + index-aware path (filtered by the `indices` rule, `PathValue.isCatIndicesPath`). `/_cat/shards` + has **no** special handling in ROR core → **VERIFY** what it returns for a restricted user, and + write the result in the README compatibility table. + +## 4. Deliverable: files to create + +``` +examples/mcp-server/ + .env # versions + ROR_MIN_LICENSE_EDITION=FREE + README.md # title + first paragraph are printed by run.sh + confs/elasticsearch.yml # copy of examples/fleet/confs/elasticsearch.yml (xpack security + TLS) + confs/kibana.yml # copy of examples/basic-multitenancy/confs/kibana.yml + confs/readonlyrest.yml # see §5 + docker-compose.override.yml # mcp-initializer + mcp-server services + images/mcp-initializer/ + Dockerfile # FROM ubuntu:24.04 + curl + jq (mirror examples/fleet/images/fleet-initializer) + entrypoint.sh # creates API keys, writes /mcp-env/.env + scripts/init.sh # seed demo indices (runs in the shared `initializer` container) + scripts/post-start.sh # print endpoints, users, and the `claude mcp add` one-liner +``` + +Do **not** add a `certs/` dir: the runner's ES/KBN images already bake in `ca.crt`/`elasticsearch.crt` +(`runner/images/es/Dockerfile-API`). The MCP server uses `ES_SSL_SKIP_VERIFY=true` because it has no +custom-CA option. + +Remember to add the example to the table in the root `README.md`. + +## 5. `confs/readonlyrest.yml` (starting point) + +```yaml +readonlyrest: + + audit: + enabled: true + outputs: [index] + + access_control_rules: + + - name: "KIBANA" + type: allow + auth_key: kibana:kibana + verbosity: error + + # Scenario A — the MCP server's own service identity. + # ROR asks ES to validate the API key; any valid key resolves to username "mcp". + - name: "MCP server (ES API key)" + type: allow + token_authentication: + type: "api-key" + username: "mcp" + indices: ["logs-*", "orders-*"] + actions: + - "indices:data/read/*" + - "indices:admin/mappings/get" + - "indices:monitor/*" + - "cluster:monitor/*" + + # Scenario B — per-user identity via Authorization passthrough (http mode). + - name: "Analyst via MCP" + type: allow + auth_key: analyst:analyst + indices: ["logs-*"] + actions: + - "indices:data/read/*" + - "indices:admin/mappings/get" + - "indices:monitor/*" + - "cluster:monitor/*" + + - name: "Admins" + type: allow + auth_key: admin:admin + kibana: + access: admin +``` + +Notes: +- **Do not copy the fleet example's `forbid` block for `cluster:admin/xpack/security/api_key/*`** — + the initializer needs `POST /_security/api_key`. If you want to show the forbid pattern, put it + *below* the Admins block so admin can still mint keys, and say why in the README. +- The `actions` allow-list is what makes the MCP identity genuinely read-only. Tune it against real + ACL log output (`runner/ror-cluster.log`) — start permissive, then tighten, then re-run all 5 tools. +- Scenario B is what answers "can different agents see different data?" — it works because ROR sees + ordinary basic auth. Two personas (`analyst`, `admin`) are enough; do not build a full multitenancy + demo here (that is `basic-multitenancy`, and it needs an ENT license). + +## 6. `docker-compose.override.yml` (starting point) + +```yaml +services: + + mcp-initializer: + build: + context: ${EXAMPLE_DIR}/images/mcp-initializer + dockerfile: Dockerfile + hostname: mcp-initializer + depends_on: + es-ror: + condition: service_healthy + volumes: + - mcp-env:/mcp-env + networks: + - ror-network + + mcp-server: + image: docker.elastic.co/mcp/elasticsearch:${MCP_SERVER_VERSION:-0.4.6} + hostname: mcp-server + command: ["http"] + working_dir: /mcp-env # dotenvy picks up /mcp-env/.env written by the initializer + depends_on: + mcp-initializer: + condition: service_completed_successfully + ports: + - "18080:8080" + volumes: + - mcp-env:/mcp-env:ro + networks: + - ror-network + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ping | grep -q pong"] + interval: 10s + timeout: 5s + retries: 20 + start_period: 20s + +volumes: + mcp-env: +``` + +**Risks + fallbacks, in order of preference:** + +1. `working_dir` + `/mcp-env/.env` is the cleanest way to inject a *runtime-generated* API key + (the key value only exists after ES is up). **VERIFY** that dotenvy actually finds it; the image + has no `WORKDIR`, and `dotenv()` walks up from the CWD. +2. If that fails: build a thin wrapper image + `FROM docker.elastic.co/mcp/elasticsearch:0.4.6`, add an entrypoint shell script that reads + `/mcp-env/api-key`, exports `ES_API_KEY`, and `exec`s the binary — same pattern as + `examples/fleet/images/apm-agent/entrypoint.sh`. wolfi-base ships a shell. +3. If the healthcheck binary is missing in wolfi-base (no `curl`/`wget`), drop the healthcheck and + use `depends_on: service_started`, or healthcheck `mcp-server` from the initializer instead. + +Also **VERIFY**: `docker compose up --wait` (see `runner/run.sh`) tolerates the one-shot +`mcp-initializer`. The fleet example does the same thing, so it should be fine. + +## 7. `images/mcp-initializer/entrypoint.sh` — what it must do + +Model it on `examples/fleet/images/fleet-initializer/entrypoint.sh` (same `check_curl` wrapper, +same wait-loop, `#!/bin/bash -x`, fail loudly). + +1. Wait until `https://es-ror:9200/_cluster/health` answers as `admin:admin`. +2. Create the service API key: + `POST /_security/api_key -d '{"name":"mcp-server"}'` → take the **`encoded`** field (that is the + base64 `id:api_key` value the `ApiKey` scheme needs; if a build returns only `id`/`api_key`, + compute `base64(id:api_key)` yourself). +3. Write `/mcp-env/.env`: + ``` + ES_URL=https://es-ror:9200 + ES_API_KEY= + ES_SSL_SKIP_VERIFY=true + ``` +4. Also write the raw key to `/mcp-env/api-key` and echo it to stdout so `post-start.sh` and the + README's curl examples can use it. +5. Exit 0 (one-shot). + +**VERIFY while implementing:** that `POST /_security/api_key` actually succeeds through ROR as +`admin:admin`. ROR forwards it to ES and ES owns the key. The ROR Fleet guide documents Fleet Server +doing exactly this, so it should work; if it does not, fall back to creating the key via the +`kibana:kibana` block and note it. + +**Open question worth answering for the customer (cheap to test here):** create a second key with +restrictive `role_descriptors` and check whether ES enforces them under ROR. Expectation: **no** — +ROR does its own authorization and runs the request internally, so the key's privileges are ignored +and only the ROR block's `indices`/`actions` apply. Confirm and write the answer in the README. + +## 8. `scripts/init.sh` + +Runs in the shared `initializer` container (`ELASTICSEARCH_USER=kibana` by default, helpers in +`/usr/local/lib/ror-utils.sh`). Seed enough data that the tools return something interesting: + +```bash +source /usr/local/lib/ror-utils.sh +createIndex "logs-app-2026" && generate_log_documents 50 | putDocument "logs-app-2026" +createIndex "orders-2026" && putDocument "orders-2026" '{...}' # a few hand-written docs +createIndex "hr-salaries-2026" && putDocument "hr-salaries-2026" '{...}' # NOT in the MCP block → proves the ACL +``` + +`hr-salaries-*` is the money shot: ask the agent "list all indices" / "search hr-salaries" and it +cannot see it. Make the README call that out. + +## 9. `scripts/post-start.sh` + +Print, in this order: +1. Kibana URL `https://localhost:15601` and the users (`admin:admin`, `analyst:analyst`). +2. MCP endpoint `http://localhost:18080/mcp`, health `http://localhost:18080/ping`. +3. The exact wiring command for Claude Code: + `claude mcp add --transport http elasticsearch http://localhost:18080/mcp` + and the per-user variant that exercises passthrough: + `claude mcp add --transport http elasticsearch-analyst http://localhost:18080/mcp --header "Authorization: Basic $(printf 'analyst:analyst' | base64)"` +4. A copy-paste `curl` that lists tools, so the example can be checked without any MCP client: + ```bash + curl -s http://localhost:18080/mcp -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' + ``` + **VERIFY** the streamable-HTTP handshake actually accepts a bare `tools/list` without an + `initialize` first; if not, ship a tiny `scripts/mcp-smoke-test.sh` that does + `initialize` → `tools/list` → `tools/call list_indices` instead. That script is the example's + self-test and the thing to run in CI/by hand. + +Follow the existing convention: `open https://localhost:15601` at the end. + +## 10. README.md for the example + +Mirror the fleet README's shape: `# Title`, one description paragraph (both are printed by +`run.sh`), architecture box, ports table, users table, `
` step-by-step, "What to explore", +"How to run". Then add three sections that are specific to this example: + +1. **Tool compatibility table** — the 5 tools × (works / blocked / filtered) with the ROR actions + involved. Fill it from real runs, not from this plan. +2. **API keys with ROR — what you get and what you don't**: ROR validates keys against ES, never + stores them, so rotation is free; but all keys collapse to one ROR username, and any + `role_descriptors` on the key are (**VERIFY**) not enforced. Point at + https://docs.readonlyrest.com/elasticsearch/fleet for the same mechanism under Fleet. +3. **Per-user access** — the `Authorization` passthrough trick, with the `analyst` demo. + +Also state up front: Kibana Agent Builder's MCP endpoint is a Kibana premium feature and is out of +scope; this example uses the standalone Elasticsearch MCP server, which is upstream-deprecated. + +## 11. `.env` + +``` +ROR_MIN_LICENSE_EDITION=FREE + +ES_VERSION=9.3.3 +ROR_ES_PLUGIN_SOURCE=API +ROR_ES_VERSION=1.69.1 + +KBN_VERSION=9.3.3 +ROR_KBN_PLUGIN_SOURCE=API +ROR_KBN_VERSION=1.69.1 + +MCP_SERVER_VERSION=0.4.6 +``` + +Keep the same ES/ROR pins as the other examples (proven combination). ROR must be ≥ 1.69.0 for +`token_authentication: api-key`. FREE edition is enough — do not use Kibana multitenancy here. + +## 12. Definition of done + +1. `./run.sh mcp-server` comes up clean from scratch (`./clean.sh` first), no manual steps. +2. `curl http://localhost:18080/ping` → `pong`. +3. All 5 MCP tools exercised against the ROR-secured cluster; results recorded in the README table. +4. `hr-salaries-2026` is provably invisible to the MCP identity (both `list_indices` and a direct + `search`), and the denial shows up in the ROR audit index / `runner/ror-cluster.log`. +5. The `analyst` passthrough path returns a different index set than the service key path. +6. Root `README.md` example table updated. +7. README answers, in plain language: *does it work with ROR* (yes, with API keys), *what is not + supported* (Agent Builder; per-key ACL differentiation), *what was tested* (versions + table). + +## 13. Suggested reply to the customer (draft, for the human to send) + +- Agent Builder's MCP endpoint is a Kibana premium feature → not supportable with ROR. +- The standalone `elastic/mcp-server-elasticsearch` works with ROR: it authenticates with a normal ES + API key, and ROR validates that key through Elasticsearch (`token_authentication: type: api-key`), + the same mechanism we already ship for Elastic Fleet. +- Caveat: with ROR, an API key is a service identity — every valid key resolves to one ROR user, so + permissions come from the ROR block, not from the key's `role_descriptors`. For per-user access, + run the MCP server in HTTP mode and let each client pass its own `Authorization` header. +- We now ship a runnable example: `curl -sL …/quickstart.sh | bash -s mcp-server` (link the example + dir), including a table of which MCP tools work and which ROR rules they need. diff --git a/README.md b/README.md index 422f0ef..b985923 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ To stop and clean up: | [basic-multitenancy](examples/basic-multitenancy) | Multi-tenancy: isolated Kibana spaces and index access per user | | [kibana-reverse-proxy](examples/kibana-reverse-proxy) | Two Kibana nodes behind an Apache HTTPS reverse proxy with sticky-session load balancing, SSL termination, and a configurable base-path rewriting strategy | | [fleet](examples/fleet) | Full Elastic Fleet stack: Fleet Server, Elastic Agent with APM, and an instrumented Node.js service, all secured with ReadonlyREST | +| [mcp-server](examples/mcp-server) | The Elasticsearch MCP server (elastic/mcp-server-elasticsearch) authenticating with an ES API key behind ReadonlyREST | ## Project structure diff --git a/examples/mcp-server/.env b/examples/mcp-server/.env new file mode 100644 index 0000000..e675fff --- /dev/null +++ b/examples/mcp-server/.env @@ -0,0 +1,18 @@ +# Minimum ReadonlyREST license edition required to run this example (FREE, PRO, ENT). +# If the detected license is lower, run.sh will exit with an error. +ROR_MIN_LICENSE_EDITION=FREE + +# ES/KBN ROR_PLUGIN_SOURCE options: +# API - download ReadonlyREST plugin from API (requires ROR_ES_VERSION / ROR_KBN_VERSION) +# LOCAL_FILE - use a local plugin file (requires ROR_ES_FILE / ROR_KBN_FILE) + +ES_VERSION=9.3.3 +ROR_ES_PLUGIN_SOURCE=API +ROR_ES_VERSION=1.69.1 + +KBN_VERSION=9.3.3 +ROR_KBN_PLUGIN_SOURCE=API +ROR_KBN_VERSION=1.69.1 + +# Elastic MCP server for Elasticsearch (elastic/mcp-server-elasticsearch) +MCP_SERVER_VERSION=0.4.6 diff --git a/examples/mcp-server/README.md b/examples/mcp-server/README.md new file mode 100644 index 0000000..927d2b4 --- /dev/null +++ b/examples/mcp-server/README.md @@ -0,0 +1,132 @@ +# Elasticsearch MCP Server with ReadonlyREST + +Runs the standalone [`elastic/mcp-server-elasticsearch`](https://github.com/elastic/mcp-server-elasticsearch) MCP server against a ReadonlyREST-secured Elasticsearch cluster, authenticating with a plain Elasticsearch API key. + +> Kibana's **Agent Builder** MCP endpoint is a Kibana premium feature and out of scope for ROR (ROR secures Elasticsearch and Kibana's HTTP layer, not Kibana's internal plugin APIs). This example uses the standalone, Elasticsearch-only MCP server instead. Note that upstream has marked that server deprecated ("critical security updates only", superseded by Agent Builder) — it still works today and is the only ES-native MCP server ROR can secure. + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Docker network (ror-network) │ +│ │ +│ es-ror ─────────────────────────────── kbn-ror │ +│ │ │ +│ ├── initializer (one-shot: seeds logs-*, orders-*, │ +│ │ hr-salaries-* indices) │ +│ │ │ +│ ├── mcp-initializer (one-shot: mints an ES API key, │ +│ │ writes it to the mcp-env volume) │ +│ │ │ +│ └── mcp-server (elastic/mcp-server-elasticsearch, :8080) │ +│ ▲ │ +│ │ Authorization: ApiKey ... (service identity) │ +│ │ Authorization: Basic ... (per-user passthrough)│ +│ MCP client (Claude Code, curl, ...) │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Exposed ports + +| Service | Host port | Description | +|------------|-----------|---------------------------------------| +| Kibana | 15601 | ReadonlyREST Kibana UI | +| MCP server | 18080 | Elasticsearch MCP server (`/mcp`, `/ping`) | + +## Users + +| Username | Password | Role | +|-----------|-----------|----------------------------------------------------------------| +| `admin` | `admin` | Kibana admin; the only identity allowed to mint ES API keys | +| `analyst` | `analyst` | MCP access to `logs-*` only, via Authorization passthrough | + +## How it works + +
+Step 1 — Elasticsearch starts with ReadonlyREST + +Elasticsearch starts with `xpack.security` TLS enabled and the ReadonlyREST plugin loaded. `readonlyrest.yml` defines four ACL blocks: + +- **KIBANA** — allows Kibana's internal user (`kibana:kibana`) unrestricted access for its own saved objects and system indices. +- **MCP server (ES API key)** — `token_authentication: {type: api-key}`. ROR asks Elasticsearch to validate the presented `ApiKey ` header; any key ES considers valid is logged in as the single fixed ROR user `mcp`, scoped to `logs-*` / `orders-*` and a read-only action set. +- **Analyst via MCP** — ordinary `auth_key: analyst:analyst` basic auth, scoped to `logs-*` only. This is what per-user access looks like once forwarded through the MCP server (see Step 4). +- **Admins** — `admin:admin`, unrestricted, so it can call `POST /_security/api_key` to mint keys for the MCP server. + +
+ +
+Step 2 — Demo data is seeded + +The shared `initializer` container runs `scripts/init.sh`, creating `logs-2026` (50 generated log lines), `orders-2026` (a handful of orders), and `hr-salaries-2026` (salary data). Only the first two match the MCP identity's `indices` pattern — `hr-salaries-2026` is there specifically to prove ROR blocks it. + +> The index is named `logs-2026`, not `logs-app-2026` — Elasticsearch ships a built-in `logs-*-*` index template that forces any two-hyphen `logs-`-prefixed name into a data stream, so a plain index create call 400s on a name with two segments after `logs-`. + +
+ +
+Step 3 — mcp-initializer mints an API key + +Once Elasticsearch is healthy, `mcp-initializer` calls `POST /_security/api_key` as `admin:admin` (allowed because the Admins block is unrestricted), takes the response's `encoded` field — the ready-to-use `base64(id:api_key)` value — and writes it to a shared Docker volume as `/mcp-env/.env`: + +``` +ES_URL=https://es-ror:9200 +ES_API_KEY= +ES_SSL_SKIP_VERIFY=true +``` + +`ES_SSL_SKIP_VERIFY=true` is used because the MCP server has no custom-CA option — only an on/off switch — and this cluster uses a self-signed certificate. + +
+ +
+Step 4 — mcp-server starts + +`mcp-server` runs `docker.elastic.co/mcp/elasticsearch` in `http --container-mode` mode with its working directory pointed at the volume `mcp-initializer` wrote to; the binary's built-in `.env` loader (`dotenvy`) picks up the API key from there automatically. It listens on `:8080` (mapped to host `18080`), exposing `/mcp` (Streamable HTTP, no `initialize` call required first) and `/ping` (health check). + +In HTTP mode the server forwards the request's own `Authorization` header to Elasticsearch when present, overriding its configured API key — this is what makes Scenario B below possible. + +
+ +## Tool compatibility + +All 5 tools tested against the MCP identity (`token_authentication`, scoped to `logs-*`/`orders-*`) and against `hr-salaries-2026` (out of scope). Verified against a live `./run.sh mcp-server` run: + +| Tool | ES request | ROR actions required | In scope (`logs-2026`) | Out of scope (`hr-salaries-2026`) | +|----------------|--------------------------------------|------------------------------------------------------------------------------|--------|--------| +| `list_indices` | `GET /_cat/indices/` | `indices:monitor/stats`, `indices:monitor/settings/get`, `cluster:monitor/state` | ✅ returned, with doc counts | ✅ silently absent — a `logs-*,orders-*,hr-*` pattern only lists `logs-2026` and `orders-2026` | +| `get_mappings` | `GET //_mapping` | `indices:admin/mappings/get` | ✅ mapping returned | ✅ blocked — ES itself returns `index_not_found_exception` (404) | +| `search` | `POST //_search` | `indices:data/read/search` | ✅ 53 hits | ✅ blocked — same 404 | +| `esql` | `POST /_query` | `indices:data/read/esql` (+ `resolve_fields`, `compute`) | ✅ rows returned | ✅ blocked — surfaces as `400 Bad Request` instead (ES|QL validates the `FROM` target differently than the REST index APIs) | +| `get_shards` | `GET /_cat/shards[/]` | `cluster:monitor/state`, `indices:monitor/stats` | ✅ shards listed | ✅ silently absent | + +For every REST-style call above, ROR doesn't hand back a plain 403 for an index outside a user's scope — it rewrites the requested index name to a random string before forwarding to Elasticsearch, so the *client* sees Elasticsearch's own `index_not_found_exception`, not a ROR-branded denial. This is deliberate: it avoids confirming to a caller that a restricted index even exists. + +> **Testing blocked calls with `curl`:** this MCP server (`elastic/mcp-server-elasticsearch` 0.4.6) closes its SSE response stream right after a *successful* tool call, but leaves the stream open after an *error* result — the JSON-RPC error itself arrives instantly, but plain `curl` (as used in the smoke test below) keeps waiting for the connection to close and will hang until it hits its own timeout. This is a quirk of the upstream binary, unrelated to ROR — it happens for any ES-level error, ROR-caused or not. Real MCP clients aren't affected, since they resolve on the JSON-RPC `id` rather than on stream closure. If you're poking at a blocked index with `curl` yourself, add `--max-time 5`. + +## API keys with ROR — what you get and what you don't + +- ROR validates the key **against Elasticsearch** on every request (`ApiKeyService.validateToken`, cached) — it never stores the key itself, so rotating or revoking keys through `_security/api_key` works exactly as it would without ROR. Same mechanism ROR already uses for [Elastic Fleet](https://docs.readonlyrest.com/elasticsearch/fleet). +- **Every valid API key resolves to the same ROR user** (`mcp`, here). An API key is a service identity to ROR, not a per-user identity — you cannot give key A and key B different index permissions with `token_authentication` alone. +- Any `role_descriptors` attached to the key at creation time are not consulted by ROR — ROR does its own authorization from `readonlyrest.yml` and never delegates to Elasticsearch's native security layer once the token is validated. +- For per-user access, run the MCP server in `http` mode (as this example does) and let each client send its own `Authorization` header — ROR sees it as ordinary basic auth or its own API key, unrelated to the server's configured identity. See the `analyst` user below. + +## Per-user access (Authorization passthrough) + +```bash +claude mcp add --transport http elasticsearch-analyst http://localhost:18080/mcp \ + --header "Authorization: Basic $(printf 'analyst:analyst' | base64)" +``` + +`analyst` can only see `logs-*` — asking it to list or search `orders-*` or `hr-salaries-2026` is denied by ROR, independently of what the MCP server's own configured API key can reach. + +## What to explore + +- Ask the agent to list indices, then to search `hr-salaries-2026` directly — both are refused; check `runner/ror-cluster.log` for the ROR audit entries. +- Compare what the `elasticsearch` (service key) and `elasticsearch-analyst` (passthrough) MCP connections can each see. +- Run an ES|QL query (`esql` tool) against `logs-2026` and against `hr-salaries-2026`. + +## How to run + +```bash +./run.sh mcp-server +``` diff --git a/examples/mcp-server/confs/elasticsearch.yml b/examples/mcp-server/confs/elasticsearch.yml new file mode 100644 index 0000000..e263ae6 --- /dev/null +++ b/examples/mcp-server/confs/elasticsearch.yml @@ -0,0 +1,19 @@ +network.host: 0.0.0.0 + +path.repo: /tmp/repositories + +cluster.max_shards_per_node: 10000 + +xpack.security.enabled: true +xpack.security.http.ssl.enabled: true +xpack.security.http.ssl.key: elasticsearch.key +xpack.security.http.ssl.certificate: elasticsearch.crt +xpack.security.http.ssl.certificate_authorities: ca.crt +xpack.security.http.ssl.verification_mode: certificate +xpack.security.http.ssl.client_authentication: optional +xpack.security.transport.ssl.enabled: true +xpack.security.transport.ssl.key: elasticsearch.key +xpack.security.transport.ssl.certificate: elasticsearch.crt +xpack.security.transport.ssl.certificate_authorities: ca.crt +xpack.security.transport.ssl.verification_mode: certificate +xpack.security.transport.ssl.client_authentication: optional diff --git a/examples/mcp-server/confs/kibana.yml b/examples/mcp-server/confs/kibana.yml new file mode 100644 index 0000000..b594f4b --- /dev/null +++ b/examples/mcp-server/confs/kibana.yml @@ -0,0 +1,16 @@ +server.name: ${SERVER_NAME} +server.host: 0.0.0.0 + +elasticsearch.username: kibana +elasticsearch.password: kibana +elasticsearch.ssl.verificationMode: none + +server.ssl.enabled: true +server.ssl.certificate: /usr/share/kibana/config/kibana.crt +server.ssl.key: /usr/share/kibana/config/kibana.key +server.ssl.redirectHttpFromPort: 80 + +xpack.encryptedSavedObjects.encryptionKey: "min-32-byte-long-strong-encryption-key" + +readonlyrest_kbn.logLevel: info +readonlyrest_kbn.cookiePass: '12312313123213123213123abcdefghijklm' diff --git a/examples/mcp-server/confs/readonlyrest.yml b/examples/mcp-server/confs/readonlyrest.yml new file mode 100644 index 0000000..bc80a7e --- /dev/null +++ b/examples/mcp-server/confs/readonlyrest.yml @@ -0,0 +1,46 @@ +readonlyrest: + + audit: + enabled: true + outputs: [index] + + access_control_rules: + + - name: "KIBANA" + type: allow + auth_key: kibana:kibana + + # Scenario A: the MCP server's own service identity. + # ROR asks Elasticsearch to validate the API key (ApiKey + # header). Any key that ES considers valid resolves to the single ROR user + # "mcp" below — permissions come from this block, not from the key itself. + - name: "MCP server (ES API key)" + type: allow + token_authentication: + type: "api-key" + username: "mcp" + indices: ["logs-*", "orders-*"] + actions: + - "indices:data/read/*" + - "indices:admin/mappings/get" + - "indices:monitor/*" + - "cluster:monitor/*" + + # Scenario B: per-user identity via Authorization passthrough. When the MCP + # server runs in http mode it forwards the client's own Authorization header + # to Elasticsearch, so ordinary basic auth reaches ROR unchanged. + - name: "Analyst via MCP" + type: allow + auth_key: analyst:analyst + indices: ["logs-*"] + actions: + - "indices:data/read/*" + - "indices:admin/mappings/get" + - "indices:monitor/*" + - "cluster:monitor/*" + + - name: "Admins" + type: allow + auth_key: admin:admin + kibana: + access: admin diff --git a/examples/mcp-server/docker-compose.override.yml b/examples/mcp-server/docker-compose.override.yml new file mode 100644 index 0000000..e55d905 --- /dev/null +++ b/examples/mcp-server/docker-compose.override.yml @@ -0,0 +1,45 @@ +services: + + # One-shot: mints an ES API key for the MCP server and writes it to the + # shared mcp-env volume as a .env file. + mcp-initializer: + build: + context: ${EXAMPLE_DIR}/images/mcp-initializer + dockerfile: Dockerfile + hostname: mcp-initializer + depends_on: + es-ror: + condition: service_healthy + volumes: + - mcp-env:/mcp-env + networks: + - ror-network + + # Elastic MCP server for Elasticsearch (elastic/mcp-server-elasticsearch), + # in HTTP mode so it can both use its own API key identity and forward a + # per-client Authorization header straight through to Elasticsearch/ROR. + mcp-server: + image: docker.elastic.co/mcp/elasticsearch:${MCP_SERVER_VERSION:-0.4.6} + hostname: mcp-server + # --container-mode makes the server listen on 0.0.0.0:8080 instead of the + # default 127.0.0.1:8080, which is required for the host port mapping + # below to work. + command: ["http", "--container-mode"] + # dotenvy (the config loader) searches upward from the working directory + # for a .env file, so pointing it at the volume mcp-initializer wrote to + # is enough to pick up ES_URL / ES_API_KEY / ES_SSL_SKIP_VERIFY. + working_dir: /mcp-env + depends_on: + mcp-initializer: + condition: service_completed_successfully + ports: + - "18080:8080" + volumes: + - mcp-env:/mcp-env:ro + networks: + - ror-network + # No healthcheck: the image (wolfi-base + busybox) ships neither curl nor + # wget, and nothing in this example depends on mcp-server being healthy. + +volumes: + mcp-env: diff --git a/examples/mcp-server/images/mcp-initializer/Dockerfile b/examples/mcp-server/images/mcp-initializer/Dockerfile new file mode 100644 index 0000000..91a190c --- /dev/null +++ b/examples/mcp-server/images/mcp-initializer/Dockerfile @@ -0,0 +1,9 @@ +FROM ubuntu:24.04 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl jq \ + && rm -rf /var/lib/apt/lists/* + +COPY entrypoint.sh /entrypoint.sh + +ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/examples/mcp-server/images/mcp-initializer/entrypoint.sh b/examples/mcp-server/images/mcp-initializer/entrypoint.sh new file mode 100755 index 0000000..2e4d9ed --- /dev/null +++ b/examples/mcp-server/images/mcp-initializer/entrypoint.sh @@ -0,0 +1,78 @@ +#!/bin/bash -x +# +# One-shot initializer: mints an Elasticsearch API key for the MCP server and +# writes it where the mcp-server container's `.env` loader (dotenvy) will find +# it. Runs once and exits so Docker marks it "completed"; mcp-server depends on +# this container completing successfully before it starts. + +ES_URL="https://es-ror:9200" + +# Wrapper around curl that prints the HTTP status code and fails loudly on +# non-2xx responses so the script exits at the first failing API call. +check_curl() { + local description="$1" + shift + + echo "Executing: $description" + + local response http_code body + response=$(curl -w "\n%{http_code}" "$@") + http_code=$(echo "$response" | tail -n1) + body=$(echo "$response" | sed '$d') + + echo "Response body: $body" + echo "HTTP Status: $http_code" + + if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then + echo "OK: $description (HTTP $http_code)" + RESPONSE_BODY="$body" + return 0 + else + echo "FAILED: $description (HTTP $http_code)" + return 1 + fi +} + +# es-ror is already known-healthy (depends_on: condition: service_healthy), +# but ROR's own settings reload can lag a couple of seconds behind the ES +# healthcheck, so retry the first call a few times before giving up. +ATTEMPTS=0 +until check_curl "Create ES API key for the MCP server" \ + -s -k -u admin:admin \ + -XPOST -H "Content-type: application/json" \ + "$ES_URL/_security/api_key" \ + -d '{"name":"mcp-server"}'; do + ATTEMPTS=$((ATTEMPTS + 1)) + if [ "$ATTEMPTS" -ge 10 ]; then + echo "Failed to create API key after $ATTEMPTS attempts, exiting..." + exit 1 + fi + echo "Retrying in 3s ($ATTEMPTS/10)..." + sleep 3 +done + +API_KEY_ENCODED=$(echo "$RESPONSE_BODY" | jq -r '.encoded') + +if [ -z "$API_KEY_ENCODED" ] || [ "$API_KEY_ENCODED" = "null" ]; then + echo "ERROR: Could not extract 'encoded' API key from response" + exit 1 +fi + +mkdir -p /mcp-env + +# ES_URL / ES_API_KEY / ES_SSL_SKIP_VERIFY are the config vars the +# elasticsearch-core-mcp-server binary reads (directly, or via this .env file +# loaded with dotenvy from its working directory). We skip TLS verification +# because the MCP server has no custom-CA option, only an on/off switch. +cat > /mcp-env/.env < /mcp-env/api-key + +echo "=== MCP server API key created ===" +echo "$RESPONSE_BODY" | jq '{id, name}' +echo "Wrote /mcp-env/.env" diff --git a/examples/mcp-server/scripts/init.sh b/examples/mcp-server/scripts/init.sh new file mode 100755 index 0000000..d363a36 --- /dev/null +++ b/examples/mcp-server/scripts/init.sh @@ -0,0 +1,21 @@ +#!/bin/bash -ex + +set -o pipefail + +source /usr/local/lib/ror-utils.sh + +# "logs-app-2026" (matching Elasticsearch's built-in "logs-*-*" index template) +# would fail with "matches a data-stream-only template" — a single hyphen avoids it. +createIndex "logs-2026" && generate_log_documents 50 | putDocument "logs-2026" + +createIndex "orders-2026" +putDocument "orders-2026" '{"order_id":"ORD-1001","customer":"Acme Corp","amount":4520.00,"status":"shipped","@timestamp":"2026-09-01T10:15:00Z"}' +putDocument "orders-2026" '{"order_id":"ORD-1002","customer":"Globex","amount":980.50,"status":"pending","@timestamp":"2026-09-05T14:32:00Z"}' +putDocument "orders-2026" '{"order_id":"ORD-1003","customer":"Initech","amount":12300.75,"status":"shipped","@timestamp":"2026-09-10T09:05:00Z"}' + +# NOT matched by the "MCP server" or "Analyst via MCP" ACL blocks (only +# logs-* and orders-* are) — proves the MCP identity cannot see it, via +# list_indices or a direct search. +createIndex "hr-salaries-2026" +putDocument "hr-salaries-2026" '{"employee":"Alice Smith","department":"Engineering","salary":128000,"@timestamp":"2026-01-01T00:00:00Z"}' +putDocument "hr-salaries-2026" '{"employee":"Bob Jones","department":"Sales","salary":95000,"@timestamp":"2026-01-01T00:00:00Z"}' diff --git a/examples/mcp-server/scripts/post-start.sh b/examples/mcp-server/scripts/post-start.sh new file mode 100644 index 0000000..bf4d3c8 --- /dev/null +++ b/examples/mcp-server/scripts/post-start.sh @@ -0,0 +1,22 @@ +echo -e "You can access ReadonlyREST Kibana here: https://localhost:15601" +echo -e "" +echo -e "Users:" +echo -e " admin:admin Kibana admin, can mint ES API keys" +echo -e " analyst:analyst Kibana + MCP access to logs-* only (Authorization passthrough)" +echo -e "" +echo -e "MCP server (Elasticsearch MCP, ROR-secured):" +echo -e " Endpoint: http://localhost:18080/mcp" +echo -e " Health: http://localhost:18080/ping" +echo -e "" +echo -e "Wire it into Claude Code, using the MCP server's own ES API key identity:" +echo -e " claude mcp add --transport http elasticsearch http://localhost:18080/mcp" +echo -e "" +echo -e "Or exercise per-user access via Authorization passthrough (analyst:analyst):" +echo -e " claude mcp add --transport http elasticsearch-analyst http://localhost:18080/mcp \\" +echo -e " --header \"Authorization: Basic \$(printf 'analyst:analyst' | base64)\"" +echo -e "" +echo -e "Smoke test without any MCP client (lists the 5 available tools):" +echo -e " curl -s http://localhost:18080/mcp -H 'Content-Type: application/json' \\" +echo -e " -H 'Accept: application/json, text/event-stream' \\" +echo -e " -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}'" +open https://localhost:15601 diff --git a/runner/utils/boot/extract_license_edition.sh b/runner/utils/boot/extract_license_edition.sh index ab6f638..b455631 100755 --- a/runner/utils/boot/extract_license_edition.sh +++ b/runner/utils/boot/extract_license_edition.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env sh +#!/usr/bin/env bash # Extract 'license.edition' from a ROR_ACTIVATION_KEY # Usage: extract_license_edition.sh set -eu From f65015fcce6697e3db667b99ebc4e94624833a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Fri, 18 Sep 2026 14:10:57 +0200 Subject: [PATCH 2/4] wip --- README.md | 2 +- examples/mcp-server/README.md | 76 +++++++----------- examples/mcp-server/confs/readonlyrest.yml | 23 +----- .../mcp-server/docker-compose.override.yml | 41 ++++------ .../images/mcp-initializer/Dockerfile | 9 --- .../images/mcp-initializer/entrypoint.sh | 78 ------------------- examples/mcp-server/scripts/init.sh | 4 +- examples/mcp-server/scripts/post-start.sh | 11 ++- 8 files changed, 55 insertions(+), 189 deletions(-) delete mode 100644 examples/mcp-server/images/mcp-initializer/Dockerfile delete mode 100755 examples/mcp-server/images/mcp-initializer/entrypoint.sh diff --git a/README.md b/README.md index b985923..d25b751 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ To stop and clean up: | [basic-multitenancy](examples/basic-multitenancy) | Multi-tenancy: isolated Kibana spaces and index access per user | | [kibana-reverse-proxy](examples/kibana-reverse-proxy) | Two Kibana nodes behind an Apache HTTPS reverse proxy with sticky-session load balancing, SSL termination, and a configurable base-path rewriting strategy | | [fleet](examples/fleet) | Full Elastic Fleet stack: Fleet Server, Elastic Agent with APM, and an instrumented Node.js service, all secured with ReadonlyREST | -| [mcp-server](examples/mcp-server) | The Elasticsearch MCP server (elastic/mcp-server-elasticsearch) authenticating with an ES API key behind ReadonlyREST | +| [mcp-server](examples/mcp-server) | The Elasticsearch MCP server (elastic/mcp-server-elasticsearch) behind ReadonlyREST, with each MCP client's own credentials passed through to ROR | ## Project structure diff --git a/examples/mcp-server/README.md b/examples/mcp-server/README.md index 927d2b4..2dc8e7b 100644 --- a/examples/mcp-server/README.md +++ b/examples/mcp-server/README.md @@ -1,8 +1,8 @@ # Elasticsearch MCP Server with ReadonlyREST -Runs the standalone [`elastic/mcp-server-elasticsearch`](https://github.com/elastic/mcp-server-elasticsearch) MCP server against a ReadonlyREST-secured Elasticsearch cluster, authenticating with a plain Elasticsearch API key. +Runs the standalone [`elastic/mcp-server-elasticsearch`](https://github.com/elastic/mcp-server-elasticsearch) MCP server against a ReadonlyREST-secured Elasticsearch cluster. The MCP server holds **no credentials of its own** — every MCP client sends its own `Authorization` header, the server forwards it to Elasticsearch, and ROR applies that user's ACL block. -> Kibana's **Agent Builder** MCP endpoint is a Kibana premium feature and out of scope for ROR (ROR secures Elasticsearch and Kibana's HTTP layer, not Kibana's internal plugin APIs). This example uses the standalone, Elasticsearch-only MCP server instead. Note that upstream has marked that server deprecated ("critical security updates only", superseded by Agent Builder) — it still works today and is the only ES-native MCP server ROR can secure. +> Kibana's **Agent Builder** MCP endpoint is a Kibana premium feature and out of scope for ROR. This example uses the standalone, Elasticsearch-only MCP server instead. Note that upstream has marked that server deprecated ("critical security updates only", superseded by Agent Builder) — it still works today. ## Architecture @@ -15,13 +15,9 @@ Runs the standalone [`elastic/mcp-server-elasticsearch`](https://github.com/elas │ ├── initializer (one-shot: seeds logs-*, orders-*, │ │ │ hr-salaries-* indices) │ │ │ │ -│ ├── mcp-initializer (one-shot: mints an ES API key, │ -│ │ writes it to the mcp-env volume) │ -│ │ │ │ └── mcp-server (elastic/mcp-server-elasticsearch, :8080) │ │ ▲ │ -│ │ Authorization: ApiKey ... (service identity) │ -│ │ Authorization: Basic ... (per-user passthrough)│ +│ │ Authorization: Basic ... (forwarded unchanged) │ │ MCP client (Claude Code, curl, ...) │ └──────────────────────────────────────────────────────────────┘ ``` @@ -35,65 +31,58 @@ Runs the standalone [`elastic/mcp-server-elasticsearch`](https://github.com/elas ## Users -| Username | Password | Role | -|-----------|-----------|----------------------------------------------------------------| -| `admin` | `admin` | Kibana admin; the only identity allowed to mint ES API keys | -| `analyst` | `analyst` | MCP access to `logs-*` only, via Authorization passthrough | +| Username | Password | Role | +|-----------|-----------|-----------------------------------------------------------------| +| `analyst` | `analyst` | Read-only MCP access to `logs-*` only | +| `admin` | `admin` | Kibana admin, unrestricted — useful as a contrast through MCP | ## How it works
Step 1 — Elasticsearch starts with ReadonlyREST -Elasticsearch starts with `xpack.security` TLS enabled and the ReadonlyREST plugin loaded. `readonlyrest.yml` defines four ACL blocks: +Elasticsearch starts with `xpack.security` TLS enabled and the ReadonlyREST plugin loaded. `readonlyrest.yml` defines three ACL blocks: - **KIBANA** — allows Kibana's internal user (`kibana:kibana`) unrestricted access for its own saved objects and system indices. -- **MCP server (ES API key)** — `token_authentication: {type: api-key}`. ROR asks Elasticsearch to validate the presented `ApiKey ` header; any key ES considers valid is logged in as the single fixed ROR user `mcp`, scoped to `logs-*` / `orders-*` and a read-only action set. -- **Analyst via MCP** — ordinary `auth_key: analyst:analyst` basic auth, scoped to `logs-*` only. This is what per-user access looks like once forwarded through the MCP server (see Step 4). -- **Admins** — `admin:admin`, unrestricted, so it can call `POST /_security/api_key` to mint keys for the MCP server. +- **Analyst via MCP** — ordinary `auth_key: analyst:analyst` basic auth, scoped to `logs-*` and a read-only action set. Nothing in this block is MCP-specific: it is a plain ROR user, and that is the whole point — the MCP server adds no identity of its own. +- **Admins** — `admin:admin`, unrestricted.
Step 2 — Demo data is seeded -The shared `initializer` container runs `scripts/init.sh`, creating `logs-2026` (50 generated log lines), `orders-2026` (a handful of orders), and `hr-salaries-2026` (salary data). Only the first two match the MCP identity's `indices` pattern — `hr-salaries-2026` is there specifically to prove ROR blocks it. +The shared `initializer` container runs `scripts/init.sh`, creating `logs-2026` (50 generated log lines), `orders-2026` (a handful of orders), and `hr-salaries-2026` (salary data). Only `logs-2026` is inside `analyst`'s `indices` scope — the other two are there to prove ROR blocks them. > The index is named `logs-2026`, not `logs-app-2026` — Elasticsearch ships a built-in `logs-*-*` index template that forces any two-hyphen `logs-`-prefixed name into a data stream, so a plain index create call 400s on a name with two segments after `logs-`.
-Step 3 — mcp-initializer mints an API key +Step 3 — mcp-server starts -Once Elasticsearch is healthy, `mcp-initializer` calls `POST /_security/api_key` as `admin:admin` (allowed because the Admins block is unrestricted), takes the response's `encoded` field — the ready-to-use `base64(id:api_key)` value — and writes it to a shared Docker volume as `/mcp-env/.env`: +Once `initializer` reports healthy (its `/tmp/init_done` healthcheck, which only passes after `init.sh` returns), `mcp-server` runs `docker.elastic.co/mcp/elasticsearch` in `http --container-mode` mode with only two settings: `ES_URL=https://es-ror:9200` and `ES_SSL_SKIP_VERIFY=true` (the MCP server has no custom-CA option, only an on/off switch, and this cluster uses a self-signed certificate). It listens on `:8080` (mapped to host `18080`), exposing `/mcp` (Streamable HTTP, no `initialize` call required first) and `/ping` (health check). -``` -ES_URL=https://es-ror:9200 -ES_API_KEY= -ES_SSL_SKIP_VERIFY=true -``` - -`ES_SSL_SKIP_VERIFY=true` is used because the MCP server has no custom-CA option — only an on/off switch — and this cluster uses a self-signed certificate. +No `ES_API_KEY` or username/password is configured, so in `http` mode the server has nothing to fall back on: it forwards the request's own `Authorization` header to Elasticsearch, and a client that sends none gets a ROR 401.
-
-Step 4 — mcp-server starts +## Connect an MCP client -`mcp-server` runs `docker.elastic.co/mcp/elasticsearch` in `http --container-mode` mode with its working directory pointed at the volume `mcp-initializer` wrote to; the binary's built-in `.env` loader (`dotenvy`) picks up the API key from there automatically. It listens on `:8080` (mapped to host `18080`), exposing `/mcp` (Streamable HTTP, no `initialize` call required first) and `/ping` (health check). - -In HTTP mode the server forwards the request's own `Authorization` header to Elasticsearch when present, overriding its configured API key — this is what makes Scenario B below possible. +```bash +claude mcp add --transport http elasticsearch-analyst http://localhost:18080/mcp \ + --header "Authorization: Basic $(printf 'analyst:analyst' | base64)" +``` -
+`analyst` can only see `logs-*`. Point a second connection at the same endpoint with `admin:admin` credentials and it sees everything — same server, same tools, different ROR block. ## Tool compatibility -All 5 tools tested against the MCP identity (`token_authentication`, scoped to `logs-*`/`orders-*`) and against `hr-salaries-2026` (out of scope). Verified against a live `./run.sh mcp-server` run: +All 5 tools were exercised against a live `./run.sh mcp-server` cluster, in scope (`logs-2026`) and out of scope (`hr-salaries-2026`): | Tool | ES request | ROR actions required | In scope (`logs-2026`) | Out of scope (`hr-salaries-2026`) | |----------------|--------------------------------------|------------------------------------------------------------------------------|--------|--------| -| `list_indices` | `GET /_cat/indices/` | `indices:monitor/stats`, `indices:monitor/settings/get`, `cluster:monitor/state` | ✅ returned, with doc counts | ✅ silently absent — a `logs-*,orders-*,hr-*` pattern only lists `logs-2026` and `orders-2026` | +| `list_indices` | `GET /_cat/indices/` | `indices:monitor/stats`, `indices:monitor/settings/get`, `cluster:monitor/state` | ✅ returned, with doc counts | ✅ silently absent — only in-scope indices are listed | | `get_mappings` | `GET //_mapping` | `indices:admin/mappings/get` | ✅ mapping returned | ✅ blocked — ES itself returns `index_not_found_exception` (404) | | `search` | `POST //_search` | `indices:data/read/search` | ✅ 53 hits | ✅ blocked — same 404 | | `esql` | `POST /_query` | `indices:data/read/esql` (+ `resolve_fields`, `compute`) | ✅ rows returned | ✅ blocked — surfaces as `400 Bad Request` instead (ES|QL validates the `FROM` target differently than the REST index APIs) | @@ -103,27 +92,20 @@ For every REST-style call above, ROR doesn't hand back a plain 403 for an index > **Testing blocked calls with `curl`:** this MCP server (`elastic/mcp-server-elasticsearch` 0.4.6) closes its SSE response stream right after a *successful* tool call, but leaves the stream open after an *error* result — the JSON-RPC error itself arrives instantly, but plain `curl` (as used in the smoke test below) keeps waiting for the connection to close and will hang until it hits its own timeout. This is a quirk of the upstream binary, unrelated to ROR — it happens for any ES-level error, ROR-caused or not. Real MCP clients aren't affected, since they resolve on the JSON-RPC `id` rather than on stream closure. If you're poking at a blocked index with `curl` yourself, add `--max-time 5`. -## API keys with ROR — what you get and what you don't - -- ROR validates the key **against Elasticsearch** on every request (`ApiKeyService.validateToken`, cached) — it never stores the key itself, so rotating or revoking keys through `_security/api_key` works exactly as it would without ROR. Same mechanism ROR already uses for [Elastic Fleet](https://docs.readonlyrest.com/elasticsearch/fleet). -- **Every valid API key resolves to the same ROR user** (`mcp`, here). An API key is a service identity to ROR, not a per-user identity — you cannot give key A and key B different index permissions with `token_authentication` alone. -- Any `role_descriptors` attached to the key at creation time are not consulted by ROR — ROR does its own authorization from `readonlyrest.yml` and never delegates to Elasticsearch's native security layer once the token is validated. -- For per-user access, run the MCP server in `http` mode (as this example does) and let each client send its own `Authorization` header — ROR sees it as ordinary basic auth or its own API key, unrelated to the server's configured identity. See the `analyst` user below. - -## Per-user access (Authorization passthrough) +## Why no service API key? -```bash -claude mcp add --transport http elasticsearch-analyst http://localhost:18080/mcp \ - --header "Authorization: Basic $(printf 'analyst:analyst' | base64)" -``` +The MCP server can also carry a credential of its own (`ES_API_KEY`), and ROR validates such keys with `token_authentication: {type: "api-key"}` — the same mechanism it uses for [Elastic Fleet](https://docs.readonlyrest.com/elasticsearch/fleet). This example deliberately doesn't: -`analyst` can only see `logs-*` — asking it to list or search `orders-*` or `hr-salaries-2026` is denied by ROR, independently of what the MCP server's own configured API key can reach. +- **Every valid API key resolves to the same ROR user.** An API key is a service identity to ROR, not a per-user identity — you cannot give key A and key B different index permissions with `token_authentication` alone. +- A configured key is also a *fallback*: any client that reaches the MCP port without an `Authorization` header would silently inherit the server's identity. With no key configured, unauthenticated clients get a 401 instead. +- Passthrough keeps ROR's audit log meaningful — each MCP call is attributed to the real user, not to one shared `mcp` account. ## What to explore - Ask the agent to list indices, then to search `hr-salaries-2026` directly — both are refused; check `runner/ror-cluster.log` for the ROR audit entries. -- Compare what the `elasticsearch` (service key) and `elasticsearch-analyst` (passthrough) MCP connections can each see. +- Add a second MCP connection with `admin:admin` and compare what each one can see. - Run an ES|QL query (`esql` tool) against `logs-2026` and against `hr-salaries-2026`. +- Drop the `Authorization` header entirely and watch ROR reject the call with a 401. ## How to run diff --git a/examples/mcp-server/confs/readonlyrest.yml b/examples/mcp-server/confs/readonlyrest.yml index bc80a7e..88130b2 100644 --- a/examples/mcp-server/confs/readonlyrest.yml +++ b/examples/mcp-server/confs/readonlyrest.yml @@ -10,25 +10,10 @@ readonlyrest: type: allow auth_key: kibana:kibana - # Scenario A: the MCP server's own service identity. - # ROR asks Elasticsearch to validate the API key (ApiKey - # header). Any key that ES considers valid resolves to the single ROR user - # "mcp" below — permissions come from this block, not from the key itself. - - name: "MCP server (ES API key)" - type: allow - token_authentication: - type: "api-key" - username: "mcp" - indices: ["logs-*", "orders-*"] - actions: - - "indices:data/read/*" - - "indices:admin/mappings/get" - - "indices:monitor/*" - - "cluster:monitor/*" - - # Scenario B: per-user identity via Authorization passthrough. When the MCP - # server runs in http mode it forwards the client's own Authorization header - # to Elasticsearch, so ordinary basic auth reaches ROR unchanged. + # Per-user identity via Authorization passthrough. When the MCP server runs + # in http mode it forwards the client's own Authorization header to + # Elasticsearch, so ordinary basic auth reaches ROR unchanged — the MCP + # server itself holds no credentials. - name: "Analyst via MCP" type: allow auth_key: analyst:analyst diff --git a/examples/mcp-server/docker-compose.override.yml b/examples/mcp-server/docker-compose.override.yml index e55d905..67d6da7 100644 --- a/examples/mcp-server/docker-compose.override.yml +++ b/examples/mcp-server/docker-compose.override.yml @@ -1,23 +1,8 @@ services: - # One-shot: mints an ES API key for the MCP server and writes it to the - # shared mcp-env volume as a .env file. - mcp-initializer: - build: - context: ${EXAMPLE_DIR}/images/mcp-initializer - dockerfile: Dockerfile - hostname: mcp-initializer - depends_on: - es-ror: - condition: service_healthy - volumes: - - mcp-env:/mcp-env - networks: - - ror-network - # Elastic MCP server for Elasticsearch (elastic/mcp-server-elasticsearch), - # in HTTP mode so it can both use its own API key identity and forward a - # per-client Authorization header straight through to Elasticsearch/ROR. + # in HTTP mode, with no credentials of its own: every client must send its + # own Authorization header, which the server forwards to Elasticsearch/ROR. mcp-server: image: docker.elastic.co/mcp/elasticsearch:${MCP_SERVER_VERSION:-0.4.6} hostname: mcp-server @@ -25,21 +10,21 @@ services: # default 127.0.0.1:8080, which is required for the host port mapping # below to work. command: ["http", "--container-mode"] - # dotenvy (the config loader) searches upward from the working directory - # for a .env file, so pointing it at the volume mcp-initializer wrote to - # is enough to pick up ES_URL / ES_API_KEY / ES_SSL_SKIP_VERIFY. - working_dir: /mcp-env + environment: + # No ES_API_KEY / ES_USERNAME: the server starts unauthenticated and + # relies entirely on the caller's forwarded Authorization header. + - ES_URL=https://es-ror:9200 + # The MCP server has no custom-CA option, only an on/off switch, and this + # cluster uses a self-signed certificate. + - ES_SSL_SKIP_VERIFY=true + # Waiting for the initializer (its /tmp/init_done healthcheck) means the + # demo indices exist by the time an MCP client can connect. depends_on: - mcp-initializer: - condition: service_completed_successfully + initializer: + condition: service_healthy ports: - "18080:8080" - volumes: - - mcp-env:/mcp-env:ro networks: - ror-network # No healthcheck: the image (wolfi-base + busybox) ships neither curl nor # wget, and nothing in this example depends on mcp-server being healthy. - -volumes: - mcp-env: diff --git a/examples/mcp-server/images/mcp-initializer/Dockerfile b/examples/mcp-server/images/mcp-initializer/Dockerfile deleted file mode 100644 index 91a190c..0000000 --- a/examples/mcp-server/images/mcp-initializer/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM ubuntu:24.04 - -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl jq \ - && rm -rf /var/lib/apt/lists/* - -COPY entrypoint.sh /entrypoint.sh - -ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/examples/mcp-server/images/mcp-initializer/entrypoint.sh b/examples/mcp-server/images/mcp-initializer/entrypoint.sh deleted file mode 100755 index 2e4d9ed..0000000 --- a/examples/mcp-server/images/mcp-initializer/entrypoint.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/bin/bash -x -# -# One-shot initializer: mints an Elasticsearch API key for the MCP server and -# writes it where the mcp-server container's `.env` loader (dotenvy) will find -# it. Runs once and exits so Docker marks it "completed"; mcp-server depends on -# this container completing successfully before it starts. - -ES_URL="https://es-ror:9200" - -# Wrapper around curl that prints the HTTP status code and fails loudly on -# non-2xx responses so the script exits at the first failing API call. -check_curl() { - local description="$1" - shift - - echo "Executing: $description" - - local response http_code body - response=$(curl -w "\n%{http_code}" "$@") - http_code=$(echo "$response" | tail -n1) - body=$(echo "$response" | sed '$d') - - echo "Response body: $body" - echo "HTTP Status: $http_code" - - if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then - echo "OK: $description (HTTP $http_code)" - RESPONSE_BODY="$body" - return 0 - else - echo "FAILED: $description (HTTP $http_code)" - return 1 - fi -} - -# es-ror is already known-healthy (depends_on: condition: service_healthy), -# but ROR's own settings reload can lag a couple of seconds behind the ES -# healthcheck, so retry the first call a few times before giving up. -ATTEMPTS=0 -until check_curl "Create ES API key for the MCP server" \ - -s -k -u admin:admin \ - -XPOST -H "Content-type: application/json" \ - "$ES_URL/_security/api_key" \ - -d '{"name":"mcp-server"}'; do - ATTEMPTS=$((ATTEMPTS + 1)) - if [ "$ATTEMPTS" -ge 10 ]; then - echo "Failed to create API key after $ATTEMPTS attempts, exiting..." - exit 1 - fi - echo "Retrying in 3s ($ATTEMPTS/10)..." - sleep 3 -done - -API_KEY_ENCODED=$(echo "$RESPONSE_BODY" | jq -r '.encoded') - -if [ -z "$API_KEY_ENCODED" ] || [ "$API_KEY_ENCODED" = "null" ]; then - echo "ERROR: Could not extract 'encoded' API key from response" - exit 1 -fi - -mkdir -p /mcp-env - -# ES_URL / ES_API_KEY / ES_SSL_SKIP_VERIFY are the config vars the -# elasticsearch-core-mcp-server binary reads (directly, or via this .env file -# loaded with dotenvy from its working directory). We skip TLS verification -# because the MCP server has no custom-CA option, only an on/off switch. -cat > /mcp-env/.env < /mcp-env/api-key - -echo "=== MCP server API key created ===" -echo "$RESPONSE_BODY" | jq '{id, name}' -echo "Wrote /mcp-env/.env" diff --git a/examples/mcp-server/scripts/init.sh b/examples/mcp-server/scripts/init.sh index d363a36..b23cc79 100755 --- a/examples/mcp-server/scripts/init.sh +++ b/examples/mcp-server/scripts/init.sh @@ -1,6 +1,8 @@ #!/bin/bash -ex -set -o pipefail +# `bash init.sh` (how cluster-initializer invokes this) ignores the shebang +# flags, so set them here. +set -exo pipefail source /usr/local/lib/ror-utils.sh diff --git a/examples/mcp-server/scripts/post-start.sh b/examples/mcp-server/scripts/post-start.sh index bf4d3c8..0347b90 100644 --- a/examples/mcp-server/scripts/post-start.sh +++ b/examples/mcp-server/scripts/post-start.sh @@ -1,17 +1,15 @@ echo -e "You can access ReadonlyREST Kibana here: https://localhost:15601" echo -e "" echo -e "Users:" -echo -e " admin:admin Kibana admin, can mint ES API keys" -echo -e " analyst:analyst Kibana + MCP access to logs-* only (Authorization passthrough)" +echo -e " admin:admin Kibana admin, unrestricted" +echo -e " analyst:analyst Kibana + MCP access to logs-* only" echo -e "" echo -e "MCP server (Elasticsearch MCP, ROR-secured):" echo -e " Endpoint: http://localhost:18080/mcp" echo -e " Health: http://localhost:18080/ping" echo -e "" -echo -e "Wire it into Claude Code, using the MCP server's own ES API key identity:" -echo -e " claude mcp add --transport http elasticsearch http://localhost:18080/mcp" -echo -e "" -echo -e "Or exercise per-user access via Authorization passthrough (analyst:analyst):" +echo -e "The MCP server has no credentials of its own - every client sends its own" +echo -e "Authorization header, which is forwarded straight to Elasticsearch/ROR:" echo -e " claude mcp add --transport http elasticsearch-analyst http://localhost:18080/mcp \\" echo -e " --header \"Authorization: Basic \$(printf 'analyst:analyst' | base64)\"" echo -e "" @@ -19,4 +17,5 @@ echo -e "Smoke test without any MCP client (lists the 5 available tools):" echo -e " curl -s http://localhost:18080/mcp -H 'Content-Type: application/json' \\" echo -e " -H 'Accept: application/json, text/event-stream' \\" echo -e " -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}'" + open https://localhost:15601 From 97c2896705b2bce06ff892bed93d676895df0b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Fri, 18 Sep 2026 15:19:32 +0200 Subject: [PATCH 3/4] wip --- .claude/plans/mcp-server-example.md | 332 ------------------ .gitignore | 1 + examples/mcp-server/.env | 11 +- examples/mcp-server/README.md | 107 ++++-- .../mcp-server/confs/opencode-analyst.json | 14 + examples/mcp-server/confs/opencode-hr.json | 14 + examples/mcp-server/confs/readonlyrest.yml | 31 +- .../mcp-server/docker-compose.override.yml | 60 ++++ .../mcp-server/images/opencode/Dockerfile | 29 ++ examples/mcp-server/scripts/init.sh | 7 +- examples/mcp-server/scripts/post-start.sh | 18 +- runner/utils/boot/extract_license_edition.sh | 2 +- 12 files changed, 244 insertions(+), 382 deletions(-) delete mode 100644 .claude/plans/mcp-server-example.md create mode 100644 examples/mcp-server/confs/opencode-analyst.json create mode 100644 examples/mcp-server/confs/opencode-hr.json create mode 100644 examples/mcp-server/images/opencode/Dockerfile diff --git a/.claude/plans/mcp-server-example.md b/.claude/plans/mcp-server-example.md deleted file mode 100644 index e5cfbc2..0000000 --- a/.claude/plans/mcp-server-example.md +++ /dev/null @@ -1,332 +0,0 @@ -# Plan: `examples/mcp-server` — Elasticsearch MCP Server behind ReadonlyREST - -**Audience:** the implementation agent. Everything below was verified against the actual sources -(ROR core Scala, elastic/mcp-server-elasticsearch Rust, ROR docs, this repo's runner) on 2026-09-17. -Where something could not be verified without running the stack, it is marked **VERIFY**. - -**Goal:** a runnable example (`./run.sh mcp-server`) proving that the Elasticsearch MCP server works -against a ROR-secured cluster with **Elasticsearch API-key authentication**, plus a README that -answers the customer question honestly, including the limitations. - ---- - -## 1. Background / why this shape - -The customer (Zdenek) asked about the **Kibana Agent Builder MCP endpoint**. That is a Kibana -premium feature, so ROR cannot support it — say so once in the README and move on. The plan targets -[`elastic/mcp-server-elasticsearch`](https://github.com/elastic/mcp-server-elasticsearch), which talks -to **Elasticsearch only** and therefore sits entirely behind ROR's ACL. - -Note for the README: that server carries an upstream deprecation notice ("critical security updates -only", superseded by Agent Builder). It still works and is the only ES-native MCP server that ROR can -secure today. State this plainly rather than hiding it. - -## 2. Verified facts about the MCP server (v0.4.6, Rust) - -| Fact | Source | -|---|---| -| Image `docker.elastic.co/mcp/elasticsearch`, multi-arch, entrypoint `/usr/local/bin/elasticsearch-core-mcp-server`, base `cgr.dev/chainguard/wolfi-base` | `Dockerfile` | -| Subcommands: `stdio` and `http`. HTTP listens on `:8080`, MCP endpoint `/mcp`, health `/ping` | `README.md`, `src/cli.rs` | -| Args can also come from the `CLI_ARGS` env var (whitespace-split) | `src/bin/elasticsearch-core-mcp-server.rs` | -| Config env: `ES_URL`, `ES_API_KEY`, `ES_USERNAME`/`ES_PASSWORD`, `ES_SSL_SKIP_VERIFY` | `elastic-mcp.json5`, `README.md` | -| **Reads a `.env` file** at startup via `dotenvy::dotenv()` (searches CWD upwards). Real env vars take precedence over `.env`. | `src/bin/elasticsearch-core-mcp-server.rs` | -| **In `http` mode the incoming `Authorization` header is forwarded to ES per request**, overriding the configured credentials. Accepts `ApiKey …`, `Basic …`, and strips a leading `Bearer ` prefix. | `src/servers/elasticsearch/mod.rs:81-103` | -| No custom-CA option — only `ES_SSL_SKIP_VERIFY=true` | `src/servers/elasticsearch/mod.rs` | -| 5 tools and the ES calls they make | `src/servers/elasticsearch/base_tools.rs` | - -Tool → ES request → ES action string: - -| Tool | Request | Action | -|---|---|---| -| `list_indices` | `GET /_cat/indices/?format=json&h=index,status,docs.count` | `indices:monitor/stats`, `indices:monitor/settings/get`, `cluster:monitor/state` | -| `get_mappings` | `GET //_mapping` | `indices:admin/mappings/get` | -| `search` | `POST //_search` | `indices:data/read/search` | -| `esql` | `POST /_query` | `indices:data/read/esql` (+ `…/resolve_fields`, `…/compute`) | -| `get_shards` | `GET /_cat/shards[/]?format=json` | `cluster:monitor/state`, `indices:monitor/stats` | - -## 3. Verified facts about ROR + API keys - -- `token_authentication` with `type: "api-key"` asks **Elasticsearch** to validate the key - (`ApiKeyService.validateToken`, result cached) and, on success, logs the request in as the - **single fixed `username` from the config** - (`core/.../blocks/rules/auth/TokenAuthenticationRule.scala`). -- The header prefix is **strictly `ApiKey`** (case-insensitive), header name defaults to - `Authorization`, overridable with `header:` - (`core/.../factory/decoders/rules/auth/TokenAuthenticationRuleDecoder.scala`, - `AuthorizationTokenPrefix.api = "ApiKey"`). This matches exactly what the MCP server sends. -- Requires ES ≥ 7.14 and `xpack.security.enabled: true` with HTTP TLS (API key service). - ROR ≥ **1.69.0** (that release added Fleet/API-key/service-token support). -- **Key limitation to document:** every valid ES API key maps to the *same* ROR user, so you cannot - give key A and key B different index permissions with `token_authentication` alone. API keys are a - *service identity*, not a per-user identity. Per-user ACLs need basic-auth passthrough - (see §5, scenario B) or one MCP server process per identity. -- ROR applies the ACL to ES|QL (1.71.0 also covers `LOOKUP JOIN`), and treats `/_cat/indices` as an - index-aware path (filtered by the `indices` rule, `PathValue.isCatIndicesPath`). `/_cat/shards` - has **no** special handling in ROR core → **VERIFY** what it returns for a restricted user, and - write the result in the README compatibility table. - -## 4. Deliverable: files to create - -``` -examples/mcp-server/ - .env # versions + ROR_MIN_LICENSE_EDITION=FREE - README.md # title + first paragraph are printed by run.sh - confs/elasticsearch.yml # copy of examples/fleet/confs/elasticsearch.yml (xpack security + TLS) - confs/kibana.yml # copy of examples/basic-multitenancy/confs/kibana.yml - confs/readonlyrest.yml # see §5 - docker-compose.override.yml # mcp-initializer + mcp-server services - images/mcp-initializer/ - Dockerfile # FROM ubuntu:24.04 + curl + jq (mirror examples/fleet/images/fleet-initializer) - entrypoint.sh # creates API keys, writes /mcp-env/.env - scripts/init.sh # seed demo indices (runs in the shared `initializer` container) - scripts/post-start.sh # print endpoints, users, and the `claude mcp add` one-liner -``` - -Do **not** add a `certs/` dir: the runner's ES/KBN images already bake in `ca.crt`/`elasticsearch.crt` -(`runner/images/es/Dockerfile-API`). The MCP server uses `ES_SSL_SKIP_VERIFY=true` because it has no -custom-CA option. - -Remember to add the example to the table in the root `README.md`. - -## 5. `confs/readonlyrest.yml` (starting point) - -```yaml -readonlyrest: - - audit: - enabled: true - outputs: [index] - - access_control_rules: - - - name: "KIBANA" - type: allow - auth_key: kibana:kibana - verbosity: error - - # Scenario A — the MCP server's own service identity. - # ROR asks ES to validate the API key; any valid key resolves to username "mcp". - - name: "MCP server (ES API key)" - type: allow - token_authentication: - type: "api-key" - username: "mcp" - indices: ["logs-*", "orders-*"] - actions: - - "indices:data/read/*" - - "indices:admin/mappings/get" - - "indices:monitor/*" - - "cluster:monitor/*" - - # Scenario B — per-user identity via Authorization passthrough (http mode). - - name: "Analyst via MCP" - type: allow - auth_key: analyst:analyst - indices: ["logs-*"] - actions: - - "indices:data/read/*" - - "indices:admin/mappings/get" - - "indices:monitor/*" - - "cluster:monitor/*" - - - name: "Admins" - type: allow - auth_key: admin:admin - kibana: - access: admin -``` - -Notes: -- **Do not copy the fleet example's `forbid` block for `cluster:admin/xpack/security/api_key/*`** — - the initializer needs `POST /_security/api_key`. If you want to show the forbid pattern, put it - *below* the Admins block so admin can still mint keys, and say why in the README. -- The `actions` allow-list is what makes the MCP identity genuinely read-only. Tune it against real - ACL log output (`runner/ror-cluster.log`) — start permissive, then tighten, then re-run all 5 tools. -- Scenario B is what answers "can different agents see different data?" — it works because ROR sees - ordinary basic auth. Two personas (`analyst`, `admin`) are enough; do not build a full multitenancy - demo here (that is `basic-multitenancy`, and it needs an ENT license). - -## 6. `docker-compose.override.yml` (starting point) - -```yaml -services: - - mcp-initializer: - build: - context: ${EXAMPLE_DIR}/images/mcp-initializer - dockerfile: Dockerfile - hostname: mcp-initializer - depends_on: - es-ror: - condition: service_healthy - volumes: - - mcp-env:/mcp-env - networks: - - ror-network - - mcp-server: - image: docker.elastic.co/mcp/elasticsearch:${MCP_SERVER_VERSION:-0.4.6} - hostname: mcp-server - command: ["http"] - working_dir: /mcp-env # dotenvy picks up /mcp-env/.env written by the initializer - depends_on: - mcp-initializer: - condition: service_completed_successfully - ports: - - "18080:8080" - volumes: - - mcp-env:/mcp-env:ro - networks: - - ror-network - healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ping | grep -q pong"] - interval: 10s - timeout: 5s - retries: 20 - start_period: 20s - -volumes: - mcp-env: -``` - -**Risks + fallbacks, in order of preference:** - -1. `working_dir` + `/mcp-env/.env` is the cleanest way to inject a *runtime-generated* API key - (the key value only exists after ES is up). **VERIFY** that dotenvy actually finds it; the image - has no `WORKDIR`, and `dotenv()` walks up from the CWD. -2. If that fails: build a thin wrapper image - `FROM docker.elastic.co/mcp/elasticsearch:0.4.6`, add an entrypoint shell script that reads - `/mcp-env/api-key`, exports `ES_API_KEY`, and `exec`s the binary — same pattern as - `examples/fleet/images/apm-agent/entrypoint.sh`. wolfi-base ships a shell. -3. If the healthcheck binary is missing in wolfi-base (no `curl`/`wget`), drop the healthcheck and - use `depends_on: service_started`, or healthcheck `mcp-server` from the initializer instead. - -Also **VERIFY**: `docker compose up --wait` (see `runner/run.sh`) tolerates the one-shot -`mcp-initializer`. The fleet example does the same thing, so it should be fine. - -## 7. `images/mcp-initializer/entrypoint.sh` — what it must do - -Model it on `examples/fleet/images/fleet-initializer/entrypoint.sh` (same `check_curl` wrapper, -same wait-loop, `#!/bin/bash -x`, fail loudly). - -1. Wait until `https://es-ror:9200/_cluster/health` answers as `admin:admin`. -2. Create the service API key: - `POST /_security/api_key -d '{"name":"mcp-server"}'` → take the **`encoded`** field (that is the - base64 `id:api_key` value the `ApiKey` scheme needs; if a build returns only `id`/`api_key`, - compute `base64(id:api_key)` yourself). -3. Write `/mcp-env/.env`: - ``` - ES_URL=https://es-ror:9200 - ES_API_KEY= - ES_SSL_SKIP_VERIFY=true - ``` -4. Also write the raw key to `/mcp-env/api-key` and echo it to stdout so `post-start.sh` and the - README's curl examples can use it. -5. Exit 0 (one-shot). - -**VERIFY while implementing:** that `POST /_security/api_key` actually succeeds through ROR as -`admin:admin`. ROR forwards it to ES and ES owns the key. The ROR Fleet guide documents Fleet Server -doing exactly this, so it should work; if it does not, fall back to creating the key via the -`kibana:kibana` block and note it. - -**Open question worth answering for the customer (cheap to test here):** create a second key with -restrictive `role_descriptors` and check whether ES enforces them under ROR. Expectation: **no** — -ROR does its own authorization and runs the request internally, so the key's privileges are ignored -and only the ROR block's `indices`/`actions` apply. Confirm and write the answer in the README. - -## 8. `scripts/init.sh` - -Runs in the shared `initializer` container (`ELASTICSEARCH_USER=kibana` by default, helpers in -`/usr/local/lib/ror-utils.sh`). Seed enough data that the tools return something interesting: - -```bash -source /usr/local/lib/ror-utils.sh -createIndex "logs-app-2026" && generate_log_documents 50 | putDocument "logs-app-2026" -createIndex "orders-2026" && putDocument "orders-2026" '{...}' # a few hand-written docs -createIndex "hr-salaries-2026" && putDocument "hr-salaries-2026" '{...}' # NOT in the MCP block → proves the ACL -``` - -`hr-salaries-*` is the money shot: ask the agent "list all indices" / "search hr-salaries" and it -cannot see it. Make the README call that out. - -## 9. `scripts/post-start.sh` - -Print, in this order: -1. Kibana URL `https://localhost:15601` and the users (`admin:admin`, `analyst:analyst`). -2. MCP endpoint `http://localhost:18080/mcp`, health `http://localhost:18080/ping`. -3. The exact wiring command for Claude Code: - `claude mcp add --transport http elasticsearch http://localhost:18080/mcp` - and the per-user variant that exercises passthrough: - `claude mcp add --transport http elasticsearch-analyst http://localhost:18080/mcp --header "Authorization: Basic $(printf 'analyst:analyst' | base64)"` -4. A copy-paste `curl` that lists tools, so the example can be checked without any MCP client: - ```bash - curl -s http://localhost:18080/mcp -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' - ``` - **VERIFY** the streamable-HTTP handshake actually accepts a bare `tools/list` without an - `initialize` first; if not, ship a tiny `scripts/mcp-smoke-test.sh` that does - `initialize` → `tools/list` → `tools/call list_indices` instead. That script is the example's - self-test and the thing to run in CI/by hand. - -Follow the existing convention: `open https://localhost:15601` at the end. - -## 10. README.md for the example - -Mirror the fleet README's shape: `# Title`, one description paragraph (both are printed by -`run.sh`), architecture box, ports table, users table, `
` step-by-step, "What to explore", -"How to run". Then add three sections that are specific to this example: - -1. **Tool compatibility table** — the 5 tools × (works / blocked / filtered) with the ROR actions - involved. Fill it from real runs, not from this plan. -2. **API keys with ROR — what you get and what you don't**: ROR validates keys against ES, never - stores them, so rotation is free; but all keys collapse to one ROR username, and any - `role_descriptors` on the key are (**VERIFY**) not enforced. Point at - https://docs.readonlyrest.com/elasticsearch/fleet for the same mechanism under Fleet. -3. **Per-user access** — the `Authorization` passthrough trick, with the `analyst` demo. - -Also state up front: Kibana Agent Builder's MCP endpoint is a Kibana premium feature and is out of -scope; this example uses the standalone Elasticsearch MCP server, which is upstream-deprecated. - -## 11. `.env` - -``` -ROR_MIN_LICENSE_EDITION=FREE - -ES_VERSION=9.3.3 -ROR_ES_PLUGIN_SOURCE=API -ROR_ES_VERSION=1.69.1 - -KBN_VERSION=9.3.3 -ROR_KBN_PLUGIN_SOURCE=API -ROR_KBN_VERSION=1.69.1 - -MCP_SERVER_VERSION=0.4.6 -``` - -Keep the same ES/ROR pins as the other examples (proven combination). ROR must be ≥ 1.69.0 for -`token_authentication: api-key`. FREE edition is enough — do not use Kibana multitenancy here. - -## 12. Definition of done - -1. `./run.sh mcp-server` comes up clean from scratch (`./clean.sh` first), no manual steps. -2. `curl http://localhost:18080/ping` → `pong`. -3. All 5 MCP tools exercised against the ROR-secured cluster; results recorded in the README table. -4. `hr-salaries-2026` is provably invisible to the MCP identity (both `list_indices` and a direct - `search`), and the denial shows up in the ROR audit index / `runner/ror-cluster.log`. -5. The `analyst` passthrough path returns a different index set than the service key path. -6. Root `README.md` example table updated. -7. README answers, in plain language: *does it work with ROR* (yes, with API keys), *what is not - supported* (Agent Builder; per-key ACL differentiation), *what was tested* (versions + table). - -## 13. Suggested reply to the customer (draft, for the human to send) - -- Agent Builder's MCP endpoint is a Kibana premium feature → not supportable with ROR. -- The standalone `elastic/mcp-server-elasticsearch` works with ROR: it authenticates with a normal ES - API key, and ROR validates that key through Elasticsearch (`token_authentication: type: api-key`), - the same mechanism we already ship for Elastic Fleet. -- Caveat: with ROR, an API key is a service identity — every valid key resolves to one ROR user, so - permissions come from the ROR block, not from the key's `role_descriptors`. For per-user access, - run the MCP server in HTTP mode and let each client pass its own `Authorization` header. -- We now ship a runnable example: `curl -sL …/quickstart.sh | bash -s mcp-server` (link the example - dir), including a table of which MCP tools work and which ROR rules they need. diff --git a/.gitignore b/.gitignore index e46529f..932cf4d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ **/subst-xpack runner/.env runner/.current-example +.claude \ No newline at end of file diff --git a/examples/mcp-server/.env b/examples/mcp-server/.env index e675fff..23b7c73 100644 --- a/examples/mcp-server/.env +++ b/examples/mcp-server/.env @@ -6,13 +6,16 @@ ROR_MIN_LICENSE_EDITION=FREE # API - download ReadonlyREST plugin from API (requires ROR_ES_VERSION / ROR_KBN_VERSION) # LOCAL_FILE - use a local plugin file (requires ROR_ES_FILE / ROR_KBN_FILE) -ES_VERSION=9.3.3 +ES_VERSION=9.5.4 ROR_ES_PLUGIN_SOURCE=API -ROR_ES_VERSION=1.69.1 +ROR_ES_VERSION=1.71.0 -KBN_VERSION=9.3.3 +KBN_VERSION=9.5.4 ROR_KBN_PLUGIN_SOURCE=API -ROR_KBN_VERSION=1.69.1 +ROR_KBN_VERSION=1.71.0 # Elastic MCP server for Elasticsearch (elastic/mcp-server-elasticsearch) MCP_SERVER_VERSION=0.4.6 + +# opencode CLI (https://opencode.ai), the MCP client used to drive the example +OPENCODE_VERSION=1.18.31 diff --git a/examples/mcp-server/README.md b/examples/mcp-server/README.md index 2dc8e7b..9d6e586 100644 --- a/examples/mcp-server/README.md +++ b/examples/mcp-server/README.md @@ -7,19 +7,23 @@ Runs the standalone [`elastic/mcp-server-elasticsearch`](https://github.com/elas ## Architecture ``` -┌──────────────────────────────────────────────────────────────┐ -│ Docker network (ror-network) │ +┌────────────────────────────────────────────────────────────────┐ +│ Docker network (ror-network) │ │ │ -│ es-ror ─────────────────────────────── kbn-ror │ +│ es-ror ─────────────────────────────── kbn-ror │ │ │ │ -│ ├── initializer (one-shot: seeds logs-*, orders-*, │ -│ │ hr-salaries-* indices) │ +│ ├── initializer (one-shot: seeds logs-*, orders-*, │ +│ │ hr-salaries-* indices) │ │ │ │ -│ └── mcp-server (elastic/mcp-server-elasticsearch, :8080) │ +│ └── mcp-server (elastic/mcp-server-elasticsearch, :8080) │ │ ▲ │ │ │ Authorization: Basic ... (forwarded unchanged) │ -│ MCP client (Claude Code, curl, ...) │ -└──────────────────────────────────────────────────────────────┘ +│ ├── opencode-analyst sends analyst:analyst │ +│ ├── opencode-hr sends hr:hr │ +│ │ │ +└──────────────┼─────────────────────────────────────────────────┘ + │ + MCP client on the host (any MCP client, curl, ...) ``` ## Exposed ports @@ -28,13 +32,17 @@ Runs the standalone [`elastic/mcp-server-elasticsearch`](https://github.com/elas |------------|-----------|---------------------------------------| | Kibana | 15601 | ReadonlyREST Kibana UI | | MCP server | 18080 | Elasticsearch MCP server (`/mcp`, `/ping`) | +| opencode | 1455 | OAuth redirect target for browser sign-ins inside `opencode-analyst` | ## Users -| Username | Password | Role | +| Username | Password | Reaches | |-----------|-----------|-----------------------------------------------------------------| -| `analyst` | `analyst` | Read-only MCP access to `logs-*` only | -| `admin` | `admin` | Kibana admin, unrestricted — useful as a contrast through MCP | +| `analyst` | `analyst` | `logs-*` and `orders-*`, through MCP and in Kibana (read-only) | +| `hr` | `hr` | `hr-salaries-*` and `orders-*`, same access in both | +| `admin` | `admin` | Kibana admin, unrestricted - useful as a contrast through MCP | + +`orders-*` is the shared ground; each user also has one index pattern the other cannot see at all — in Kibana's Discover exactly as through an MCP tool call, since it is one ACL either way. ## How it works @@ -44,7 +52,10 @@ Runs the standalone [`elastic/mcp-server-elasticsearch`](https://github.com/elas Elasticsearch starts with `xpack.security` TLS enabled and the ReadonlyREST plugin loaded. `readonlyrest.yml` defines three ACL blocks: - **KIBANA** — allows Kibana's internal user (`kibana:kibana`) unrestricted access for its own saved objects and system indices. -- **Analyst via MCP** — ordinary `auth_key: analyst:analyst` basic auth, scoped to `logs-*` and a read-only action set. Nothing in this block is MCP-specific: it is a plain ROR user, and that is the whole point — the MCP server adds no identity of its own. +- **Logs analyst via MCP** — ordinary `auth_key: analyst:analyst` basic auth, scoped to `logs-*` + `orders-*` with `kibana: {access: ro}`. +- **HR analyst via MCP** — the same shape with `auth_key: hr:hr`, scoped to `hr-salaries-*` + `orders-*`. Nothing in either block is MCP-specific: they are plain ROR users who can equally log into Kibana, and that is the whole point — the MCP server adds no identity of its own. + + `kibana: {access: ro}` does the work an explicit `actions:` list used to: it admits the read-only action set (which covers all five MCP tools) plus the Kibana-internal calls a browser session needs, and refuses writes — a saved-object `POST` as `analyst` comes back `403 Forbidden by ReadonlyREST`. An explicit `actions:` list instead of it would let the MCP tools through but leave Kibana unusable. - **Admins** — `admin:admin`, unrestricted.
@@ -52,7 +63,7 @@ Elasticsearch starts with `xpack.security` TLS enabled and the ReadonlyREST plug
Step 2 — Demo data is seeded -The shared `initializer` container runs `scripts/init.sh`, creating `logs-2026` (50 generated log lines), `orders-2026` (a handful of orders), and `hr-salaries-2026` (salary data). Only `logs-2026` is inside `analyst`'s `indices` scope — the other two are there to prove ROR blocks them. +The shared `initializer` container runs `scripts/init.sh`, creating `logs-2026` (50 generated log lines), `orders-2026` (a handful of orders), and `hr-salaries-2026` (salary data). `logs-2026` belongs to `analyst`, `hr-salaries-2026` to `hr`, and `orders-2026` to both — so the same MCP server answers two agents differently. > The index is named `logs-2026`, not `logs-app-2026` — Elasticsearch ships a built-in `logs-*-*` index template that forces any two-hyphen `logs-`-prefixed name into a data stream, so a plain index create call 400s on a name with two segments after `logs-`. @@ -63,22 +74,69 @@ The shared `initializer` container runs `scripts/init.sh`, creating `logs-2026` Once `initializer` reports healthy (its `/tmp/init_done` healthcheck, which only passes after `init.sh` returns), `mcp-server` runs `docker.elastic.co/mcp/elasticsearch` in `http --container-mode` mode with only two settings: `ES_URL=https://es-ror:9200` and `ES_SSL_SKIP_VERIFY=true` (the MCP server has no custom-CA option, only an on/off switch, and this cluster uses a self-signed certificate). It listens on `:8080` (mapped to host `18080`), exposing `/mcp` (Streamable HTTP, no `initialize` call required first) and `/ping` (health check). -No `ES_API_KEY` or username/password is configured, so in `http` mode the server has nothing to fall back on: it forwards the request's own `Authorization` header to Elasticsearch, and a client that sends none gets a ROR 401. +No `ES_API_KEY` or username/password is configured, so in `http` mode the server has nothing to fall back on: it forwards the request's own `Authorization` header to Elasticsearch, and a client that sends none is rejected by ROR with a 403.
-## Connect an MCP client +## Connect your own MCP client + +The endpoint is plain Streamable HTTP at `http://localhost:18080/mcp`; any MCP client works, as long as it can set a header. The generic shape is one remote server plus one `Authorization` header: + +```json +{ + "url": "http://localhost:18080/mcp", + "headers": { "Authorization": "Basic YW5hbHlzdDphbmFseXN0" } +} +``` + +The headers for the two MCP users: `Basic YW5hbHlzdDphbmFseXN0` (`analyst:analyst`) and `Basic aHI6aHI=` (`hr:hr`) — `printf 'analyst:analyst' | base64` if you want to check. + +Swap the header for `admin:admin`'s and the same server, with the same tools, returns everything — a different ROR block, not a different endpoint. The example also ships two preconfigured clients; see below. + +## Drive it with two agents + +The example ships two [opencode](https://opencode.ai) containers - a terminal agent used here purely as an MCP client, so the ACL can be exercised by a real agent loop instead of by `curl`. They are identical except for one line of config, the `Authorization` header they send: + +| Container | Sends | Reaches | +|---|---|---| +| `opencode-analyst` | `Basic YW5hbHlzdDphbmFseXN0` (`analyst:analyst`) | `logs-*`, `orders-*` | +| `opencode-hr` | `Basic aHI6aHI=` (`hr:hr`) | `hr-salaries-*`, `orders-*` | ```bash -claude mcp add --transport http elasticsearch-analyst http://localhost:18080/mcp \ - --header "Authorization: Basic $(printf 'analyst:analyst' | base64)" +docker exec -it opencode-analyst opencode # in one terminal +docker exec -it opencode-hr opencode # in another ``` -`analyst` can only see `logs-*`. Point a second connection at the same endpoint with `admin:admin` credentials and it sees everything — same server, same tools, different ROR block. +### Signing in (bring your own model) + +The containers hold no model credentials. Run `/connect` in the TUI and pick **any provider opencode supports** — the credentials land in the shared `opencode-auth` volume, so signing in once covers both agents, and `./run.sh`'s container recreation does not log you out. + +| Sign-in style | Works in the container | +|---|---| +| Paste an API key (any provider) | Yes, nothing else needed | +| Browser sign-in that redirects to `localhost:1455` (OpenAI, ...) | Yes — `opencode-analyst` publishes port 1455, so the redirect from your browser reaches the listener inside the container. Sign in from **that** container; the other one picks the credentials up from the shared volume. | +| Device-code flow (GitHub Copilot: open a URL, type a code) | Yes, no ports involved | +| Code-paste flow (Anthropic Claude Pro/Max: open a URL, paste the code back) | Yes — it needs the `opencode-anthropic-auth` plugin, which is baked into the image and declared under `plugin` in the configs | + +Only one container can claim host port 1455, which is why the browser flow has a designated container rather than working from either. + +### What to ask them + +Ask both agents the same three things and compare: + +1. *"which indices can you see?"* — `analyst` sees `logs-2026` and `orders-2026`; `hr` sees `hr-salaries-2026` and `orders-2026`. +2. *"summarise orders-2026"* — both succeed. Shared ground. +3. *"read hr-salaries-2026"* — `hr` reads it; `analyst` gets Elasticsearch's own `index_not_found_exception`. ROR rewrites an out-of-scope index name instead of returning a denial, so the agent usually reports the index does not exist rather than "I was blocked". Mirror it with *"read logs-2026"* to see `hr` blocked the same way. + +Then open Kibana as `admin:admin` and look at the ROR audit index: every tool call is attributed to `analyst` or `hr`, never to a shared service account. + +### Changing what an agent is + +Edit the header in `confs/opencode-analyst.json` / `confs/opencode-hr.json` and `docker restart opencode-analyst`. `admin:admin` is `Basic YWRtaW46YWRtaW4=` if you want an agent with no restrictions for contrast. `opencode mcp add` cannot do this from inside the container — it writes to the global config, which is mounted read-only on purpose so the example's configs do not drift. ## Tool compatibility -All 5 tools were exercised against a live `./run.sh mcp-server` cluster, in scope (`logs-2026`) and out of scope (`hr-salaries-2026`): +All 5 tools were exercised against a live `./run.sh mcp-server` cluster as `analyst`, in scope (`logs-2026`) and out of scope (`hr-salaries-2026`): | Tool | ES request | ROR actions required | In scope (`logs-2026`) | Out of scope (`hr-salaries-2026`) | |----------------|--------------------------------------|------------------------------------------------------------------------------|--------|--------| @@ -97,15 +155,16 @@ For every REST-style call above, ROR doesn't hand back a plain 403 for an index The MCP server can also carry a credential of its own (`ES_API_KEY`), and ROR validates such keys with `token_authentication: {type: "api-key"}` — the same mechanism it uses for [Elastic Fleet](https://docs.readonlyrest.com/elasticsearch/fleet). This example deliberately doesn't: - **Every valid API key resolves to the same ROR user.** An API key is a service identity to ROR, not a per-user identity — you cannot give key A and key B different index permissions with `token_authentication` alone. -- A configured key is also a *fallback*: any client that reaches the MCP port without an `Authorization` header would silently inherit the server's identity. With no key configured, unauthenticated clients get a 401 instead. +- A configured key is also a *fallback*: any client that reaches the MCP port without an `Authorization` header would silently inherit the server's identity. With no key configured, unauthenticated clients get a 403 instead. - Passthrough keeps ROR's audit log meaningful — each MCP call is attributed to the real user, not to one shared `mcp` account. ## What to explore -- Ask the agent to list indices, then to search `hr-salaries-2026` directly — both are refused; check `runner/ror-cluster.log` for the ROR audit entries. -- Add a second MCP connection with `admin:admin` and compare what each one can see. -- Run an ES|QL query (`esql` tool) against `logs-2026` and against `hr-salaries-2026`. -- Drop the `Authorization` header entirely and watch ROR reject the call with a 401. +- Ask `opencode-analyst` to read `hr-salaries-2026` and `opencode-hr` to read `logs-2026` — both are refused, each for its own index; check the ROR audit index for the two identities. +- Log into Kibana as `analyst` and again as `hr`: the same ACL that shapes the agents' tool calls shapes Discover's index list. Writes are refused (`ro`), so saving a search fails on purpose. +- Point a third MCP connection at the same endpoint with `admin:admin` and compare what it sees. +- Run an ES|QL query (`esql` tool) against an in-scope and an out-of-scope index and note the different error shape (400 rather than 404). +- Drop the `Authorization` header entirely and watch ROR reject the call with a 403. ## How to run diff --git a/examples/mcp-server/confs/opencode-analyst.json b/examples/mcp-server/confs/opencode-analyst.json new file mode 100644 index 0000000..c3951b5 --- /dev/null +++ b/examples/mcp-server/confs/opencode-analyst.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-anthropic-auth"], + "mcp": { + "elasticsearch": { + "type": "remote", + "url": "http://mcp-server:8080/mcp", + "enabled": true, + "headers": { + "Authorization": "Basic YW5hbHlzdDphbmFseXN0" + } + } + } +} diff --git a/examples/mcp-server/confs/opencode-hr.json b/examples/mcp-server/confs/opencode-hr.json new file mode 100644 index 0000000..71987a3 --- /dev/null +++ b/examples/mcp-server/confs/opencode-hr.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-anthropic-auth"], + "mcp": { + "elasticsearch": { + "type": "remote", + "url": "http://mcp-server:8080/mcp", + "enabled": true, + "headers": { + "Authorization": "Basic aHI6aHI=" + } + } + } +} diff --git a/examples/mcp-server/confs/readonlyrest.yml b/examples/mcp-server/confs/readonlyrest.yml index 88130b2..acd943e 100644 --- a/examples/mcp-server/confs/readonlyrest.yml +++ b/examples/mcp-server/confs/readonlyrest.yml @@ -10,22 +10,25 @@ readonlyrest: type: allow auth_key: kibana:kibana - # Per-user identity via Authorization passthrough. When the MCP server runs - # in http mode it forwards the client's own Authorization header to - # Elasticsearch, so ordinary basic auth reaches ROR unchanged — the MCP - # server itself holds no credentials. - - name: "Analyst via MCP" - type: allow - auth_key: analyst:analyst - indices: ["logs-*"] - actions: - - "indices:data/read/*" - - "indices:admin/mappings/get" - - "indices:monitor/*" - - "cluster:monitor/*" - - name: "Admins" type: allow auth_key: admin:admin kibana: access: admin + + # Two MCP users, each with a private index pattern and one they share: + # whatever an agent is told, ROR is what decides which of the two it is + # talking as, and what that user can reach. + - name: "Logs analyst via MCP" + type: allow + auth_key: analyst:analyst + indices: ["logs-*", "orders-*"] + kibana: + access: ro + + - name: "HR analyst via MCP" + type: allow + auth_key: hr:hr + indices: ["hr-salaries-*", "orders-*"] + kibana: + access: ro diff --git a/examples/mcp-server/docker-compose.override.yml b/examples/mcp-server/docker-compose.override.yml index 67d6da7..1246b56 100644 --- a/examples/mcp-server/docker-compose.override.yml +++ b/examples/mcp-server/docker-compose.override.yml @@ -28,3 +28,63 @@ services: - ror-network # No healthcheck: the image (wolfi-base + busybox) ships neither curl nor # wget, and nothing in this example depends on mcp-server being healthy. + + # Two opencode (https://opencode.ai) containers, one per MCP user. opencode is + # a terminal agent used here purely as an MCP client, so the ACL can be + # exercised by a real agent loop instead of by curl. Each container mounts a + # config that differs in exactly one thing - the Authorization header it sends + # to the MCP server - which is the whole identity story of this example. + # + # The agent is bring-your-own-model: `/connect` inside the TUI signs in with + # whichever provider the user already has. Both containers share the + # opencode-auth volume, so signing in once covers both agents. + opencode-analyst: + build: + context: ${EXAMPLE_DIR}/images/opencode + dockerfile: Dockerfile + args: + OPENCODE_VERSION: ${OPENCODE_VERSION:-OPENCODE_VERSION_NOT_CONFIGURED} + container_name: opencode-analyst + hostname: opencode-analyst + # Keeps a usable TTY for `docker exec -it`. + stdin_open: true + tty: true + depends_on: + mcp-server: + condition: service_started + ports: + # Providers that sign in through the browser (OpenAI, GitHub Copilot, ...) + # redirect to http://localhost:1455/auth/callback. opencode listens on + # [::]:1455 inside the container, so publishing the port is what lets the + # redirect from the host's browser actually arrive. Only this container + # publishes it - the host port can only be claimed once, and the shared + # auth volume means one sign-in is enough for both agents. + - "1455:1455" + volumes: + - ${EXAMPLE_DIR}/confs/opencode-analyst.json:/root/.config/opencode/opencode.json:ro + # `./run.sh` recreates containers, so the sign-in has to outlive them. + - opencode-auth:/root/.local/share/opencode + networks: + - ror-network + + opencode-hr: + build: + context: ${EXAMPLE_DIR}/images/opencode + dockerfile: Dockerfile + args: + OPENCODE_VERSION: ${OPENCODE_VERSION:-OPENCODE_VERSION_NOT_CONFIGURED} + container_name: opencode-hr + hostname: opencode-hr + stdin_open: true + tty: true + depends_on: + mcp-server: + condition: service_started + volumes: + - ${EXAMPLE_DIR}/confs/opencode-hr.json:/root/.config/opencode/opencode.json:ro + - opencode-auth:/root/.local/share/opencode + networks: + - ror-network + +volumes: + opencode-auth: diff --git a/examples/mcp-server/images/opencode/Dockerfile b/examples/mcp-server/images/opencode/Dockerfile new file mode 100644 index 0000000..ed70550 --- /dev/null +++ b/examples/mcp-server/images/opencode/Dockerfile @@ -0,0 +1,29 @@ +FROM node:22-bookworm-slim + +ARG OPENCODE_VERSION=OPENCODE_VERSION_NOT_CONFIGURED + +# git: opencode treats its working directory as a project and shells out to git. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && npm i -g "opencode-ai@${OPENCODE_VERSION}" + +# The Claude Pro/Max sign-in lives in an opencode plugin that is fetched from +# npm the first time it is needed. `opencode models` is the cheapest command +# that triggers that install, so the plugin is baked into the image instead of +# being downloaded while the user waits at the login prompt. The config written +# here is only the prewarm trigger - at runtime the example mounts its own. +RUN mkdir -p /root/.config/opencode \ + && echo '{"plugin":["opencode-anthropic-auth"]}' > /root/.config/opencode/opencode.json \ + && opencode models > /dev/null \ + && rm /root/.config/opencode/opencode.json + +ENV TERM=xterm-256color + +# An empty working directory keeps the agent pointed at the Elasticsearch MCP +# tools rather than at files. +WORKDIR /workspace + +# The container only has to stay up - the user attaches to it with +# `docker exec -it opencode opencode`. +CMD ["sleep", "infinity"] diff --git a/examples/mcp-server/scripts/init.sh b/examples/mcp-server/scripts/init.sh index b23cc79..1dcae6a 100755 --- a/examples/mcp-server/scripts/init.sh +++ b/examples/mcp-server/scripts/init.sh @@ -8,16 +8,17 @@ source /usr/local/lib/ror-utils.sh # "logs-app-2026" (matching Elasticsearch's built-in "logs-*-*" index template) # would fail with "matches a data-stream-only template" — a single hyphen avoids it. +# "analyst" reads this one; "hr" cannot see it. createIndex "logs-2026" && generate_log_documents 50 | putDocument "logs-2026" +# The index both MCP users share, so the two agents have common ground. createIndex "orders-2026" putDocument "orders-2026" '{"order_id":"ORD-1001","customer":"Acme Corp","amount":4520.00,"status":"shipped","@timestamp":"2026-09-01T10:15:00Z"}' putDocument "orders-2026" '{"order_id":"ORD-1002","customer":"Globex","amount":980.50,"status":"pending","@timestamp":"2026-09-05T14:32:00Z"}' putDocument "orders-2026" '{"order_id":"ORD-1003","customer":"Initech","amount":12300.75,"status":"shipped","@timestamp":"2026-09-10T09:05:00Z"}' -# NOT matched by the "MCP server" or "Analyst via MCP" ACL blocks (only -# logs-* and orders-* are) — proves the MCP identity cannot see it, via -# list_indices or a direct search. +# "hr" reads this one, "analyst" cannot see it at all - neither through +# list_indices nor through a direct search. createIndex "hr-salaries-2026" putDocument "hr-salaries-2026" '{"employee":"Alice Smith","department":"Engineering","salary":128000,"@timestamp":"2026-01-01T00:00:00Z"}' putDocument "hr-salaries-2026" '{"employee":"Bob Jones","department":"Sales","salary":95000,"@timestamp":"2026-01-01T00:00:00Z"}' diff --git a/examples/mcp-server/scripts/post-start.sh b/examples/mcp-server/scripts/post-start.sh index 0347b90..3eb3025 100644 --- a/examples/mcp-server/scripts/post-start.sh +++ b/examples/mcp-server/scripts/post-start.sh @@ -2,16 +2,26 @@ echo -e "You can access ReadonlyREST Kibana here: https://localhost:15601" echo -e "" echo -e "Users:" echo -e " admin:admin Kibana admin, unrestricted" -echo -e " analyst:analyst Kibana + MCP access to logs-* only" +echo -e " analyst:analyst MCP + Kibana (read-only): logs-* and orders-*" +echo -e " hr:hr MCP + Kibana (read-only): hr-salaries-* and orders-*" echo -e "" echo -e "MCP server (Elasticsearch MCP, ROR-secured):" echo -e " Endpoint: http://localhost:18080/mcp" echo -e " Health: http://localhost:18080/ping" echo -e "" echo -e "The MCP server has no credentials of its own - every client sends its own" -echo -e "Authorization header, which is forwarded straight to Elasticsearch/ROR:" -echo -e " claude mcp add --transport http elasticsearch-analyst http://localhost:18080/mcp \\" -echo -e " --header \"Authorization: Basic \$(printf 'analyst:analyst' | base64)\"" +echo -e "Authorization header, which is forwarded straight to Elasticsearch/ROR." +echo -e "Point any MCP client at the endpoint with one of these headers:" +echo -e " Authorization: Basic YW5hbHlzdDphbmFseXN0 # analyst:analyst" +echo -e " Authorization: Basic aHI6aHI= # hr:hr" +echo -e "" +echo -e "Two agents, one per MCP user (opencode, already wired to the MCP server):" +echo -e " docker exec -it opencode-analyst opencode # sends analyst:analyst" +echo -e " docker exec -it opencode-hr opencode # sends hr:hr" +echo -e "" +echo -e " First time: run /connect in the TUI and sign in with whichever provider" +echo -e " you use - API key, or a browser/subscription login. One sign-in covers" +echo -e " both containers. Then ask each agent: \"which indices can you see?\"" echo -e "" echo -e "Smoke test without any MCP client (lists the 5 available tools):" echo -e " curl -s http://localhost:18080/mcp -H 'Content-Type: application/json' \\" diff --git a/runner/utils/boot/extract_license_edition.sh b/runner/utils/boot/extract_license_edition.sh index b455631..ab6f638 100755 --- a/runner/utils/boot/extract_license_edition.sh +++ b/runner/utils/boot/extract_license_edition.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh # Extract 'license.edition' from a ROR_ACTIVATION_KEY # Usage: extract_license_edition.sh set -eu From 052c94aaa05895d0ea11662e2fcadb3e7f094dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Sat, 19 Sep 2026 10:55:00 +0200 Subject: [PATCH 4/4] review fixes --- .gitignore | 2 +- examples/mcp-server/confs/opencode-analyst.json | 2 +- examples/mcp-server/confs/opencode-hr.json | 2 +- examples/mcp-server/confs/readonlyrest.yml | 1 + examples/mcp-server/docker-compose.override.yml | 2 +- examples/mcp-server/images/opencode/Dockerfile | 2 +- 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 932cf4d..23132e3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ **/subst-xpack runner/.env runner/.current-example -.claude \ No newline at end of file +.claude diff --git a/examples/mcp-server/confs/opencode-analyst.json b/examples/mcp-server/confs/opencode-analyst.json index c3951b5..a04e267 100644 --- a/examples/mcp-server/confs/opencode-analyst.json +++ b/examples/mcp-server/confs/opencode-analyst.json @@ -1,6 +1,6 @@ { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-anthropic-auth"], + "plugin": ["opencode-anthropic-auth@0.0.13"], "mcp": { "elasticsearch": { "type": "remote", diff --git a/examples/mcp-server/confs/opencode-hr.json b/examples/mcp-server/confs/opencode-hr.json index 71987a3..402f453 100644 --- a/examples/mcp-server/confs/opencode-hr.json +++ b/examples/mcp-server/confs/opencode-hr.json @@ -1,6 +1,6 @@ { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-anthropic-auth"], + "plugin": ["opencode-anthropic-auth@0.0.13"], "mcp": { "elasticsearch": { "type": "remote", diff --git a/examples/mcp-server/confs/readonlyrest.yml b/examples/mcp-server/confs/readonlyrest.yml index acd943e..2aa5c5d 100644 --- a/examples/mcp-server/confs/readonlyrest.yml +++ b/examples/mcp-server/confs/readonlyrest.yml @@ -9,6 +9,7 @@ readonlyrest: - name: "KIBANA" type: allow auth_key: kibana:kibana + verbosity: error - name: "Admins" type: allow diff --git a/examples/mcp-server/docker-compose.override.yml b/examples/mcp-server/docker-compose.override.yml index 1246b56..c4c7a6c 100644 --- a/examples/mcp-server/docker-compose.override.yml +++ b/examples/mcp-server/docker-compose.override.yml @@ -59,7 +59,7 @@ services: # redirect from the host's browser actually arrive. Only this container # publishes it - the host port can only be claimed once, and the shared # auth volume means one sign-in is enough for both agents. - - "1455:1455" + - "127.0.0.1:1455:1455" volumes: - ${EXAMPLE_DIR}/confs/opencode-analyst.json:/root/.config/opencode/opencode.json:ro # `./run.sh` recreates containers, so the sign-in has to outlive them. diff --git a/examples/mcp-server/images/opencode/Dockerfile b/examples/mcp-server/images/opencode/Dockerfile index ed70550..769ab52 100644 --- a/examples/mcp-server/images/opencode/Dockerfile +++ b/examples/mcp-server/images/opencode/Dockerfile @@ -14,7 +14,7 @@ RUN apt-get update \ # being downloaded while the user waits at the login prompt. The config written # here is only the prewarm trigger - at runtime the example mounts its own. RUN mkdir -p /root/.config/opencode \ - && echo '{"plugin":["opencode-anthropic-auth"]}' > /root/.config/opencode/opencode.json \ + && echo '{"plugin":["opencode-anthropic-auth@0.0.13"]}' > /root/.config/opencode/opencode.json \ && opencode models > /dev/null \ && rm /root/.config/opencode/opencode.json