# Directory Structure
```
├── .buildkite
│ ├── diff
│ ├── docker.yml
│ ├── pipeline.yml
│ └── pull-requests.json
├── .dockerignore
├── .env-example
├── .github
│ └── workflows
│ ├── auto-comment.yml
│ ├── build.yml
│ └── stale.yml
├── .gitignore
├── Cargo.lock
├── Cargo.toml
├── catalog-info.yaml
├── Dockerfile
├── Dockerfile-8000
├── docs
│ └── CONTRIBUTING.md
├── elastic-mcp.json5
├── LICENSE
├── Makefile
├── NOTICE.txt
├── README.md
├── renovate.json
├── rustfmt.toml
├── scripts
│ └── cargo-run.sh
├── src
│ ├── bin
│ │ ├── elasticsearch-core-mcp-server.rs
│ │ └── start_http.rs
│ ├── cli.rs
│ ├── lib.rs
│ ├── protocol
│ │ ├── http.rs
│ │ ├── mod.rs
│ │ └── stdio.rs
│ ├── servers
│ │ ├── elasticsearch
│ │ │ ├── base_tools.rs
│ │ │ └── mod.rs
│ │ └── mod.rs
│ └── utils
│ ├── interpolator.rs
│ ├── mod.rs
│ └── rmcp_ext.rs
└── tests
└── http_tests.rs
```
# Files
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
```
1 | docs
2 | target
3 | .idea
4 | .vscode
5 |
```
--------------------------------------------------------------------------------
/.env-example:
--------------------------------------------------------------------------------
```
1 | # The MCP server looks for `.env` files to populate environment variables that aren't already set
2 | # Copy and edit this file (it's listed in .gitignore)
3 |
4 | ES_URL="http://localhost:9200"
5 | ES_API_KEY="<my-api-key>"
6 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | debug/
4 | target/
5 |
6 | # These are backup files generated by rustfmt
7 | **/*.rs.bk
8 |
9 | # MSVC Windows builds of rustc generate these, which store debugging information
10 | *.pdb
11 |
12 | # Generated by cargo mutants
13 | # Contains mutation testing data
14 | **/mutants.out*/
15 |
16 | .idea/
17 | .vscode/
18 |
19 | .env
20 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Elasticsearch MCP Server
2 |
3 | > [!CAUTION]
4 | > This MCP server is deprecated and will only receive critical security updates going forward.
5 | > It has been superseded by [Elastic Agent Builder](https://ela.st/agent-builder-docs)'s [MCP endpoint](https://ela.st/agent-builder-mcp), which is available in Elastic 9.2.0+ and Elasticsearch Serverless projects.
6 |
7 | Connect to your Elasticsearch data directly from any MCP Client using the Model Context Protocol (MCP).
8 |
9 | This server connects agents to your Elasticsearch data using the Model Context Protocol. It allows you to interact with your Elasticsearch indices through natural language conversations.
10 |
11 | ## Available Tools
12 |
13 | * `list_indices`: List all available Elasticsearch indices
14 | * `get_mappings`: Get field mappings for a specific Elasticsearch index
15 | * `search`: Perform an Elasticsearch search with the provided query DSL
16 | * `esql`: Perform an ES|QL query
17 | * `get_shards`: Get shard information for all or specific indices
18 |
19 | ## Prerequisites
20 |
21 | * An Elasticsearch instance
22 | * Elasticsearch authentication credentials (API key or username/password)
23 | * An MCP Client (e.g. [Claude Desktop](https://claude.ai/download), [Goose](https://block.github.io/goose/))
24 |
25 | **Supported Elasticsearch versions**
26 |
27 | This works with Elasticsearch versions `8.x` and `9.x`.
28 |
29 | ## Installation & Setup
30 |
31 | > [!NOTE]
32 | >
33 | > Versions 0.3.1 and earlier were installed via `npm`. These versions are deprecated and no longer supported. The following instructions only apply to 0.4.0 and later.
34 | >
35 | > To view instructions for versions 0.3.1 and earlier, see the [README for v0.3.1](https://github.com/elastic/mcp-server-elasticsearch/tree/v0.3.1).
36 |
37 | This MCP server is provided as a Docker image at `docker.elastic.co/mcp/elasticsearch`
38 | that supports MCP's stdio, SSE and streamable-HTTP protocols.
39 |
40 | Running this container without any argument will output a usage message:
41 |
42 | ```
43 | docker run docker.elastic.co/mcp/elasticsearch
44 | ```
45 |
46 | ```
47 | Usage: elasticsearch-mcp-server <COMMAND>
48 |
49 | Commands:
50 | stdio Start a stdio server
51 | http Start a streamable-HTTP server with optional SSE support
52 | help Print this message or the help of the given subcommand(s)
53 |
54 | Options:
55 | -h, --help Print help
56 | -V, --version Print version
57 | ```
58 |
59 | ### Using the stdio protocol
60 |
61 | The MCP server needs environment variables to be set:
62 |
63 | * `ES_URL`: the URL of your Elasticsearch cluster
64 | * For authentication use either an API key or basic authentication:
65 | * API key: `ES_API_KEY`
66 | * Basic auth: `ES_USERNAME` and `ES_PASSWORD`
67 | * Optionally, `ES_SSL_SKIP_VERIFY` set to `true` skips SSL/TLS certificate verification when connecting
68 | to Elasticsearch. The ability to provide a custom certificate will be added in a later version.
69 |
70 | The MCP server is started in stdio mode with this command:
71 |
72 | ```bash
73 | docker run -i --rm -e ES_URL -e ES_API_KEY docker.elastic.co/mcp/elasticsearch stdio
74 | ```
75 |
76 | The configuration for Claude Desktop is as follows:
77 |
78 | ```json
79 | {
80 | "mcpServers": {
81 | "elasticsearch-mcp-server": {
82 | "command": "docker",
83 | "args": [
84 | "run", "-i", "--rm",
85 | "-e", "ES_URL", "-e", "ES_API_KEY",
86 | "docker.elastic.co/mcp/elasticsearch",
87 | "stdio"
88 | ],
89 | "env": {
90 | "ES_URL": "<elasticsearch-cluster-url>",
91 | "ES_API_KEY": "<elasticsearch-API-key>"
92 | }
93 | }
94 | }
95 | }
96 | ```
97 |
98 | ### Using the streamable-HTTP and SSE protocols
99 |
100 | Note: streamable-HTTP is recommended, as [SSE is deprecated](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse-deprecated).
101 |
102 | The MCP server needs environment variables to be set:
103 |
104 | * `ES_URL`, the URL of your Elasticsearch cluster
105 | * For authentication use either an API key or basic authentication:
106 | * API key: `ES_API_KEY`
107 | * Basic auth: `ES_USERNAME` and `ES_PASSWORD`
108 | * Optionally, `ES_SSL_SKIP_VERIFY` set to `true` skips SSL/TLS certificate verification when connecting
109 | to Elasticsearch. The ability to provide a custom certificate will be added in a later version.
110 |
111 | The MCP server is started in http mode with this command:
112 |
113 | ```bash
114 | docker run --rm -e ES_URL -e ES_API_KEY -p 8080:8080 docker.elastic.co/mcp/elasticsearch http
115 | ```
116 |
117 | If for some reason your execution environment doesn't allow passing parameters to the container, they can be passed
118 | using the `CLI_ARGS` environment variable: `docker run --rm -e ES_URL -e ES_API_KEY -e CLI_ARGS=http -p 8080:8080...`
119 |
120 | The streamable-HTTP endpoint is at `http:<host>:8080/mcp`. There's also a health check at `http:<host>:8080/ping`
121 |
122 | Configuration for Claude Desktop (free edition that only supports the stdio protocol).
123 |
124 | 1. Install `mcp-proxy` (or an equivalent), that will bridge stdio to streamable-http. The executable
125 | will be installed in `~/.local/bin`:
126 |
127 | ```bash
128 | uv tool install mcp-proxy
129 | ```
130 |
131 | 2. Add this configuration to Claude Desktop:
132 |
133 | ```json
134 | {
135 | "mcpServers": {
136 | "elasticsearch-mcp-server": {
137 | "command": "/<home-directory>/.local/bin/mcp-proxy",
138 | "args": [
139 | "--transport=streamablehttp",
140 | "--header", "Authorization", "ApiKey <elasticsearch-API-key>",
141 | "http://<mcp-server-host>:<mcp-server-port>/mcp"
142 | ]
143 | }
144 | }
145 | }
146 | ```
147 |
```
--------------------------------------------------------------------------------
/docs/CONTRIBUTING.md:
--------------------------------------------------------------------------------
```markdown
1 | # Contributing
2 |
3 | [fork]: https://github.com/elastic/mcp-server-elasticsearch/fork
4 | [pr]: https://github.com/elastic/mcp-server-elasticsearch/compare
5 | [code-of-conduct]: https://www.elastic.co/community/codeofconduct
6 |
7 | Elasticsearch MCP Server is open source, and we love to receive contributions from our community — you!
8 |
9 | There are many ways to contribute, from writing tutorials or blog posts, improving the documentation, submitting bug reports and feature requests or writing code.
10 |
11 | Contributions are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) under the [project's license](../LICENSE).
12 |
13 | Please note that this project follows the [Elastic's Open Source Community Code of Conduct][code-of-conduct].
14 |
15 | ## Setup
16 |
17 | 1. Install Rust (using [rustup](https://www.rust-lang.org/tools/install) is recommended)
18 |
19 | 2. Build the project:
20 | ```sh
21 | cargo build
22 | ```
23 |
24 | or to build the Docker image, run:
25 |
26 | ```sh
27 | docker build -t mcp/elasticsearch
28 | ```
29 |
30 | ## Start Elasticsearch
31 |
32 | You can use either:
33 |
34 | 1. **Elastic Cloud** - Use an existing Elasticsearch deployment and your API key
35 | 2. **Local Elasticsearch** - Run Elasticsearch locally using the [start-local](https://www.elastic.co/guide/en/elasticsearch/reference/current/run-elasticsearch-locally.html) script:
36 |
37 | ```bash
38 | curl -fsSL https://elastic.co/start-local | sh
39 | ```
40 |
41 | This starts Elasticsearch and Kibana with Docker:
42 | - Elasticsearch: <http://localhost:9200>
43 | - Kibana: <http://localhost:5601>
44 |
45 | > [!NOTE]
46 | > The `start-local` setup is for development only. It uses basic authentication and disables HTTPS.
47 |
48 | ## Development Workflow
49 |
50 | 1. [Fork][fork] and clone the repository
51 | 2. Create a new branch: `git checkout -b my-branch-name`
52 | 3. Make your changes and add tests
53 | 4. Fix `cargo clippy` warnings, run `cargo fmt` and `cargo test`
54 | 5. Test locally with the MCP Inspector:
55 | ```bash
56 | npx @modelcontextprotocol/inspector
57 | ```
58 | 7. [Test with MCP Client](../README.md#installation--setup)
59 | 8. Push to your fork and [submit a pull request][pr]
60 |
61 | ## Best Practices
62 |
63 | - Follow existing code style and patterns
64 | - Write [conventional commits](https://www.conventionalcommits.org/)
65 | - Include tests for your changes
66 | - Keep PRs focused on a single concern
67 | - Update documentation as needed
68 |
69 | ## Getting Help
70 |
71 | - Open an issue in the repository
72 | - Ask questions on [discuss.elastic.co](https://discuss.elastic.co/)
73 |
74 | ## Resources
75 |
76 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/)
77 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/)
78 | - [Elastic Code of Conduct][code-of-conduct]
79 |
```
--------------------------------------------------------------------------------
/rustfmt.toml:
--------------------------------------------------------------------------------
```toml
1 | max_width = 120
2 |
```
--------------------------------------------------------------------------------
/NOTICE.txt:
--------------------------------------------------------------------------------
```
1 | Elasticsearch MCP Server
2 | Copyright 2025 Elasticsearch B.V.
3 |
```
--------------------------------------------------------------------------------
/scripts/cargo-run.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/usr/bin/env bash
2 |
3 | cd "$(dirname $0)"/..
4 | exec cargo run "$@"
5 |
```
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "local>elastic/renovate-config"
5 | ],
6 | "schedule": [
7 | "after 1am on monday"
8 | ]
9 | }
10 |
```
--------------------------------------------------------------------------------
/.buildkite/docker.yml:
--------------------------------------------------------------------------------
```yaml
1 | ---
2 | # $yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json
3 | steps:
4 | - label: "Build and publish Docker image"
5 | command: "make docker-push-elastic"
6 | agents:
7 | provider: "gcp"
8 |
```
--------------------------------------------------------------------------------
/.buildkite/pull-requests.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "jobs": [
3 | {
4 | "enabled": true,
5 | "pipelineSlug": "mcp-server-elasticsearch",
6 | "allow_org_users": true,
7 | "allowed_repo_permissions": [
8 | "admin",
9 | "write"
10 | ],
11 | "allowed_list": [],
12 | "set_commit_status": true,
13 | "commit_status_context": "buildkite/mcp-server-elasticsearch",
14 | "build_on_commit": false,
15 | "build_on_comment": true,
16 | "trigger_comment_regex": "^(?:(?:buildkite\\W+)?(?:build|test)\\W+(?:this|it))",
17 | "always_trigger_comment_regex": "^(?:(?:buildkite\\W+)?(?:build|test)\\W+(?:this|it))",
18 | "skip_ci_labels": [
19 | "skip-ci"
20 | ],
21 | "skip_target_branches": [],
22 | "always_require_ci_on_changed": []
23 | }
24 | ]
25 | }
26 |
```
--------------------------------------------------------------------------------
/src/protocol/mod.rs:
--------------------------------------------------------------------------------
```rust
1 | // Licensed to Elasticsearch B.V. under one or more contributor
2 | // license agreements. See the NOTICE file distributed with
3 | // this work for additional information regarding copyright
4 | // ownership. Elasticsearch B.V. licenses this file to you under
5 | // the Apache License, Version 2.0 (the "License"); you may
6 | // not use this file except in compliance with the License.
7 | // You may obtain a copy of the License at
8 | //
9 | // http://www.apache.org/licenses/LICENSE-2.0
10 | //
11 | // Unless required by applicable law or agreed to in writing,
12 | // software distributed under the License is distributed on an
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 | // KIND, either express or implied. See the License for the
15 | // specific language governing permissions and limitations
16 | // under the License.
17 |
18 | pub mod http;
19 | pub mod stdio;
20 |
```
--------------------------------------------------------------------------------
/src/protocol/stdio.rs:
--------------------------------------------------------------------------------
```rust
1 | // Licensed to Elasticsearch B.V. under one or more contributor
2 | // license agreements. See the NOTICE file distributed with
3 | // this work for additional information regarding copyright
4 | // ownership. Elasticsearch B.V. licenses this file to you under
5 | // the Apache License, Version 2.0 (the "License"); you may
6 | // not use this file except in compliance with the License.
7 | // You may obtain a copy of the License at
8 | //
9 | // http://www.apache.org/licenses/LICENSE-2.0
10 | //
11 | // Unless required by applicable law or agreed to in writing,
12 | // software distributed under the License is distributed on an
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 | // KIND, either express or implied. See the License for the
15 | // specific language governing permissions and limitations
16 | // under the License.
17 |
18 | //! Empty for now, stdio is handled in `lib.rs`.
19 |
```
--------------------------------------------------------------------------------
/.github/workflows/stale.yml:
--------------------------------------------------------------------------------
```yaml
1 | ---
2 | name: "Close stale issues and PRs"
3 | on:
4 | schedule:
5 | - cron: "30 1 * * *"
6 |
7 | jobs:
8 | stale:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
12 | with:
13 | stale-issue-label: stale
14 | stale-pr-label: stale
15 | days-before-stale: 60
16 | days-before-close: 14
17 | exempt-issue-labels: "good first issue"
18 | close-issue-label: closed-stale
19 | close-pr-label: closed-stale
20 | stale-issue-message: "This issue is stale because it has been open 60 days with no activity. Remove the `stale` label, or leave a comment, or this will be closed in 14 days."
21 | stale-pr-message: "This pull request is stale because it has been open 60 days with no activity. Remove the `stale` label, or leave a comment, or this will be closed in 14 days."
22 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | # Copyright Elasticsearch B.V. and contributors
2 | # SPDX-License-Identifier: Apache-2.0
3 |
4 | # To create a multi-arch image, run:
5 | # docker buildx build --platform linux/amd64,linux/arm64 --tag elasticsearch-core-mcp-server .
6 |
7 | FROM rust:1.89@sha256:c50cd6e20c46b0b36730b5eb27289744e4bb8f32abc90d8c64ca09decf4f55ba AS builder
8 |
9 | WORKDIR /app
10 |
11 | COPY Cargo.toml Cargo.lock ./
12 |
13 | # Cache dependencies
14 | RUN mkdir -p ./src/bin && \
15 | echo "pub fn main() {}" > ./src/bin/elasticsearch-core-mcp-server.rs && \
16 | cargo build --release
17 |
18 | COPY src ./src/
19 |
20 | RUN cargo build --release
21 |
22 | #--------------------------------------------------------------------------------------------------
23 |
24 | FROM cgr.dev/chainguard/wolfi-base:latest
25 |
26 | COPY --from=builder /app/target/release/elasticsearch-core-mcp-server /usr/local/bin/elasticsearch-core-mcp-server
27 |
28 | ENV CONTAINER_MODE=true
29 |
30 | EXPOSE 8080/tcp
31 | ENTRYPOINT ["/usr/local/bin/elasticsearch-core-mcp-server"]
32 |
```
--------------------------------------------------------------------------------
/.github/workflows/auto-comment.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Add comment
2 | on:
3 | issues:
4 | types: [opened]
5 | pull_request_target:
6 | types: [opened]
7 |
8 | jobs:
9 | add-comment:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Fetch ephemeral GitHub token
13 | id: fetch-token
14 | uses: elastic/ci-gh-actions/fetch-github-token@8a7604dfdd4e7fe21f969bfe9ff96e17635ea577 # v1.0.0
15 | with:
16 | vault-instance: "ci-prod"
17 | - name: Add comment
18 | uses: actions/github-script@v7
19 | with:
20 | github-token: ${{ steps.fetch-token.outputs.token }}
21 | script: |
22 | const { owner, repo } = context.repo;
23 | const commentBody = "**Deprecation warning:** This MCP server is deprecated and will only receive critical security updates going forward. It has been superseded by [Elastic Agent Builder](https://ela.st/agent-builder-docs)'s [MCP endpoint](https://ela.st/agent-builder-mcp), which is available in Elastic 9.2.0+ and Elasticsearch Serverless projects.";
24 |
25 | github.rest.issues.createComment({
26 | owner,
27 | repo,
28 | issue_number: context.payload.issue?.number ?? context.payload.pull_request?.number,
29 | body: commentBody
30 | });
31 |
```
--------------------------------------------------------------------------------
/.buildkite/pipeline.yml:
--------------------------------------------------------------------------------
```yaml
1 | ---
2 | # $yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json
3 | steps:
4 | - label: "Triggering pipelines"
5 | plugins:
6 | monorepo-diff#v1.4.0:
7 | diff: ".buildkite/diff ${BUILDKITE_COMMIT}"
8 | wait: true
9 | watch:
10 | # if our Renovate configuration is amended, then make sure we have well-formed config
11 | # for more info, see https://docs.elastic.dev/plat-prod-team/service-catalogue/renovate/testing-renovate-changes
12 | - path: "renovate.json"
13 | config:
14 | label: "Verify Renovate configuration"
15 | command: "renovate-config-validator"
16 | agents:
17 | image: "docker.elastic.co/ci-agent-images/pipelib:0.22.0@sha256:25503116fb91c18383b17aee528b9ca6e520ef58622c7f961c7d255bb8ba51f6"
18 | # if our catalog-info.yaml is changed, make sure it's well-formed according to our internal standards as well as Backstage's validation
19 | - path: "catalog-info.yaml"
20 | config:
21 | command: "/agent/check-catalog-info.sh"
22 | agents:
23 | image: "docker.elastic.co/ci-agent-images/pipelib:0.22.0@sha256:25503116fb91c18383b17aee528b9ca6e520ef58622c7f961c7d255bb8ba51f6"
24 |
```
--------------------------------------------------------------------------------
/src/bin/start_http.rs:
--------------------------------------------------------------------------------
```rust
1 | // Licensed to Elasticsearch B.V. under one or more contributor
2 | // license agreements. See the NOTICE file distributed with
3 | // this work for additional information regarding copyright
4 | // ownership. Elasticsearch B.V. licenses this file to you under
5 | // the Apache License, Version 2.0 (the "License"); you may
6 | // not use this file except in compliance with the License.
7 | // You may obtain a copy of the License at
8 | //
9 | // http://www.apache.org/licenses/LICENSE-2.0
10 | //
11 | // Unless required by applicable law or agreed to in writing,
12 | // software distributed under the License is distributed on an
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 | // KIND, either express or implied. See the License for the
15 | // specific language governing permissions and limitations
16 | // under the License.
17 |
18 | use elasticsearch_core_mcp_server::cli::HttpCommand;
19 | use elasticsearch_core_mcp_server::run_http;
20 |
21 | /// Start the MCP http server with the local configuration.
22 | /// Useful for debugging from the IDE.
23 | #[tokio::main]
24 | pub async fn main() -> anyhow::Result<()> {
25 | println!("Current directory: {:?}", std::env::current_dir()?);
26 |
27 | run_http(HttpCommand {
28 | config: Some("elastic-mcp.json5".parse()?),
29 | address: None,
30 | sse: true,
31 | },
32 | false)
33 | .await?;
34 |
35 | Ok(())
36 | }
37 |
```
--------------------------------------------------------------------------------
/src/utils/mod.rs:
--------------------------------------------------------------------------------
```rust
1 | // Licensed to Elasticsearch B.V. under one or more contributor
2 | // license agreements. See the NOTICE file distributed with
3 | // this work for additional information regarding copyright
4 | // ownership. Elasticsearch B.V. licenses this file to you under
5 | // the Apache License, Version 2.0 (the "License"); you may
6 | // not use this file except in compliance with the License.
7 | // You may obtain a copy of the License at
8 | //
9 | // http://www.apache.org/licenses/LICENSE-2.0
10 | //
11 | // Unless required by applicable law or agreed to in writing,
12 | // software distributed under the License is distributed on an
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 | // KIND, either express or implied. See the License for the
15 | // specific language governing permissions and limitations
16 | // under the License.
17 |
18 | use serde::{Deserialize, Deserializer};
19 |
20 | pub mod interpolator;
21 | pub mod rmcp_ext;
22 |
23 | /// Deserialize a string, and return `None` if it's empty. Useful for configuration fields like
24 | /// `"foo": "${SOME_ENV_VAR:}"` that uses an env var if present without failing if missing.
25 | pub fn none_if_empty_string<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
26 | let s: Option<String> = Deserialize::deserialize(deserializer)?;
27 | match s {
28 | Some(s) if s.is_empty() => Ok(None),
29 | _ => Ok(s),
30 | }
31 | }
32 |
```
--------------------------------------------------------------------------------
/src/utils/rmcp_ext.rs:
--------------------------------------------------------------------------------
```rust
1 | // Licensed to Elasticsearch B.V. under one or more contributor
2 | // license agreements. See the NOTICE file distributed with
3 | // this work for additional information regarding copyright
4 | // ownership. Elasticsearch B.V. licenses this file to you under
5 | // the Apache License, Version 2.0 (the "License"); you may
6 | // not use this file except in compliance with the License.
7 | // You may obtain a copy of the License at
8 | //
9 | // http://www.apache.org/licenses/LICENSE-2.0
10 | //
11 | // Unless required by applicable law or agreed to in writing,
12 | // software distributed under the License is distributed on an
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 | // KIND, either express or implied. See the License for the
15 | // specific language governing permissions and limitations
16 | // under the License.
17 |
18 | //! Various extensions and utilities for the Rust MCP sdk.
19 |
20 | use rmcp::{RoleServer, Service};
21 | use std::sync::Arc;
22 |
23 | /// A factory to create server (`Service<RoleServer>`) instances.
24 | pub struct ServerProvider<S: Service<RoleServer>>(pub Arc<dyn Fn() -> S + Send + Sync>);
25 |
26 | impl<S: Service<RoleServer>, F: Fn() -> S + Send + Sync + 'static> From<F> for ServerProvider<S> {
27 | fn from(value: F) -> Self {
28 | ServerProvider(Arc::new(value))
29 | }
30 | }
31 |
32 | impl<S: Service<RoleServer>> From<Arc<dyn Fn() -> S + Send + Sync>> for ServerProvider<S> {
33 | fn from(value: Arc<dyn Fn() -> S + Send + Sync>) -> Self {
34 | ServerProvider(value)
35 | }
36 | }
37 |
```
--------------------------------------------------------------------------------
/src/servers/mod.rs:
--------------------------------------------------------------------------------
```rust
1 | // Licensed to Elasticsearch B.V. under one or more contributor
2 | // license agreements. See the NOTICE file distributed with
3 | // this work for additional information regarding copyright
4 | // ownership. Elasticsearch B.V. licenses this file to you under
5 | // the Apache License, Version 2.0 (the "License"); you may
6 | // not use this file except in compliance with the License.
7 | // You may obtain a copy of the License at
8 | //
9 | // http://www.apache.org/licenses/LICENSE-2.0
10 | //
11 | // Unless required by applicable law or agreed to in writing,
12 | // software distributed under the License is distributed on an
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 | // KIND, either express or implied. See the License for the
15 | // specific language governing permissions and limitations
16 | // under the License.
17 |
18 | use serde::{Deserialize, Serialize};
19 |
20 | pub mod elasticsearch;
21 |
22 | /// Inclusion or exclusion list.
23 | #[derive(Debug, Serialize, Deserialize)]
24 | #[serde(rename_all = "snake_case")]
25 | pub enum IncludeExclude {
26 | Include(Vec<String>),
27 | Exclude(Vec<String>),
28 | }
29 |
30 | impl IncludeExclude {
31 | pub fn is_included(&self, name: &str) -> bool {
32 | use IncludeExclude::*;
33 | match self {
34 | Include(includes) => includes.iter().map(|s| s.as_str()).any(|s| s == name),
35 | Exclude(excludes) => excludes.iter().map(|s| s.as_str()).all(|s| s != name),
36 | }
37 | }
38 |
39 | pub fn filter(&self, tools: &mut Vec<rmcp::model::Tool>) {
40 | tools.retain(|t| self.is_included(&t.name))
41 | }
42 | }
43 |
```
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
```toml
1 | [package]
2 | name = "elasticsearch-core-mcp-server"
3 | version = "0.4.6"
4 | edition = "2024"
5 | authors = ["Elastic.co"]
6 | license-file = "LICENSE"
7 | description = "MCP server for core Elastisearch features"
8 | homepage = "https://github.com/elastic/mcp-server-elasticsearch"
9 | repository = "https://github.com/elastic/mcp-server-elasticsearch"
10 |
11 | default-run = "elasticsearch-core-mcp-server"
12 |
13 | [dependencies]
14 | # Base stuff
15 | anyhow = "1.0"
16 | futures = "0.3"
17 | indexmap = { version = "2", features = ["serde"] }
18 | itertools = "0.12"
19 | thiserror = "2"
20 |
21 | serde = { version = "1.0", features = ["derive"] }
22 | serde_json = "1"
23 |
24 | # CLI, config
25 | clap = { version = "4", features = ["derive", "env"] }
26 | dotenvy = "0.15"
27 | serde-aux = "4"
28 | serde_json5 = "0.2"
29 |
30 | # Logging
31 | tracing = "0.1"
32 | tracing-subscriber = { version = "0.3", features = [
33 | "env-filter",
34 | "std",
35 | "fmt",
36 | ]}
37 |
38 | elasticsearch = { version = "9.0.0-alpha.1", git = "https://github.com/elastic/elasticsearch-rs", branch = "new-with-creds" }
39 |
40 | # Async and http
41 | tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "io-std", "signal", "process"] }
42 | tokio-util = "0.7"
43 | axum = "0.8"
44 | http = "1.3.1"
45 |
46 | # Schemars: keep in sync with rmcp
47 | schemars = { version = "0.8", features = ["chrono"] }
48 |
49 | reqwest = "0.12"
50 | futures-util = "0.3"
51 |
52 | # MCP rust sdk: main branch, 2025-06-26
53 | [dependencies.rmcp]
54 | features = [
55 | "server", "auth",
56 | "transport-sse-server", "transport-streamable-http-server",
57 | "transport-io", # stdio
58 | "client", "transport-sse-client", "transport-streamable-http-client", "transport-child-process",
59 | ]
60 | # Keep rev in sync with rmcp-macros below
61 | version = "0.2.1"
62 |
63 | [dependencies.rmcp-macros]
64 | version = "0.2.1"
65 |
66 | [dev-dependencies]
67 | sse-stream = "0.2"
68 |
69 | [profile.release]
70 | codegen-units = 1
71 | strip = true
72 | lto = true
73 | opt-level = "z"
74 | # Note: do not add `panic = "abort"` since tower-http has a panic-handling middleware
75 |
```
--------------------------------------------------------------------------------
/elastic-mcp.json5:
--------------------------------------------------------------------------------
```
1 |
2 | {
3 | // Configure the target Elasticsearch server
4 | "elasticsearch": {
5 | "url": "${ES_URL}",
6 | "api_key": "${ES_API_KEY:}",
7 | "username": "${ES_USERNAME:}",
8 | "password": "${ES_PASSWORD:}",
9 | "ssl_skip_verify": "${ES_SSL_SKIP_VERIFY:false}",
10 |
11 | /* WIP
12 | "tools": {
13 | // Exclude the "search" builtin tool as it's too broad
14 | "exclude": ["search"],
15 |
16 | // Custom tools
17 | "custom": {
18 | // An ES|QL query
19 | "add-42": {
20 | "type": "esql",
21 | "description": "Adds 42 to the input value",
22 | "query": "row value = ?value | eval result = value + 42 | keep result",
23 | "parameters": {
24 | "value": {
25 | "title": "The value",
26 | "type": "number"
27 | }
28 | }
29 | },
30 | // A stored search template
31 | "a-stored-template": {
32 | "type": "search_template",
33 | "description": "This is the description for this stored template",
34 | "template_id": "my-template",
35 | "parameters": {
36 | "param_1": {
37 | "title": "The first parameter",
38 | "description": "Use this parameter to blah blah and blah",
39 | "type": "string"
40 | }
41 | }
42 | },
43 | // An inline search template
44 | "an-inline-template": {
45 | "type": "search_template",
46 | "description": "This is the description for this inline template",
47 | "template": {
48 | "query": {
49 | "term": {
50 | "some-field": "{{param_1}}"
51 | }
52 | }
53 | },
54 | "parameters": {
55 | "param_1": {
56 | "title": "The first parameter",
57 | "description": "Use this parameter to blah blah and blah",
58 | "type": "string"
59 | }
60 | }
61 | }
62 | }
63 | }
64 | */
65 | }
66 | }
67 |
```
--------------------------------------------------------------------------------
/catalog-info.yaml:
--------------------------------------------------------------------------------
```yaml
1 | ---
2 | # yaml-language-server: $schema=https://json.schemastore.org/catalog-info.json
3 | apiVersion: backstage.io/v1alpha1
4 | kind: Component
5 | metadata:
6 | name: mcp-server-elasticsearch
7 | spec:
8 | type: library
9 | owner: group:devtools-team
10 | lifecycle: beta
11 |
12 | ---
13 | # yaml-language-server: $schema=https://gist.githubusercontent.com/elasticmachine/988b80dae436cafea07d9a4a460a011d/raw/rre.schema.json
14 | apiVersion: backstage.io/v1alpha1
15 | kind: Resource
16 | metadata:
17 | name: buildkite-pipeline-mcp-server-elasticsearch
18 | description: Buildkite Pipeline for mcp-server-elasticsearch
19 | links:
20 | - title: Pipeline
21 | url: https://buildkite.com/elastic/mcp-server-elasticsearch
22 |
23 | spec:
24 | type: buildkite-pipeline
25 | owner: group:devtools-team
26 | system: buildkite
27 | implementation:
28 | apiVersion: buildkite.elastic.dev/v1
29 | kind: Pipeline
30 | metadata:
31 | name: mcp-server-elasticsearch
32 | description: Run checks for the mcp-server-elasticsearch package
33 | spec:
34 | repository: elastic/mcp-server-elasticsearch
35 | pipeline_file: ".buildkite/pipeline.yml"
36 | teams:
37 | devtools-team:
38 | access_level: MANAGE_BUILD_AND_READ
39 | everyone:
40 | access_level: READ_ONLY
41 |
42 | ---
43 | # yaml-language-server: $schema=https://gist.githubusercontent.com/elasticmachine/988b80dae436cafea07d9a4a460a011d/raw/rre.schema.json
44 | apiVersion: backstage.io/v1alpha1
45 | kind: Resource
46 | metadata:
47 | name: mcp-server-elasticsearch-docker
48 | description: Build and publish Docker images for mcp-server-elasticsearch
49 | spec:
50 | type: buildkite-pipeline
51 | owner: group:devtools-team
52 | system: buildkite
53 | implementation:
54 | apiVersion: buildkite.elastic.dev/v1
55 | kind: Pipeline
56 | metadata:
57 | name: mcp-server-elasticsearch-docker
58 | description: Build and publish Docker images for mcp-server-elasticsearch
59 | spec:
60 | repository: elastic/mcp-server-elasticsearch
61 | pipeline_file: ".buildkite/docker.yml"
62 | teams:
63 | devtools-team:
64 | access_level: MANAGE_BUILD_AND_READ
65 | everyone:
66 | access_level: READ_ONLY
67 | provider_settings:
68 | build_pull_requests: false
69 | build_branches: false
70 | build_tags: true
71 | cancel_intermediate_builds: true
72 |
```
--------------------------------------------------------------------------------
/src/bin/elasticsearch-core-mcp-server.rs:
--------------------------------------------------------------------------------
```rust
1 | // Licensed to Elasticsearch B.V. under one or more contributor
2 | // license agreements. See the NOTICE file distributed with
3 | // this work for additional information regarding copyright
4 | // ownership. Elasticsearch B.V. licenses this file to you under
5 | // the Apache License, Version 2.0 (the "License"); you may
6 | // not use this file except in compliance with the License.
7 | // You may obtain a copy of the License at
8 | //
9 | // http://www.apache.org/licenses/LICENSE-2.0
10 | //
11 | // Unless required by applicable law or agreed to in writing,
12 | // software distributed under the License is distributed on an
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 | // KIND, either express or implied. See the License for the
15 | // specific language governing permissions and limitations
16 | // under the License.
17 |
18 | use std::io::ErrorKind;
19 | use clap::Parser;
20 | use elasticsearch_core_mcp_server::cli::Cli;
21 | use tracing_subscriber::EnvFilter;
22 | // To test with stdio, use npx @modelcontextprotocol/inspector cargo run -p elastic-mcp
23 |
24 | #[tokio::main]
25 | async fn main() -> anyhow::Result<()> {
26 |
27 | // Also accept .env files
28 | match dotenvy::dotenv() {
29 | Err(dotenvy::Error::Io(io_err)) if io_err.kind() == ErrorKind::NotFound => {}
30 | Err(err) => return Err(err)?,
31 | Ok(_) => {}
32 | }
33 |
34 | let env_args = std::env::vars().find(|(k, _v)| k == "CLI_ARGS").map(|(_k, v)| v);
35 |
36 | let cli = if let Some(env_args) = env_args {
37 | // Concatenate arg[0] with the ARGS value split on whitespaces
38 | // Note: we don't handle shell-style string quoting and character escaping
39 | let arg0 = std::env::args().next().unwrap();
40 | let mut args = vec![arg0.as_str()];
41 | args.extend(env_args.split_whitespace());
42 |
43 | Cli::parse_from(args)
44 | } else {
45 | Cli::parse()
46 | };
47 |
48 | // Initialize the tracing subscriber with file and stdout logging
49 | tracing_subscriber::fmt()
50 | .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into()))
51 | .with_writer(std::io::stderr)
52 | .with_ansi(false)
53 | .init();
54 |
55 | tracing::info!("Elasticsearch MCP server, version {}", env!("CARGO_PKG_VERSION"));
56 |
57 | tracing::warn!("DEPRECATION NOTICE: This MCP server is deprecated and will only receive critical security updates going forward. It has been superseded by Elastic Agent Builder, which includes its own MCP server: https://ela.st/agent-builder-docs");
58 |
59 | cli.run().await
60 | }
61 |
```
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Build Rust binaries
2 | on:
3 | release:
4 | types: [published]
5 | workflow_dispatch:
6 | inputs:
7 | version:
8 | description: Version to build (e.g. v0.4.1)
9 | required: true
10 | type: string
11 |
12 | jobs:
13 | build-binary:
14 | runs-on: ${{ matrix.target.runner }}
15 | permissions:
16 | contents: write
17 | id-token: write
18 | strategy:
19 | fail-fast: false
20 | matrix:
21 | target:
22 | - name: linux-x86_64
23 | runner: ubuntu-latest
24 | ext: ""
25 | target: x86_64-unknown-linux-gnu
26 | - name: windows-x86_64
27 | runner: windows-latest
28 | ext: ".exe"
29 | target: x86_64-pc-windows-msvc
30 | - name: macos-x86_64
31 | runner: macos-latest
32 | ext: ""
33 | target: x86_64-apple-darwin
34 | - name: linux-arm64
35 | runner: ubuntu-latest
36 | ext: ""
37 | target: aarch64-unknown-linux-gnu
38 | - name: windows-arm64
39 | runner: windows-latest
40 | ext: ".exe"
41 | target: aarch64-pc-windows-msvc
42 | - name: macos-arm64
43 | runner: macos-latest
44 | ext: ""
45 | target: aarch64-apple-darwin
46 | steps:
47 | - name: Get release tag value
48 | id: version-tag
49 | shell: bash
50 | run: |
51 | if [ -n "${{ inputs.version }}" ]; then
52 | echo "ref=${{ inputs.version }}" >> $GITHUB_OUTPUT
53 | else
54 | ref=$(echo "$GITHUB_REF" | cut -d '/' -f3)
55 | echo "ref=$ref" >> $GITHUB_OUTPUT
56 | fi
57 | - name: Checkout
58 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
59 | with:
60 | path: checkout-main
61 | - name: Checkout
62 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
63 | with:
64 | ref: ${{ steps.version-tag.outputs.ref }}
65 | path: checkout-tag
66 | - name: Add target config for arm64 on Linux
67 | if: ${{ matrix.target.name == 'linux-arm64' }}
68 | run: |
69 | sudo apt update && sudo apt install -y gcc-aarch64-linux-gnu
70 | mkdir -p checkout-tag/.cargo
71 | echo '[target.aarch64-unknown-linux-gnu]' >> checkout-tag/.cargo/config
72 | echo 'linker = "aarch64-linux-gnu-gcc"' >> checkout-tag/.cargo/config
73 | - name: Build binary
74 | uses: houseabsolute/actions-rust-cross@9a1618ffb70e8374ab5f48fcccea3ebeacf57971 # v1.0.5
75 | with:
76 | command: build
77 | target: ${{ matrix.target.target }}
78 | args: "--locked --release"
79 | working-directory: ${{ github.workspace }}/checkout-tag
80 | - name: Upload binaries to release
81 | uses: svenstaro/upload-release-action@v2
82 | with:
83 | tag: ${{ steps.version-tag.outputs.ref }}
84 | file: checkout-tag/target/${{ matrix.target.target }}/release/elasticsearch-core-mcp-server${{ matrix.target.ext }}
85 | asset_name: elasticsearch-core-mcp-server-${{ steps.version-tag.outputs.ref }}-${{ matrix.target.name }}${{ matrix.target.ext }}
86 | overwrite: true
87 |
```
--------------------------------------------------------------------------------
/src/cli.rs:
--------------------------------------------------------------------------------
```rust
1 | // Licensed to Elasticsearch B.V. under one or more contributor
2 | // license agreements. See the NOTICE file distributed with
3 | // this work for additional information regarding copyright
4 | // ownership. Elasticsearch B.V. licenses this file to you under
5 | // the Apache License, Version 2.0 (the "License"); you may
6 | // not use this file except in compliance with the License.
7 | // You may obtain a copy of the License at
8 | //
9 | // http://www.apache.org/licenses/LICENSE-2.0
10 | //
11 | // Unless required by applicable law or agreed to in writing,
12 | // software distributed under the License is distributed on an
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 | // KIND, either express or implied. See the License for the
15 | // specific language governing permissions and limitations
16 | // under the License.
17 |
18 | use crate::servers::elasticsearch;
19 | use clap::Parser;
20 | use clap::{Args, Subcommand};
21 | use serde::{Deserialize, Serialize};
22 | use std::collections::HashMap;
23 | use std::path::PathBuf;
24 |
25 | /// Elastic MCP server
26 | #[derive(Debug, Parser)]
27 | #[command(version)]
28 | pub struct Cli {
29 | /// Container mode: change default http address, rewrite localhost to the host's address
30 | #[clap(global=true, long, env = "CONTAINER_MODE")]
31 | pub container_mode: bool,
32 |
33 | #[clap(subcommand)]
34 | pub command: Command,
35 | }
36 |
37 | #[derive(Debug, Subcommand)]
38 | pub enum Command {
39 | Stdio(StdioCommand),
40 | Http(HttpCommand),
41 | }
42 |
43 | /// Start a streamable-HTTP server with optional SSE support
44 | #[derive(Debug, Args)]
45 | pub struct HttpCommand {
46 | /// Config file
47 | #[clap(short, long)]
48 | pub config: Option<PathBuf>,
49 |
50 | /// Address to listen to [default: 127.0.0.1:8080]
51 | #[clap(long, value_name = "IP_ADDRESS:PORT", env = "HTTP_ADDRESS")]
52 | pub address: Option<std::net::SocketAddr>,
53 |
54 | /// Also start an SSE server on '/sse'
55 | #[clap(long)]
56 | pub sse: bool,
57 | }
58 |
59 | /// Start an stdio server
60 | #[derive(Debug, Args)]
61 | pub struct StdioCommand {
62 | /// Config file
63 | #[clap(short, long)]
64 | pub config: Option<PathBuf>,
65 | }
66 |
67 | //---------------------------------------------------------------
68 |
69 | // Reference material:
70 | // https://modelcontextprotocol.io/quickstart/user
71 | // https://code.visualstudio.com/docs/copilot/chat/mcp-servers
72 | // https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-mcp-configuration.html
73 | // https://github.com/landicefu/mcp-client-configuration-server
74 |
75 | #[derive(Debug, Serialize, Deserialize)]
76 | #[serde(rename_all = "camelCase")]
77 | pub struct Stdio {
78 | /// Command to run (e.g. "npx", "docker")
79 | pub command: String,
80 |
81 | /// Command arguments
82 | pub args: Vec<String>,
83 |
84 | /// Environment variables
85 | #[serde(default)]
86 | pub env: HashMap<String, String>,
87 | }
88 |
89 | #[derive(Debug, Serialize, Deserialize)]
90 | #[serde(rename_all = "camelCase")]
91 | pub struct Http {
92 | /// URL of the server
93 | pub url: String,
94 |
95 | /// HTTP headers to send with the request
96 | #[serde(default)]
97 | pub headers: HashMap<String, String>,
98 | }
99 |
100 | #[derive(Debug, Serialize, Deserialize)]
101 | #[serde(rename_all = "kebab-case")]
102 | #[serde(tag = "type")]
103 | pub enum McpServer {
104 | //Builtin(BuiltinConfig),
105 | Sse(Http),
106 | StreamableHttp(Http),
107 | Stdio(Stdio),
108 | }
109 |
110 | #[derive(Debug, Serialize, Deserialize)]
111 | #[serde(rename_all = "camelCase")]
112 | pub struct Configuration {
113 | pub elasticsearch: elasticsearch::ElasticsearchMcpConfig,
114 | #[serde(default)]
115 | pub mcp_servers: HashMap<String, McpServer>,
116 | }
117 |
```
--------------------------------------------------------------------------------
/src/utils/interpolator.rs:
--------------------------------------------------------------------------------
```rust
1 | // Licensed to Elasticsearch B.V. under one or more contributor
2 | // license agreements. See the NOTICE file distributed with
3 | // this work for additional information regarding copyright
4 | // ownership. Elasticsearch B.V. licenses this file to you under
5 | // the Apache License, Version 2.0 (the "License"); you may
6 | // not use this file except in compliance with the License.
7 | // You may obtain a copy of the License at
8 | //
9 | // http://www.apache.org/licenses/LICENSE-2.0
10 | //
11 | // Unless required by applicable law or agreed to in writing,
12 | // software distributed under the License is distributed on an
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 | // KIND, either express or implied. See the License for the
15 | // specific language governing permissions and limitations
16 | // under the License.
17 |
18 | //! Simple string interpolator to inject environment variables in the configuration file.
19 | use thiserror::Error;
20 |
21 | #[derive(Error, Debug)]
22 | #[error("Invalid configuration template: {reason} at {line}:{char}")]
23 | pub struct InterpolationError {
24 | pub reason: String,
25 | pub line: usize,
26 | pub char: usize,
27 | }
28 |
29 | pub fn interpolate_from_env(s: String) -> Result<String, InterpolationError> {
30 | interpolate(s, |name| std::env::var(name).ok())
31 | }
32 |
33 | const OPEN: &str = "${";
34 | const OPEN_LEN: usize = OPEN.len();
35 | const CLOSE: &str = "}";
36 | const CLOSE_LEN: usize = CLOSE.len();
37 |
38 | /// Simple string interpolation using the `${name}` and `${name:default_value}` syntax.
39 | pub fn interpolate(s: String, lookup: impl Fn(&str) -> Option<String>) -> Result<String, InterpolationError> {
40 | if !s.contains(OPEN) {
41 | return Ok(s);
42 | }
43 |
44 | let mut result: String = String::new();
45 |
46 | for (line_no, mut line) in s.lines().enumerate() {
47 | if line_no > 0 {
48 | result.push('\n');
49 | }
50 | let mut char_no = 0;
51 |
52 | let err = |char_no: usize, msg: String| InterpolationError {
53 | reason: msg,
54 | line: line_no + 1, // editors (and humans) are 1-based
55 | char: char_no,
56 | };
57 |
58 | while let Some(pos) = line.find(OPEN) {
59 | // Push text before the opening brace
60 | result.push_str(&line[..pos]);
61 |
62 | char_no += pos + OPEN_LEN;
63 | line = &line[pos + OPEN_LEN..];
64 |
65 | if let Some(pos) = line.find(CLOSE) {
66 | let expr = &line[..pos];
67 | let value = if let Some((name, default)) = expr.split_once(':') {
68 | lookup(name).unwrap_or(default.to_string())
69 | } else {
70 | lookup(expr).ok_or_else(|| err(char_no, format!("env variable '{expr}' not defined")))?
71 | };
72 | result.push_str(&value);
73 |
74 | char_no += expr.len() + CLOSE_LEN;
75 | line = &line[expr.len() + CLOSE_LEN..];
76 | } else {
77 | return Err(err(char_no, "missing closing braces".to_string()));
78 | }
79 | }
80 | result.push_str(line);
81 | }
82 |
83 | Ok(result)
84 | }
85 |
86 | #[cfg(test)]
87 | mod tests {
88 | use super::*;
89 |
90 | fn expand(name: &str) -> Result<String, InterpolationError> {
91 | let lookup = |s: &str| match s {
92 | "foo" => Some("foo_value".to_string()),
93 | "bar" => Some("bar_value".to_string()),
94 | _ => None,
95 | };
96 |
97 | interpolate(name.to_string(), lookup)
98 | }
99 |
100 | #[test]
101 | fn good_extrapolation() -> anyhow::Result<()> {
102 | assert_eq!("012345678", expand("012345678")?);
103 | assert_eq!("foo_value01234", expand("${foo}01234")?);
104 | assert_eq!("foo_value01234\n1234bar_value", expand("${foo}01234\n1234${bar}")?);
105 | assert_eq!("foo_value01234bar_value", expand("${foo}01234${bar}")?);
106 | assert_eq!("_01_foo_value01234bar_value567", expand("_01_${foo}01234${bar}567")?);
107 | Ok(())
108 | }
109 |
110 | #[test]
111 | fn failed_extrapolation() {
112 | assert!(expand("${foo01234").is_err());
113 | assert!(expand("${foo}01234${bar").is_err());
114 | assert!(expand("${baz}01234").is_err());
115 | }
116 | }
117 |
```
--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
```rust
1 | // Licensed to Elasticsearch B.V. under one or more contributor
2 | // license agreements. See the NOTICE file distributed with
3 | // this work for additional information regarding copyright
4 | // ownership. Elasticsearch B.V. licenses this file to you under
5 | // the Apache License, Version 2.0 (the "License"); you may
6 | // not use this file except in compliance with the License.
7 | // You may obtain a copy of the License at
8 | //
9 | // http://www.apache.org/licenses/LICENSE-2.0
10 | //
11 | // Unless required by applicable law or agreed to in writing,
12 | // software distributed under the License is distributed on an
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 | // KIND, either express or implied. See the License for the
15 | // specific language governing permissions and limitations
16 | // under the License.
17 |
18 | pub mod cli;
19 | mod protocol;
20 | mod servers;
21 | mod utils;
22 |
23 | use crate::cli::{Cli, Command, Configuration, HttpCommand, StdioCommand};
24 | use crate::protocol::http::{HttpProtocol, HttpServerConfig};
25 | use crate::servers::elasticsearch;
26 | use crate::utils::interpolator;
27 | use rmcp::transport::stdio;
28 | use rmcp::transport::streamable_http_server::session::never::NeverSessionManager;
29 | use rmcp::{RoleServer, Service, ServiceExt};
30 | use std::net::{IpAddr, Ipv4Addr, SocketAddr};
31 | use std::path::PathBuf;
32 | use std::sync::Arc;
33 | use tokio::select;
34 | use tokio_util::sync::CancellationToken;
35 |
36 | impl Cli {
37 | pub async fn run(self) -> anyhow::Result<()> {
38 | match self.command {
39 | Command::Stdio(cmd) => run_stdio(cmd, self.container_mode).await,
40 | Command::Http(cmd) => run_http(cmd, self.container_mode).await,
41 | }
42 | }
43 | }
44 |
45 | pub async fn run_stdio(cmd: StdioCommand, container_mode: bool) -> anyhow::Result<()> {
46 | tracing::info!("Starting stdio server");
47 | let handler = setup_services(&cmd.config, container_mode).await?;
48 | let service = handler.serve(stdio()).await.inspect_err(|e| {
49 | tracing::error!("serving error: {:?}", e);
50 | })?;
51 |
52 | select! {
53 | _ = service.waiting() => {},
54 | _ = tokio::signal::ctrl_c() => {},
55 | }
56 |
57 | Ok(())
58 | }
59 |
60 | pub async fn run_http(cmd: HttpCommand, container_mode: bool) -> anyhow::Result<()> {
61 | let handler = setup_services(&cmd.config, container_mode).await?;
62 | let server_provider = move || handler.clone();
63 | let address: SocketAddr = if let Some(addr) = cmd.address {
64 | addr
65 | } else if container_mode {
66 | SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 8080)
67 | } else {
68 | SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080)
69 | };
70 |
71 | let ct = HttpProtocol::serve_with_config(
72 | server_provider,
73 | HttpServerConfig {
74 | bind: address,
75 | ct: CancellationToken::new(),
76 | // streaming http:
77 | keep_alive: None,
78 | stateful_mode: false,
79 | session_manager: Arc::new(NeverSessionManager::default()),
80 | },
81 | )
82 | .await?;
83 |
84 | tracing::info!("Starting http server at address {}", address);
85 |
86 | tokio::signal::ctrl_c().await?;
87 | ct.cancel();
88 | Ok(())
89 | }
90 |
91 | pub async fn setup_services(config: &Option<PathBuf>, container_mode: bool) -> anyhow::Result<impl Service<RoleServer> + Clone> {
92 | // Read config file and expand variables
93 |
94 | let config = if let Some(path) = config {
95 | std::fs::read_to_string(path)?
96 | } else {
97 | // Built-in default configuration, based on env variables.
98 | r#"{
99 | "elasticsearch": {
100 | "url": "${ES_URL}",
101 | "api_key": "${ES_API_KEY:}",
102 | "username": "${ES_USERNAME:}",
103 | "password": "${ES_PASSWORD:}",
104 | "ssl_skip_verify": "${ES_SSL_SKIP_VERIFY:false}"
105 | }
106 | }"#
107 | .to_string()
108 | };
109 |
110 | // Expand environment variables in the config file
111 | let config = interpolator::interpolate_from_env(config)?;
112 |
113 | // JSON5 adds comments and multiline strings (useful for ES|QL) to JSON
114 | let config: Configuration = match serde_json5::from_str(&config) {
115 | Ok(c) => c,
116 | Err(serde_json5::Error::Message { msg, location }) if location.is_some() => {
117 | let location = location.unwrap();
118 | let line = location.line;
119 | let column = location.column;
120 | anyhow::bail!("Failed to parse config: {msg}, at line {line} column {column}");
121 | }
122 | Err(err) => return Err(err)?,
123 | };
124 |
125 | let handler = elasticsearch::ElasticsearchMcp::new_with_config(config.elasticsearch, container_mode)?;
126 | Ok(handler)
127 | }
128 |
```
--------------------------------------------------------------------------------
/src/protocol/http.rs:
--------------------------------------------------------------------------------
```rust
1 | // Licensed to Elasticsearch B.V. under one or more contributor
2 | // license agreements. See the NOTICE file distributed with
3 | // this work for additional information regarding copyright
4 | // ownership. Elasticsearch B.V. licenses this file to you under
5 | // the Apache License, Version 2.0 (the "License"); you may
6 | // not use this file except in compliance with the License.
7 | // You may obtain a copy of the License at
8 | //
9 | // http://www.apache.org/licenses/LICENSE-2.0
10 | //
11 | // Unless required by applicable law or agreed to in writing,
12 | // software distributed under the License is distributed on an
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 | // KIND, either express or implied. See the License for the
15 | // specific language governing permissions and limitations
16 | // under the License.
17 |
18 | //! Implementation of HTTP protocols
19 |
20 | use crate::utils::rmcp_ext::ServerProvider;
21 | use axum::Router;
22 | use axum::http::StatusCode;
23 | use axum::routing::get;
24 | use rmcp::transport::sse_server::SseServerConfig;
25 | use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
26 | use rmcp::transport::streamable_http_server::{SessionManager, StreamableHttpServerConfig};
27 | use rmcp::transport::{SseServer, StreamableHttpService};
28 | use rmcp::{RoleServer, Service};
29 | use std::net::SocketAddr;
30 | use std::sync::Arc;
31 | use std::time::Duration;
32 | use tokio_util::sync::CancellationToken;
33 | use tracing::Instrument;
34 |
35 | /// Configuration for an HTTP MCP server
36 | pub struct HttpServerConfig<M: SessionManager = LocalSessionManager> {
37 | /// TCP address to bind to
38 | pub bind: SocketAddr,
39 |
40 | /// Parent cancellation token. `serve_with_config` will return a child token
41 | pub ct: CancellationToken,
42 |
43 | /// Streamable http server option
44 | pub keep_alive: Option<Duration>,
45 |
46 | /// Streamable http server option
47 | pub stateful_mode: bool,
48 |
49 | /// Streamable http server option
50 | pub session_manager: Arc<M>,
51 | }
52 |
53 | /// An HTTP MCP server that supports both SSE and streamable HTTP.
54 | pub struct HttpProtocol {}
55 |
56 | impl HttpProtocol {
57 | pub async fn serve_with_config<S: Service<RoleServer>, M: SessionManager>(
58 | server_provider: impl Into<ServerProvider<S>>,
59 | config: HttpServerConfig<M>,
60 | ) -> std::io::Result<CancellationToken> {
61 | let server_provider = server_provider.into().0;
62 |
63 | let ct = config.ct.child_token();
64 |
65 | // Create a streamable http router
66 | let sh_router = {
67 | let sh_config = StreamableHttpServerConfig {
68 | sse_keep_alive: config.keep_alive,
69 | stateful_mode: config.stateful_mode,
70 | };
71 |
72 | let server_provider = server_provider.clone();
73 | // TODO: internally, new() wraps the server provider closure with an Arc. We can avoid
74 | // "double-Arc" by having
75 | let sh_service =
76 | StreamableHttpService::new(move || Ok(server_provider()), config.session_manager, sh_config);
77 | Router::new().route_service("/", sh_service)
78 | };
79 |
80 | // Create an SSE router
81 | let sse_router = {
82 | let sse_config = SseServerConfig {
83 | bind: config.bind,
84 | // SSE server will create a child cancellation token for every transport that is created
85 | // (see with_service() below)
86 | ct: ct.clone(),
87 | sse_keep_alive: config.keep_alive,
88 | sse_path: "/".to_string(),
89 | post_path: "/message".to_string(),
90 | };
91 | let (sse_server, sse_router) = SseServer::new(sse_config);
92 | let _sse_ct = sse_server.with_service(move || server_provider());
93 |
94 | sse_router
95 | };
96 |
97 | // Health and readiness
98 | // See https://kubernetes.io/docs/concepts/configuration/liveness-readiness-startup-probes/
99 | let health_router = {
100 | Router::new()
101 | // We may introduce a startup probe if we need to fetch/cache remote resources
102 | // during initialization
103 | // Ready: once we have the tool list we can process incoming requests
104 | .route("/ready", get(async || (StatusCode::OK, "Ready\n")))
105 | // Live: are we alive?
106 | .route("/live", get(async || "Alive\n"))
107 | };
108 |
109 | // Put all things together
110 | let main_router = Router::new()
111 | .route("/", get(hello))
112 | .route("/ping", get(async || (StatusCode::OK, "Ready\n")))
113 | .nest("/mcp/sse", sse_router)
114 | .nest("/mcp", sh_router)
115 | .nest("/_health", health_router)
116 | .with_state(());
117 |
118 | // Start the http server
119 | let listener = tokio::net::TcpListener::bind(config.bind).await?;
120 | let server = axum::serve(listener, main_router).with_graceful_shutdown({
121 | let ct = ct.clone();
122 | async move {
123 | ct.cancelled().await;
124 | tracing::info!("http server cancelled");
125 | }
126 | });
127 |
128 | // Await the server, or it will do nothing :-)
129 | tokio::spawn(
130 | async {
131 | let _ = server.await;
132 | }
133 | .instrument(tracing::info_span!("http-server", bind_address = %config.bind)),
134 | );
135 |
136 | Ok(ct)
137 | }
138 | }
139 |
140 | async fn hello() -> String {
141 | let version = env!("CARGO_PKG_VERSION");
142 | format!(
143 | r#"Elasticsearch MCP server. Version {version}
144 |
145 | Endpoints:
146 | - streamable-http: /mcp
147 | - sse: /mcp/sse
148 | "#
149 | )
150 | }
151 |
152 | #[cfg(test)]
153 | mod tests {
154 | #[test]
155 | pub fn test_parts_in_extensions() {}
156 | }
157 |
```
--------------------------------------------------------------------------------
/tests/http_tests.rs:
--------------------------------------------------------------------------------
```rust
1 | // Licensed to Elasticsearch B.V. under one or more contributor
2 | // license agreements. See the NOTICE file distributed with
3 | // this work for additional information regarding copyright
4 | // ownership. Elasticsearch B.V. licenses this file to you under
5 | // the Apache License, Version 2.0 (the "License"); you may
6 | // not use this file except in compliance with the License.
7 | // You may obtain a copy of the License at
8 | //
9 | // http://www.apache.org/licenses/LICENSE-2.0
10 | //
11 | // Unless required by applicable law or agreed to in writing,
12 | // software distributed under the License is distributed on an
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 | // KIND, either express or implied. See the License for the
15 | // specific language governing permissions and limitations
16 | // under the License.
17 |
18 | use anyhow::bail;
19 | use axum::Router;
20 | use axum::extract::Path;
21 | use elasticsearch_core_mcp_server::cli;
22 | use futures_util::StreamExt;
23 | use http::HeaderMap;
24 | use http::header::{ACCEPT, CONTENT_TYPE};
25 | use reqwest::Client;
26 | use rmcp::model::ToolAnnotations;
27 | use serde::Deserialize;
28 | use serde::de::DeserializeOwned;
29 | use serde_json::json;
30 | use sse_stream::SseStream;
31 | use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener};
32 |
33 | /// Simple smoke test
34 | #[tokio::test]
35 | async fn http_tool_list() -> anyhow::Result<()> {
36 | let addr = find_address()?;
37 |
38 | let cli = cli::Cli {
39 | container_mode: false,
40 | command: cli::Command::Http(cli::HttpCommand {
41 | config: None,
42 | address: Some(addr),
43 | sse: false,
44 | }),
45 | };
46 |
47 | tokio::spawn(async move { cli.run().await });
48 |
49 | let url = format!("http://127.0.0.1:{}/mcp", addr.port());
50 |
51 | let body = json!({
52 | "jsonrpc": "2.0",
53 | "id": 1,
54 | "method": "tools/list"
55 | });
56 |
57 | let client = Client::builder().build()?;
58 | tokio::time::sleep(std::time::Duration::from_secs(1)).await;
59 |
60 | let response = client
61 | .post(url)
62 | .header(CONTENT_TYPE, "application/json")
63 | .header(ACCEPT, "application/json, text/event-stream")
64 | .json(&body)
65 | .send()
66 | .await?
67 | .error_for_status()?;
68 |
69 | let response_body: ListToolsResponse = parse_response(response).await?;
70 |
71 | let names = response_body
72 | .result
73 | .tools
74 | .iter()
75 | .map(|t| t.name.as_str())
76 | .collect::<Vec<_>>();
77 | assert!(names.contains(&"search"));
78 | assert!(names.contains(&"list_indices"));
79 | assert!(names.contains(&"get_mappings"));
80 | Ok(())
81 | }
82 |
83 | // End-to-end test that spawns a mock ES server and calls the `list_indices` tool via http
84 | #[tokio::test]
85 | async fn end_to_end() -> anyhow::Result<()> {
86 | // Start an ES mock that will reply to list_indices
87 | let router = Router::new().route(
88 | "/_cat/indices/{index}",
89 | axum::routing::get(async move |headers: HeaderMap, Path(index): Path<String>| {
90 | // Check parameter forwarding
91 | assert_eq!(index, "test-index");
92 | // Check API key
93 | assert_eq!(
94 | headers.get("Authorization").unwrap().to_str().unwrap(),
95 | "ApiKey value-from-the-test"
96 | );
97 | axum::Json(json!([
98 | {
99 | "index": "test-index",
100 | "status": "open",
101 | "docs.count": "100"
102 | }
103 | ]))
104 | }),
105 | );
106 |
107 | let listener = tokio::net::TcpListener::bind(LOCALHOST_0).await?;
108 |
109 | // SAFETY: works since this is the only test in this module that sets env vars
110 | // TODO: refactor the CLI to accept an alternate source of key/values
111 | unsafe {
112 | std::env::set_var("ES_URL", format!("http://127.0.0.1:{}/", listener.local_addr()?.port()));
113 | }
114 | let server = axum::serve(listener, router);
115 | tokio::spawn(async { server.await });
116 |
117 | // Start an http MCP server
118 | let addr = find_address()?;
119 | let cli = cli::Cli {
120 | container_mode: false,
121 | command: cli::Command::Http(cli::HttpCommand {
122 | config: None,
123 | address: Some(addr),
124 | sse: false,
125 | }),
126 | };
127 |
128 | tokio::spawn(async move { cli.run().await });
129 | let url = format!("http://127.0.0.1:{}/mcp", addr.port());
130 | let body = json!({
131 | "jsonrpc": "2.0",
132 | "id": 1,
133 | "method": "tools/call",
134 | "params": {
135 | "name": "list_indices",
136 | "arguments": {
137 | "index_pattern": "test-index"
138 | }
139 | }
140 | });
141 |
142 | let client = Client::builder().build()?;
143 | tokio::time::sleep(std::time::Duration::from_secs(1)).await;
144 |
145 | let response = client
146 | .post(url)
147 | .header(CONTENT_TYPE, "application/json")
148 | .header(ACCEPT, "application/json, text/event-stream")
149 | .header("Authorization", "ApiKey value-from-the-test")
150 | .json(&body)
151 | .send()
152 | .await?
153 | .error_for_status()?;
154 |
155 | let response_body: serde_json::Value = parse_response(response).await?;
156 |
157 | assert_eq!(response_body["result"]["content"][0]["text"], "Found 1 indices:");
158 | assert_eq!(
159 | response_body["result"]["content"][1]["text"],
160 | "[{\"index\":\"test-index\",\"status\":\"open\",\"docs.count\":100}]"
161 | );
162 |
163 | Ok(())
164 | }
165 |
166 | const LOCALHOST_0: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0);
167 |
168 | fn find_address() -> anyhow::Result<SocketAddr> {
169 | // Find a free port
170 | Ok(TcpListener::bind(LOCALHOST_0)?.local_addr()?)
171 | }
172 |
173 | async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> anyhow::Result<T> {
174 | let result = match response.headers().get(CONTENT_TYPE) {
175 | Some(v) if v == "application/json" => response.json().await?,
176 | Some(v) if v == "text/event-stream" => {
177 | let mut stream = SseStream::from_byte_stream(response.bytes_stream());
178 | match stream.next().await {
179 | None => bail!("No data"),
180 | Some(Err(e)) => bail!("Bad SSE stream: {}", e),
181 | Some(Ok(sse)) => {
182 | let data = sse.data.unwrap();
183 | serde_json::from_str(&data)?
184 | }
185 | }
186 | }
187 | _ => {
188 | panic!("Unexpected content type");
189 | }
190 | };
191 |
192 | Ok(result)
193 | }
194 |
195 | #[derive(Debug, Deserialize)]
196 | #[allow(dead_code)]
197 | struct ListToolsResponse {
198 | jsonrpc: String,
199 | id: i64,
200 | result: ToolResult,
201 | }
202 |
203 | #[derive(Debug, Deserialize)]
204 | #[allow(dead_code)]
205 | struct ToolResult {
206 | tools: Vec<Tool>,
207 | }
208 |
209 | #[derive(Debug, Deserialize)]
210 | #[allow(dead_code)]
211 | struct Tool {
212 | name: String,
213 | description: String,
214 | input_schema: Option<serde_json::Value>,
215 | annotations: Option<ToolAnnotations>,
216 | }
217 |
```
--------------------------------------------------------------------------------
/src/servers/elasticsearch/mod.rs:
--------------------------------------------------------------------------------
```rust
1 | // Licensed to Elasticsearch B.V. under one or more contributor
2 | // license agreements. See the NOTICE file distributed with
3 | // this work for additional information regarding copyright
4 | // ownership. Elasticsearch B.V. licenses this file to you under
5 | // the Apache License, Version 2.0 (the "License"); you may
6 | // not use this file except in compliance with the License.
7 | // You may obtain a copy of the License at
8 | //
9 | // http://www.apache.org/licenses/LICENSE-2.0
10 | //
11 | // Unless required by applicable law or agreed to in writing,
12 | // software distributed under the License is distributed on an
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 | // KIND, either express or implied. See the License for the
15 | // specific language governing permissions and limitations
16 | // under the License.
17 |
18 | mod base_tools;
19 |
20 | use crate::servers::IncludeExclude;
21 | use crate::utils::none_if_empty_string;
22 | use elasticsearch::Elasticsearch;
23 | use elasticsearch::auth::Credentials;
24 | use elasticsearch::cert::CertificateValidation;
25 | use elasticsearch::http::Url;
26 | use elasticsearch::http::response::Response;
27 | use http::header::USER_AGENT;
28 | use http::request::Parts;
29 | use http::{HeaderValue, header};
30 | use indexmap::IndexMap;
31 | use rmcp::RoleServer;
32 | use rmcp::model::ToolAnnotations;
33 | use rmcp::service::RequestContext;
34 | use serde::de::DeserializeOwned;
35 | use serde::{Deserialize, Serialize};
36 | use serde_aux::field_attributes::deserialize_bool_from_anything;
37 | use std::borrow::Cow;
38 | use std::collections::HashMap;
39 |
40 | #[derive(Debug, Serialize, Deserialize)]
41 | pub struct ElasticsearchMcpConfig {
42 | /// Cluster URL
43 | pub url: String,
44 |
45 | /// API key
46 | #[serde(default, deserialize_with = "none_if_empty_string")]
47 | pub api_key: Option<String>,
48 |
49 | /// Username
50 | #[serde(default, deserialize_with = "none_if_empty_string")]
51 | pub username: Option<String>,
52 |
53 | /// Password
54 | #[serde(default, deserialize_with = "none_if_empty_string")]
55 | pub password: Option<String>,
56 |
57 | /// Should we skip SSL certificate verification?
58 | #[serde(default, deserialize_with = "deserialize_bool_from_anything")]
59 | pub ssl_skip_verify: bool,
60 |
61 | /// Search templates to expose as tools or resources
62 | #[serde(default)]
63 | pub tools: Tools,
64 |
65 | /// Prompts
66 | #[serde(default)]
67 | pub prompts: Vec<String>,
68 | // TODO: search as resources?
69 | }
70 |
71 | // A wrapper around an ES client that provides a client instance configured
72 | /// for a given request context (i.e. auth credentials)
73 | #[derive(Clone)]
74 | pub struct EsClientProvider(Elasticsearch);
75 |
76 | impl EsClientProvider {
77 | pub fn new(client: Elasticsearch) -> Self {
78 | EsClientProvider(client)
79 | }
80 |
81 | /// If the incoming request is a http request and has an `Authorization` header, use it
82 | /// to authenticate to the remote ES instance.
83 | pub fn get(&self, context: RequestContext<RoleServer>) -> Cow<'_, Elasticsearch> {
84 | let client = &self.0;
85 |
86 | let Some(mut auth) = context
87 | .extensions
88 | .get::<Parts>()
89 | .and_then(|p| p.headers.get(header::AUTHORIZATION))
90 | .and_then(|h| h.to_str().ok())
91 | else {
92 | // No auth
93 | return Cow::Borrowed(client);
94 | };
95 |
96 | // MCP inspector insists on sending a bearer token and prepends "Bearer" to the value provided
97 | if auth.starts_with("Bearer ApiKey ") || auth.starts_with("Bearer Basic ") {
98 | auth = auth.trim_start_matches("Bearer ");
99 | }
100 |
101 | let transport = client
102 | .transport()
103 | .clone_with_auth(Some(Credentials::AuthorizationHeader(auth.to_string())));
104 |
105 | Cow::Owned(Elasticsearch::new(transport))
106 | }
107 | }
108 |
109 | #[derive(Debug, Serialize, Deserialize, Default)]
110 | pub struct Tools {
111 | #[serde(flatten)]
112 | pub incl_excl: Option<IncludeExclude>,
113 | pub custom: HashMap<String, CustomTool>,
114 | }
115 |
116 | #[derive(Debug, Serialize, Deserialize)]
117 | #[serde(tag = "type", rename_all = "snake_case")]
118 | pub enum CustomTool {
119 | Esql(EsqlTool),
120 | SearchTemplate(SearchTemplateTool),
121 | }
122 |
123 | impl CustomTool {
124 | pub fn base(&self) -> &ToolBase {
125 | match self {
126 | CustomTool::Esql(esql) => &esql.base,
127 | CustomTool::SearchTemplate(search_template) => &search_template.base,
128 | }
129 | }
130 | }
131 |
132 | #[derive(Debug, Serialize, Deserialize)]
133 | pub struct ToolBase {
134 | pub description: String,
135 | pub parameters: IndexMap<String, schemars::schema::SchemaObject>,
136 | pub annotations: Option<ToolAnnotations>,
137 | }
138 |
139 | #[derive(Debug, Serialize, Deserialize)]
140 | pub struct EsqlTool {
141 | #[serde(flatten)]
142 | base: ToolBase,
143 | query: String,
144 | #[serde(default)]
145 | format: EsqlResultFormat,
146 | }
147 |
148 | #[derive(Debug, Serialize, Deserialize, Default)]
149 | #[serde(rename_all = "snake_case")]
150 | pub enum EsqlResultFormat {
151 | #[default]
152 | // Output as JSON, either as an array of objects or as a single object.
153 | Json,
154 | // If a single object with a single property, output only its value
155 | Value,
156 | //Csv,
157 | }
158 |
159 | #[derive(Debug, Serialize, Deserialize)]
160 | pub struct SearchTemplateTool {
161 | #[serde(flatten)]
162 | base: ToolBase,
163 | #[serde(flatten)]
164 | template: SearchTemplate,
165 | }
166 |
167 | #[derive(Debug, Serialize, Deserialize)]
168 | #[serde(rename_all = "snake_case")]
169 | pub enum SearchTemplate {
170 | TemplateId(String),
171 | Template(serde_json::Value), // or constrain to an object?
172 | }
173 |
174 | #[derive(Clone)]
175 | pub struct ElasticsearchMcp {}
176 |
177 | impl ElasticsearchMcp {
178 | pub fn new_with_config(config: ElasticsearchMcpConfig, container_mode: bool) -> anyhow::Result<base_tools::EsBaseTools> {
179 | let creds = if let Some(api_key) = config.api_key.clone() {
180 | Some(Credentials::EncodedApiKey(api_key))
181 | } else if let Some(username) = config.username.clone() {
182 | let pwd = config.password.clone().ok_or(anyhow::Error::msg("missing password"))?;
183 | Some(Credentials::Basic(username, pwd))
184 | } else {
185 | None
186 | };
187 |
188 | let url = config.url.as_str();
189 | if url.is_empty() {
190 | return Err(anyhow::Error::msg("Elasticsearch URL is empty"));
191 | }
192 |
193 | let mut url = Url::parse(url)?;
194 | if container_mode {
195 | rewrite_localhost(&mut url)?;
196 | }
197 |
198 | let pool = elasticsearch::http::transport::SingleNodeConnectionPool::new(url.clone());
199 | let mut transport = elasticsearch::http::transport::TransportBuilder::new(pool);
200 | if let Some(creds) = creds {
201 | transport = transport.auth(creds);
202 | }
203 | if config.ssl_skip_verify {
204 | transport = transport.cert_validation(CertificateValidation::None)
205 | }
206 | transport = transport.header(
207 | USER_AGENT,
208 | HeaderValue::from_str(&format!("elastic-mcp/{}", env!("CARGO_PKG_VERSION")))?,
209 | );
210 | let transport = transport.build()?;
211 | let es_client = Elasticsearch::new(transport);
212 |
213 | Ok(base_tools::EsBaseTools::new(es_client))
214 | }
215 | }
216 |
217 | //------------------------------------------------------------------------------------------------
218 | // Utilities
219 |
220 | /// Rewrite urls targeting `localhost` to a hostname that maps to the container host, if possible.
221 | ///
222 | /// The host name for the container host depends on the OCI runtime used. This is useful to accept
223 | /// Elasticsearch URLs like `http://localhost:9200`.
224 | fn rewrite_localhost(url: &mut Url) -> anyhow::Result<()> {
225 | use std::net::ToSocketAddrs;
226 | let aliases = &[
227 | "host.docker.internal", // Docker
228 | "host.containers.internal", // Podman, maybe others
229 | ];
230 |
231 | if let Some(host) = url.host_str() && host == "localhost" {
232 | for alias in aliases {
233 | if let Ok(mut alias_add) = (*alias, 80).to_socket_addrs() && alias_add.next().is_some() {
234 | url.set_host(Some(alias))?;
235 | tracing::info!("Container mode: using '{alias}' instead of 'localhost'");
236 | return Ok(());
237 | }
238 | }
239 | }
240 | tracing::warn!("Container mode: cound not find a replacement for 'localhost'");
241 | Ok(())
242 | }
243 |
244 | /// Map any error to an internal error of the MCP server
245 | pub fn internal_error(e: impl std::error::Error) -> rmcp::Error {
246 | rmcp::Error::internal_error(e.to_string(), None)
247 | }
248 |
249 | /// Return an error as an error response to the client, which may be able to take
250 | /// action to correct it. This should be refined to handle common error types such
251 | /// as index not found, which could be caused by the client hallucinating an index name.
252 | ///
253 | /// TODO (in rmcp): if rmcp::Error had a variant that accepts a CallToolResult, this would
254 | /// allow to use the '?' operator while sending a result to the client.
255 | pub fn handle_error(result: Result<Response, elasticsearch::Error>) -> Result<Response, rmcp::Error> {
256 | match result {
257 | Ok(resp) => resp.error_for_status_code(),
258 | Err(e) => {
259 | tracing::error!("Error: {:?}", &e);
260 | Err(e)
261 | }
262 | }
263 | .map_err(internal_error)
264 | }
265 |
266 | pub async fn read_json<T: DeserializeOwned>(
267 | response: Result<Response, elasticsearch::Error>,
268 | ) -> Result<T, rmcp::Error> {
269 | // let text = read_text(response).await?;
270 | // tracing::debug!("Received json {text}");
271 | // serde_json::from_str(&text).map_err(internal_error)
272 |
273 | let response = handle_error(response)?;
274 | response.json().await.map_err(internal_error)
275 | }
276 |
277 | #[allow(dead_code)]
278 | pub async fn read_text(result: Result<Response, elasticsearch::Error>) -> Result<String, rmcp::Error> {
279 | let response = handle_error(result)?;
280 | response.text().await.map_err(internal_error)
281 | }
282 |
```
--------------------------------------------------------------------------------
/src/servers/elasticsearch/base_tools.rs:
--------------------------------------------------------------------------------
```rust
1 | // Licensed to Elasticsearch B.V. under one or more contributor
2 | // license agreements. See the NOTICE file distributed with
3 | // this work for additional information regarding copyright
4 | // ownership. Elasticsearch B.V. licenses this file to you under
5 | // the Apache License, Version 2.0 (the "License"); you may
6 | // not use this file except in compliance with the License.
7 | // You may obtain a copy of the License at
8 | //
9 | // http://www.apache.org/licenses/LICENSE-2.0
10 | //
11 | // Unless required by applicable law or agreed to in writing,
12 | // software distributed under the License is distributed on an
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 | // KIND, either express or implied. See the License for the
15 | // specific language governing permissions and limitations
16 | // under the License.
17 |
18 | use crate::servers::elasticsearch::{EsClientProvider, read_json};
19 | use elasticsearch::cat::{CatIndicesParts, CatShardsParts};
20 | use elasticsearch::indices::IndicesGetMappingParts;
21 | use elasticsearch::{Elasticsearch, SearchParts};
22 | use indexmap::IndexMap;
23 | use rmcp::handler::server::tool::{Parameters, ToolRouter};
24 | use rmcp::model::{
25 | CallToolResult, Content, Implementation, JsonObject, ProtocolVersion, ServerCapabilities, ServerInfo,
26 | };
27 | use rmcp::service::RequestContext;
28 | use rmcp::{RoleServer, ServerHandler};
29 | use rmcp_macros::{tool, tool_handler, tool_router};
30 | use serde::{Deserialize, Serialize};
31 | use serde_aux::prelude::*;
32 | use serde_json::{Map, Value, json};
33 | use std::collections::HashMap;
34 |
35 | #[derive(Clone)]
36 | pub struct EsBaseTools {
37 | es_client: EsClientProvider,
38 | tool_router: ToolRouter<EsBaseTools>,
39 | }
40 |
41 | impl EsBaseTools {
42 | pub fn new(es_client: Elasticsearch) -> Self {
43 | Self {
44 | es_client: EsClientProvider::new(es_client),
45 | tool_router: Self::tool_router(),
46 | }
47 | }
48 | }
49 |
50 | #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
51 | struct ListIndicesParams {
52 | /// Index pattern of Elasticsearch indices to list
53 | pub index_pattern: String,
54 | }
55 |
56 | #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
57 | struct GetMappingsParams {
58 | /// Name of the Elasticsearch index to get mappings for
59 | index: String,
60 | }
61 |
62 | #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
63 | struct SearchParams {
64 | /// Name of the Elasticsearch index to search
65 | index: String,
66 |
67 | /// Name of the fields that need to be returned (optional)
68 | fields: Option<Vec<String>>,
69 |
70 | /// Complete Elasticsearch query DSL object that can include query, size, from, sort, etc.
71 | query_body: Map<String, Value>, // note: just Value doesn't work, as Claude would send a string
72 | }
73 |
74 | #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
75 | struct EsqlQueryParams {
76 | /// Complete Elasticsearch ES|QL query
77 | query: String,
78 | }
79 |
80 | #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
81 | struct GetShardsParams {
82 | /// Optional index name to get shard information for
83 | index: Option<String>,
84 | }
85 |
86 | #[tool_router]
87 | impl EsBaseTools {
88 | //---------------------------------------------------------------------------------------------
89 | /// Tool: list indices
90 | #[tool(
91 | description = "List all available Elasticsearch indices",
92 | annotations(title = "List ES indices", read_only_hint = true)
93 | )]
94 | async fn list_indices(
95 | &self,
96 | req_ctx: RequestContext<RoleServer>,
97 | Parameters(ListIndicesParams { index_pattern }): Parameters<ListIndicesParams>,
98 | ) -> Result<CallToolResult, rmcp::Error> {
99 | let es_client = self.es_client.get(req_ctx);
100 | let response = es_client
101 | .cat()
102 | .indices(CatIndicesParts::Index(&[&index_pattern]))
103 | .h(&["index", "status", "docs.count"])
104 | .format("json")
105 | .send()
106 | .await;
107 |
108 | let response: Vec<CatIndexResponse> = read_json(response).await?;
109 |
110 | Ok(CallToolResult::success(vec![
111 | Content::text(format!("Found {} indices:", response.len())),
112 | Content::json(response)?,
113 | ]))
114 | }
115 |
116 | //---------------------------------------------------------------------------------------------
117 | /// Tool: get mappings for an index
118 | #[tool(
119 | description = "Get field mappings for a specific Elasticsearch index",
120 | annotations(title = "Get ES index mappings", read_only_hint = true)
121 | )]
122 | async fn get_mappings(
123 | &self,
124 | req_ctx: RequestContext<RoleServer>,
125 | Parameters(GetMappingsParams { index }): Parameters<GetMappingsParams>,
126 | ) -> Result<CallToolResult, rmcp::Error> {
127 | let es_client = self.es_client.get(req_ctx);
128 | let response = es_client
129 | .indices()
130 | .get_mapping(IndicesGetMappingParts::Index(&[&index]))
131 | .send()
132 | .await;
133 |
134 | let response: MappingResponse = read_json(response).await?;
135 |
136 | // use the first mapping (we can have many if the name is a wildcard)
137 | let mapping = response.values().next().unwrap();
138 |
139 | Ok(CallToolResult::success(vec![
140 | Content::text(format!("Mappings for index {index}:")),
141 | Content::json(mapping)?,
142 | ]))
143 | }
144 |
145 | //---------------------------------------------------------------------------------------------
146 | /// Tool: search an index with the Query DSL
147 | ///
148 | /// The additional 'fields' parameter helps some LLMs that don't know about the `_source`
149 | /// request property to narrow down the data returned and reduce their context size
150 | #[tool(
151 | description = "Perform an Elasticsearch search with the provided query DSL.",
152 | annotations(title = "Elasticsearch search DSL query", read_only_hint = true)
153 | )]
154 | async fn search(
155 | &self,
156 | req_ctx: RequestContext<RoleServer>,
157 | Parameters(SearchParams {
158 | index,
159 | fields,
160 | query_body,
161 | }): Parameters<SearchParams>,
162 | ) -> Result<CallToolResult, rmcp::Error> {
163 | let es_client = self.es_client.get(req_ctx);
164 |
165 | let mut query_body = query_body;
166 |
167 | if let Some(fields) = fields {
168 | // Augment _source if it exists
169 | if let Some(Value::Array(values)) = query_body.get_mut("_source") {
170 | for field in fields.into_iter() {
171 | values.push(Value::String(field))
172 | }
173 | } else {
174 | query_body.insert("_source".to_string(), json!(fields));
175 | }
176 | }
177 |
178 | let response = es_client
179 | .search(SearchParts::Index(&[&index]))
180 | .body(query_body)
181 | .send()
182 | .await;
183 |
184 | let response: SearchResult = read_json(response).await?;
185 |
186 | let mut results: Vec<Content> = Vec::new();
187 |
188 | // Send result stats only if it's not pure aggregation results
189 | if response.aggregations.is_empty() || !response.hits.hits.is_empty() {
190 | let total = response
191 | .hits
192 | .total
193 | .map(|t| t.value.to_string())
194 | .unwrap_or("unknown".to_string());
195 |
196 | results.push(Content::text(format!(
197 | "Total results: {}, showing {}.",
198 | total,
199 | response.hits.hits.len()
200 | )));
201 | }
202 |
203 | // Original prototype sent a separate content for each document, it seems to confuse some LLMs
204 | // for hit in &response.hits.hits {
205 | // results.push(Content::json(&hit.source)?);
206 | // }
207 | if !response.hits.hits.is_empty() {
208 | let sources = response.hits.hits.iter().map(|hit| &hit.source).collect::<Vec<_>>();
209 | results.push(Content::json(&sources)?);
210 | }
211 |
212 | if !response.aggregations.is_empty() {
213 | results.push(Content::text("Aggregations results:"));
214 | results.push(Content::json(&response.aggregations)?);
215 | }
216 |
217 | Ok(CallToolResult::success(results))
218 | }
219 |
220 | //---------------------------------------------------------------------------------------------
221 | /// Tool: ES|QL
222 | #[tool(
223 | description = "Perform an Elasticsearch ES|QL query.",
224 | annotations(title = "Elasticsearch ES|QL query", read_only_hint = true)
225 | )]
226 | async fn esql(
227 | &self,
228 | req_ctx: RequestContext<RoleServer>,
229 | Parameters(EsqlQueryParams { query }): Parameters<EsqlQueryParams>,
230 | ) -> Result<CallToolResult, rmcp::Error> {
231 | let es_client = self.es_client.get(req_ctx);
232 |
233 | let request = EsqlQueryRequest { query };
234 |
235 | let response = es_client.esql().query().body(request).send().await;
236 | let response: EsqlQueryResponse = read_json(response).await?;
237 |
238 | // Transform response into an array of objects
239 | let mut objects: Vec<Value> = Vec::new();
240 | for row in response.values.into_iter() {
241 | let mut obj = Map::new();
242 | for (i, value) in row.into_iter().enumerate() {
243 | obj.insert(response.columns[i].name.clone(), value);
244 | }
245 | objects.push(Value::Object(obj));
246 | }
247 |
248 | Ok(CallToolResult::success(vec![
249 | Content::text("Results"),
250 | Content::json(objects)?,
251 | ]))
252 | }
253 |
254 | //---------------------------------------------------------------------------------------------
255 | // Tool: get shard information
256 | #[tool(
257 | description = "Get shard information for all or specific indices.",
258 | annotations(title = "Get ES shard information", read_only_hint = true)
259 | )]
260 | async fn get_shards(
261 | &self,
262 | req_ctx: RequestContext<RoleServer>,
263 | Parameters(GetShardsParams { index }): Parameters<GetShardsParams>,
264 | ) -> Result<CallToolResult, rmcp::Error> {
265 | let es_client = self.es_client.get(req_ctx);
266 |
267 | let indices: [&str; 1];
268 | let parts = match &index {
269 | Some(index) => {
270 | indices = [index];
271 | CatShardsParts::Index(&indices)
272 | }
273 | None => CatShardsParts::None,
274 | };
275 | let response = es_client
276 | .cat()
277 | .shards(parts)
278 | .format("json")
279 | .h(&["index", "shard", "prirep", "state", "docs", "store", "node"])
280 | .send()
281 | .await;
282 |
283 | let response: Vec<CatShardsResponse> = read_json(response).await?;
284 |
285 | Ok(CallToolResult::success(vec![
286 | Content::text(format!("Found {} shards:", response.len())),
287 | Content::json(response)?,
288 | ]))
289 | }
290 | }
291 |
292 | #[tool_handler]
293 | impl ServerHandler for EsBaseTools {
294 | fn get_info(&self) -> ServerInfo {
295 | ServerInfo {
296 | protocol_version: ProtocolVersion::V_2025_03_26,
297 | capabilities: ServerCapabilities::builder().enable_tools().build(),
298 | server_info: Implementation::from_build_env(),
299 | instructions: Some("Provides access to Elasticsearch".to_string()),
300 | }
301 | }
302 | }
303 |
304 | //-------------------------------------------------------------------------------------------------
305 | // Type definitions for ES request/responses (the Rust client doesn't have them yet) and tool responses.
306 |
307 | //----- Search request
308 |
309 | #[derive(Serialize, Deserialize)]
310 | pub struct SearchResult {
311 | pub hits: Hits,
312 | #[serde(default)]
313 | pub aggregations: IndexMap<String, Value>,
314 | }
315 |
316 | #[derive(Serialize, Deserialize)]
317 | pub struct Hits {
318 | pub total: Option<TotalHits>,
319 | pub hits: Vec<Hit>,
320 | }
321 |
322 | #[derive(Serialize, Deserialize)]
323 | pub struct TotalHits {
324 | pub value: u64,
325 | }
326 |
327 | #[derive(Serialize, Deserialize)]
328 | pub struct Hit {
329 | #[serde(rename = "_source")]
330 | pub source: Value,
331 | }
332 |
333 | //----- Cat responses
334 |
335 | #[derive(Serialize, Deserialize)]
336 | pub struct CatIndexResponse {
337 | pub index: String,
338 | pub status: String,
339 | #[serde(rename = "docs.count", deserialize_with = "deserialize_number_from_string")]
340 | pub doc_count: u64,
341 | }
342 |
343 | #[derive(Serialize, Deserialize)]
344 | pub struct CatShardsResponse {
345 | pub index: String,
346 | #[serde(deserialize_with = "deserialize_number_from_string")]
347 | pub shard: usize,
348 | pub prirep: String,
349 | pub state: String,
350 | #[serde(deserialize_with = "deserialize_option_number_from_string")]
351 | pub docs: Option<u64>,
352 | pub store: Option<String>,
353 | pub node: Option<String>,
354 | }
355 |
356 | //----- Index mappings
357 |
358 | pub type MappingResponse = HashMap<String, Mappings>;
359 |
360 | #[derive(Serialize, Deserialize)]
361 | pub struct Mappings {
362 | pub mappings: Mapping,
363 | }
364 |
365 | #[derive(Serialize, Deserialize)]
366 | pub struct Mapping {
367 | #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
368 | pub meta: Option<JsonObject>,
369 | properties: HashMap<String, MappingProperty>,
370 | }
371 |
372 | #[derive(Serialize, Deserialize)]
373 | pub struct MappingProperty {
374 | #[serde(rename = "type")]
375 | pub type_: String,
376 | #[serde(flatten)]
377 | pub settings: HashMap<String, serde_json::Value>,
378 | }
379 |
380 | //----- ES|QL
381 |
382 | #[derive(Serialize, Deserialize)]
383 | pub struct EsqlQueryRequest {
384 | pub query: String,
385 | }
386 |
387 | #[derive(Serialize, Deserialize)]
388 | pub struct Column {
389 | pub name: String,
390 | #[serde(rename = "type")]
391 | pub type_: String,
392 | }
393 |
394 | #[derive(Serialize, Deserialize)]
395 | pub struct EsqlQueryResponse {
396 | pub is_partial: Option<bool>,
397 | pub columns: Vec<Column>,
398 | pub values: Vec<Vec<Value>>,
399 | }
400 |
```