Skip to main content
Glama
rajanmishra6

OpenSearch MCP Server

by rajanmishra6
README.md
# OpenSearch MCP Server

A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that lets AI assistants such as Claude,
ChatGPT and Cursor search and inspect an OpenSearch cluster.

It runs in two modes:

* **Local (stdio):** the AI app starts the server as a child process on your machine. No network exposure and no
  token needed.
* **Remote (Streamable HTTP):** one shared server (production: `https://opensearch-mcp.monotype.com`) that clients
  reach over HTTPS. Every request must carry a valid API token or an OAuth token derived from one.

## Tools

| Area | Tool | What it does |
| --- | --- | --- |
| Indices | `list_indices` | List all indices |
| | `get_mapping` | Mapping of an index |
| | `get_settings` | Settings of an index |
| Documents | `search_documents` | Search an index with Query DSL (default size 20) |
| Cluster | `get_cluster_health` | Cluster health |
| | `get_cluster_stats` | Cluster-wide statistics |
| Dashboards | `list_index_patterns` | Index patterns stored in `.kibana` |
| | `generate_discover_url` | Build an OpenSearch Dashboards Discover link for a query |
| Admin | `get_ism_policies` | Index State Management policies |
| | `get_index_templates` | Index templates and their shard counts |
| | `get_shard_allocation` | Shard distribution across nodes |
| | `get_hot_threads` | Hot threads on all nodes |
| | `get_tasks` | Running cluster tasks |
| | `get_recovery_status` | Shard recovery progress and ETA |

All tools are read-only.

## Repository layout

This is the **code repository**. Deployment lives in a separate **build repository** (Helm chart, image
Dockerfile, Jenkins pipeline). Jenkins checks out both and builds the image from this repository's `src/`.

```
src/opensearch_mcp_server/
  server.py            entry point (stdio, or Streamable HTTP with --transport streamable-http)
  es_client.py         OpenSearch client (reads OPENSEARCH_* variables)
  tools/               the MCP tools listed above
  auth/
    store.py           SQLite store for API tokens and OAuth grants (hashes only)
    provider.py        OAuth 2.1 authorization server backed by API tokens
    http_app.py        authenticated Streamable HTTP app (discovery, sign-in page, /mcp)
    cli.py             opensearch-mcp-token command
tests/                 pytest suite, including a fake OpenSearch server
```

The build repository's image replaces `server.py` with its own `k8s/api_image/app/server.py` and installs with its
own `pyproject.toml` and `constraints.txt`. That version uses the official MCP SDK's `FastMCP`, serves the endpoint
under a secret `MCP_PATH_TOKEN` prefix and adds a Host-header check. It still uses the `tools/` and `auth/` packages
from this repository. When you change tools or authentication here, the next pipeline run picks them up. If you add
a dependency, add it to the build repository's `app/pyproject.toml` and `constraints.txt` too.

## Requirements

