diff --git a/.gitignore b/.gitignore index e46529f..23132e3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ **/subst-xpack runner/.env runner/.current-example +.claude diff --git a/README.md b/README.md index 422f0ef..d25b751 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) behind ReadonlyREST, with each MCP client's own credentials passed through to ROR | ## Project structure diff --git a/examples/mcp-server/.env b/examples/mcp-server/.env new file mode 100644 index 0000000..23b7c73 --- /dev/null +++ b/examples/mcp-server/.env @@ -0,0 +1,21 @@ +# 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.5.4 +ROR_ES_PLUGIN_SOURCE=API +ROR_ES_VERSION=1.71.0 + +KBN_VERSION=9.5.4 +ROR_KBN_PLUGIN_SOURCE=API +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 new file mode 100644 index 0000000..9d6e586 --- /dev/null +++ b/examples/mcp-server/README.md @@ -0,0 +1,173 @@ +# 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. 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. 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 + +``` +┌────────────────────────────────────────────────────────────────┐ +│ Docker network (ror-network) │ +│ │ +│ es-ror ─────────────────────────────── kbn-ror │ +│ │ │ +│ ├── initializer (one-shot: seeds logs-*, orders-*, │ +│ │ hr-salaries-* indices) │ +│ │ │ +│ └── mcp-server (elastic/mcp-server-elasticsearch, :8080) │ +│ ▲ │ +│ │ Authorization: Basic ... (forwarded unchanged) │ +│ ├── opencode-analyst sends analyst:analyst │ +│ ├── opencode-hr sends hr:hr │ +│ │ │ +└──────────────┼─────────────────────────────────────────────────┘ + │ + MCP client on the host (any MCP client, curl, ...) +``` + +## Exposed ports + +| Service | Host port | Description | +|------------|-----------|---------------------------------------| +| 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 | Reaches | +|-----------|-----------|-----------------------------------------------------------------| +| `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 + +
+Step 1 — Elasticsearch starts with ReadonlyREST + +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. +- **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. + +
+ +
+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). `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-`. + +
+ +
+Step 3 — mcp-server starts + +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 is rejected by ROR with a 403. + +
+ +## 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 +docker exec -it opencode-analyst opencode # in one terminal +docker exec -it opencode-hr opencode # in another +``` + +### 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 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`) | +|----------------|--------------------------------------|------------------------------------------------------------------------------|--------|--------| +| `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) | +| `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`. + +## Why no service API key? + +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 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 `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 + +```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/opencode-analyst.json b/examples/mcp-server/confs/opencode-analyst.json new file mode 100644 index 0000000..a04e267 --- /dev/null +++ b/examples/mcp-server/confs/opencode-analyst.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-anthropic-auth@0.0.13"], + "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..402f453 --- /dev/null +++ b/examples/mcp-server/confs/opencode-hr.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-anthropic-auth@0.0.13"], + "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 new file mode 100644 index 0000000..2aa5c5d --- /dev/null +++ b/examples/mcp-server/confs/readonlyrest.yml @@ -0,0 +1,35 @@ +readonlyrest: + + audit: + enabled: true + outputs: [index] + + access_control_rules: + + - name: "KIBANA" + type: allow + auth_key: kibana:kibana + verbosity: error + + - 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 new file mode 100644 index 0000000..c4c7a6c --- /dev/null +++ b/examples/mcp-server/docker-compose.override.yml @@ -0,0 +1,90 @@ +services: + + # Elastic MCP server for Elasticsearch (elastic/mcp-server-elasticsearch), + # 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 + # --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"] + 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: + initializer: + condition: service_healthy + ports: + - "18080:8080" + 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. + + # 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. + - "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. + - 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..769ab52 --- /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@0.0.13"]}' > /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 new file mode 100755 index 0000000..1dcae6a --- /dev/null +++ b/examples/mcp-server/scripts/init.sh @@ -0,0 +1,24 @@ +#!/bin/bash -ex + +# `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 + +# "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"}' + +# "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 new file mode 100644 index 0000000..3eb3025 --- /dev/null +++ b/examples/mcp-server/scripts/post-start.sh @@ -0,0 +1,31 @@ +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 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 "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' \\" +echo -e " -H 'Accept: application/json, text/event-stream' \\" +echo -e " -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}'" + +open https://localhost:15601