* Python 3.10 or newer (the production image uses 3.11)
* [uv](https://docs.astral.sh/uv/) for local development
* Network access to an OpenSearch cluster and credentials for it

```bash
git clone <code-repo-url> opensearch-mcp-server
cd opensearch-mcp-server
uv sync
```

## Configuration

Settings come from environment variables. For local work, copy `.env-template` to `.env` (git-ignored); the server
loads it automatically.

| Variable | Default | Purpose |
| --- | --- | --- |
| `OPENSEARCH_HOST` | | OpenSearch URL, e.g. `https://localhost:9200` |
| `OPENSEARCH_USERNAME`, `OPENSEARCH_PASSWORD` | | OpenSearch credentials (required) |
| `DASHBOARDS_HOST` | | OpenSearch Dashboards URL, used by `generate_discover_url` |
| `MCP_TRANSPORT` | `stdio` | `stdio` or `streamable-http` (the `--transport` flag overrides it) |
| `MCP_HOST` / `MCP_PORT` | `127.0.0.1` / `8000` | Listen address for HTTP mode |
| `MCP_PUBLIC_URL` | `http://<host>:<port>` | Public origin, no path, e.g. `https://opensearch-mcp.monotype.com`. OAuth metadata, the resource URL and the `401` discovery header are built from it, never from request headers. Must be HTTPS except on localhost |
| `MCP_HTTP_PATH` | `/mcp` | Path of the MCP endpoint (the production image derives it from `MCP_PATH_TOKEN` instead) |
| `MCP_AUTH_DB` | `~/.opensearch-mcp/auth.db` | Token database. The server and `opensearch-mcp-token` must use the same file |
| `MCP_ALLOWED_HOSTS` | | Comma-separated Host header values accepted on the MCP endpoint (DNS-rebinding protection) |
| `MCP_FORWARDED_ALLOW_IPS` | `127.0.0.1` | Proxies trusted for `X-Forwarded-*` (only affects logged client IPs) |
| `MCP_OAUTH_ACCESS_TOKEN_TTL` | `3600` | OAuth access token lifetime, seconds |
| `MCP_OAUTH_REFRESH_TOKEN_TTL` | `2592000` | OAuth refresh token lifetime, seconds (30 days) |
| `MCP_OAUTH_REDIRECT_ALLOWLIST` | Claude, ChatGPT, Cursor, loopback | Comma-separated redirect URIs clients may register; a trailing `*` is a prefix match, `*` alone allows any |
| `MCP_STATELESS` | `false` | Stateless Streamable HTTP (for several replicas without sticky sessions) |
| `MCP_JSON_RESPONSE` | `false` | Answer POSTs with plain JSON instead of an SSE stream |

## Local use with Claude Desktop (stdio)

Add this to `claude_desktop_config.json`, with `--directory` pointing at your clone:

```json
{
  "mcpServers": {
    "opensearch": {
      "command": "uv",
      "args": ["--directory", "/path/to/opensearch-mcp-server", "run", "opensearch-mcp-server"],
      "env": {
        "OPENSEARCH_HOST": "https://localhost:9200",
        "OPENSEARCH_USERNAME": "",
        "OPENSEARCH_PASSWORD": "",
        "DASHBOARDS_HOST": "https://localhost:5601"
      }
    }
  }
}
```

The file is at `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS and
`%APPDATA%\Claude\claude_desktop_config.json` on Windows. Restart Claude Desktop, then ask things like
"List all indices in the cluster" or "Show me the cluster health status".

## Running the remote (HTTP) server locally

```bash
uv run opensearch-mcp-token create --name local-dev --expires 1d   # prints the token once
uv run opensearch-mcp-server --transport streamable-http            # http://127.0.0.1:8000/mcp
```

Then check it:

```bash
curl -i -X POST http://127.0.0.1:8000/mcp                           # 401 with a WWW-Authenticate header
curl -i -X POST http://127.0.0.1:8000/mcp \
  -H "Authorization: Bearer osmcp_..." \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
```

[MCP Inspector](https://github.com/modelcontextprotocol/inspector) (`npx @modelcontextprotocol/inspector`) also
works. Choose Streamable HTTP and add the `Authorization` header.

## Authentication

### How it works

* **API tokens** are created by an operator with `opensearch-mcp-token`. They look like `osmcp_<id>_<secret>`.
  Clients that can send a fixed header use `Authorization: Bearer osmcp_...` directly.
* **OAuth** is for clients that only support MCP OAuth, such as Claude custom connectors and ChatGPT. The client
  discovers this server's authorization server from the `401` response, registers itself (Dynamic Client
  Registration) and opens a sign-in page in the browser. There the user pastes an API token. The client then gets
  short-lived access tokens and rotating refresh tokens. These are tied to that API token: they never outlive it,
  and they stop working the moment it expires, is revoked or is rotated.
* Requests without a valid token get `401` and never reach OpenSearch. Tool calls re-check the token right before
  running. A session opened with one token cannot be used with another.
* Only SHA-256 hashes of tokens, authorization codes and client secrets are stored, in a SQLite file created with
  mode `0600`. Tokens and the secret URL prefix are redacted from logs.

### Managing tokens

```bash
opensearch-mcp-token create --name <label>            # asks for an expiration
opensearch-mcp-token list [--all]                     # id, name, status, created, expires, last used
opensearch-mcp-token rotate <id> [--grace 1h]         # new token (asks for its expiration); old one revoked
opensearch-mcp-token revoke <id>                      # immediate, including OAuth sessions created with it
```

Every `create` and `rotate` asks **"When should this token expire?"**. Accepted answers:

* a date, `2026-12-31`, meaning the end of that day in UTC
* a date and time, `2026-12-31T18:00` (UTC) or `2026-12-31T18:00+05:30`
* a duration from now: `30m`, `12h`, `90d`, `8w`
* `none` for a token that never expires (you are asked to confirm)

Past or unreadable values are asked again. `--expires <value>` skips the prompt for scripts. The token is printed
once on stdout (other messages go to stderr), so `TOKEN=$(opensearch-mcp-token create --name ci --expires 30d)`
works. It cannot be shown again; create a new one if it is lost.

Changes apply on the next request, with no restart. Use one token per person or integration so you can revoke
them separately. With `rotate --grace 1h`, the old token keeps working for an hour so clients can switch over.

## Connecting clients

Use the endpoint URL **`https://opensearch-mcp.monotype.com/<MCP_PATH_TOKEN>/mcp`** in production, or
`http://127.0.0.1:8000/mcp` locally. Ask the operator for the URL and an API token.

### Claude (claude.ai, Desktop, mobile)

Claude custom connectors use OAuth by default. Sending a fixed `Authorization` header is a
[beta that only some organizations have](https://claude.com/docs/connectors/building/authentication).

**OAuth (works on every plan):**

1. Open **Settings → Connectors → Add custom connector**. On Team/Enterprise, an Owner first adds it under
   **Organization settings → Connectors → Add → Custom → Web**.
2. Enter a name and the endpoint URL. Leave the OAuth client ID and secret empty.
3. Click **Connect**. A browser page from this server opens.
4. Paste your API token and click **Authorize**. Claude lists the 14 tools.

When the API token expires or is revoked, Claude asks you to connect again. Paste a new token.

**Request headers (beta organizations only):** when adding the connector, choose no sign-in. Add the header
`Authorization` with the value `Bearer osmcp_...`. Type the `Bearer ` prefix yourself, because Claude sends the
value exactly as entered.

### Claude Code

```bash
claude mcp add --transport http opensearch https://opensearch-mcp.monotype.com/<MCP_PATH_TOKEN>/mcp \
  --header "Authorization: Bearer osmcp_..."
```

Or leave out `--header` and run `/mcp` in Claude Code to sign in through the browser.

### Claude API (MCP connector)

Pass the endpoint URL as the MCP server `url` and the API token as `authorization_token`.

### ChatGPT

Enable developer mode, add a custom MCP server with the endpoint URL and choose OAuth. Leave the client fields
empty; ChatGPT registers itself, and you sign in with your API token.

### Cursor, VS Code and other clients

Any client that can set headers works with the token directly, e.g. in Cursor's `mcp.json`:

```json
{
  "mcpServers": {
    "opensearch": {
      "url": "https://opensearch-mcp.monotype.com/<MCP_PATH_TOKEN>/mcp",
      "headers": { "Authorization": "Bearer osmcp_..." }
    }
  }
}
```

## Production deployment

Production runs on EKS behind an internet-facing AWS NLB (TLS terminated with ACM) and Cloudflare. Everything for
it is in the build repository.

| Build repo path | Purpose |
| --- | --- |
| `k8s/api_image/` | Image `Dockerfile` (Python 3.11, non-root, read-only root filesystem), `app/server.py`, `app/pyproject.toml`, `constraints.txt`, `prebuildsteps.sh` (build and push to ECR) |
| `k8s/helm/` | Chart: Deployment, Service, NLB, ConfigMap, Secret, PersistentVolumeClaim for the token database |
| `k8s/helm/prod/` | `prod.yaml` values and sops-encrypted `secrets.prod.yaml` (`OPENSEARCH_PASSWORD`, `MCP_PATH_TOKEN`) |
| `Makefile` | `build_api_image`, `deploy_image_to_prod_k8`, `lint`, `lb_info`, `token_*` targets |
| `push_to_env/prod.groovy` | Jenkins pipeline: check out both repos, build, deploy with `helm secrets`, record the version |

### Pipeline

Jenkins checks out this repository (the "Codeget" stage) and the build repository. `prebuildsteps.sh` moves
`k8s/api_image/*` to the workspace root and builds and pushes `<ECR>/opensearch-mcp:<build>`. Then
`make deploy_image_to_prod_k8` runs `helm secrets upgrade --install --atomic`. A failed rollout is rolled back
automatically.

**Before the first run:** the "Codeget" stage in `push_to_env/prod.groovy` must point at this code repository's
URL and branch.

### What the chart sets up

* `MCP_TRANSPORT=streamable-http`. `MCP_PUBLIC_URL` and `MCP_ALLOWED_HOSTS` come from `INGRESS_HOST`
  (`opensearch-mcp.monotype.com`).
* `MCP_PATH_TOKEN` (sops secret) is a secret URL prefix on top of token authentication. It is not a replacement
  for it.
* A 1Gi ReadWriteOnce volume, `opensearch-mcp-data`, is mounted at `/data` with `MCP_AUTH_DB=/data/auth.db`. It is
  annotated `helm.sh/resource-policy: keep`, so uninstalling the release does not delete the tokens. It uses the
  cluster's default StorageClass; set `persistence.storageClassName` (e.g. `gp3`) in `prod.yaml` if there is none.
  Check with `kubectl get storageclass`. To reuse a restored volume, set `persistence.existingClaim`.
* Strategy `Recreate`: only one pod can mount the volume, so the old pod stops before the new one starts. Expect
  about a minute of downtime per deploy. The chart refuses to render with a rolling update while persistence is on.
* One replica. Sessions are held in memory and the volume is single-attach.
* The NLB, the kubelet probes and `helm test` use `/health`, which needs no token.

### First deploy and token management

After the first deploy there are no tokens, so every MCP request is rejected. From the build repository, with
the production kube context selected:

```bash
make token_create NAME=rajan-claude                  # interactive: asks for the expiration
make token_create NAME=ci-bot EXPIRES=90d            # non-interactive
make token_list [ALL=1]
make token_rotate ID=<id> [GRACE=1h] [EXPIRES=...]
make token_revoke ID=<id>
```

These run `opensearch-mcp-token` inside the running pod
(`kubectl -n opensearch exec -it deploy/opensearch-mcp -- opensearch-mcp-token ...`).

### Network and proxy requirements

* All of these paths must reach the pod: `/<MCP_PATH_TOKEN>/mcp`,
  `/.well-known/oauth-protected-resource/<MCP_PATH_TOKEN>/mcp`, `/.well-known/oauth-authorization-server`,
  `/authorize`, `/token`, `/register`, `/revoke`, `/oauth/login`, `/health`. The NLB forwards everything.
* Cloudflare: Bot Fight Mode, WAF challenges or Cloudflare Access on these paths will break Claude and ChatGPT.
  Claude's traffic comes from `160.79.104.0/21`. Rate limiting on `/oauth/login`, `/register` and `/token` is
  recommended.
* The `Authorization` header must be passed through. Responses must not be buffered, and OAuth endpoints must
  answer within Claude's 10 second limit.

### Backups

The token database is the only state. Back it up with
`kubectl -n opensearch exec deploy/opensearch-mcp -- python -c "import sqlite3; s=sqlite3.connect('/data/auth.db'); d=sqlite3.connect('/tmp/backup.db'); s.backup(d)"`
followed by `kubectl cp`, or with EBS snapshots. It contains only hashes; if it is lost, issue new tokens.

## Testing

```bash
uv sync
uv run pytest
```

The suite starts the real server against a fake OpenSearch (`tests/fake_opensearch.py`), so no cluster is needed.
It covers:

* MCP initialization, tool discovery and tool calls
* valid, invalid, expired, non-expiring, revoked and rotated tokens, including revocation in the middle of a session
* the full Claude OAuth flow, from the `401` to discovery, registration, PKCE sign-in, code exchange, refresh
  rotation and revocation
* the stdio transport
* the token CLI prompts

`tests/test_deploy_image.py` covers the production image: secret prefix, Host-header check and log redaction. It
is skipped unless the installed package is the image build. To run it, copy `src/` plus the build repository's
`app/server.py`, `app/pyproject.toml` and `constraints.txt` into a directory as the Dockerfile does,
`pip install --constraint constraints.txt .` into a Python 3.11 virtualenv, then run `python -m pytest` from this
repository with that interpreter.

## Security notes and limitations

* The sign-in page asks for the API token itself. Users should only paste it into a sign-in they started from their
  own AI client. The page shows where it will redirect, and redirect URIs are limited by
  `MCP_OAUTH_REDIRECT_ALLOWLIST`.
* Everyone who uses the same token shares one identity, and every token can use every tool. There are no per-tool
  scopes.
* OpenSearch is accessed with a single service account (`OPENSEARCH_USERNAME`), whatever the MCP caller. The client
  is created with certificate verification disabled (`verify_certs=False`).
* Client ID Metadata Documents are not supported, so Claude and ChatGPT use Dynamic Client Registration, which
  creates one client record per connection.
* Authentication covers Streamable HTTP only. The old SSE transport is not available in HTTP mode. stdio stays
  local and unauthenticated by design.