Build an MCP Server for Elasticsearch

Build a TypeScript MCP server that lets Claude query your local Elasticsearch logs.

Introduction

30 Second Summary

Application logs hold the answers to most debugging questions, but finding those answers usually means writing complex query syntax by hand. What if you could just ask an AI assistant to search your logs for you in plain English?

In this project, you will build a TypeScript server that connects Claude Desktop to your local Elasticsearch instance so you can query logs through natural conversation.

What You'll Build

An MCP server that exposes log querying tools to Claude Desktop via stdio transport, letting you ask natural language questions like "show me recent errors" and get real Elasticsearch results.

By the end of this project, you'll have:

  • A TypeScript MCP server project with secure Elasticsearch integration.
  • Three registered MCP tools for listing indices, searching logs with filters, and running aggregations.
  • A Claude Desktop configuration that launches your server and uses it for conversational log analysis.
  • Secret Mission: Add a get-document tool for drilling into specific log entries by ID.

Are there any prerequisites?

You need Node.js 18+ installed, a local Elasticsearch instance running with security enabled, and Claude Desktop installed on your machine. You should be comfortable working in a terminal and have basic familiarity with TypeScript or JavaScript.

Set Up the Project

Before you write any server logic, you need a properly configured TypeScript project with the right dependencies. This step creates the foundation that the MCP SDK, Elasticsearch client, and TypeScript compiler all depend on.

You will initialize an npm project, install the MCP SDK and Elasticsearch client libraries, and configure TypeScript for ES module output.

In this step, get ready to:
  • Initialize the project directory and install all dependencies.
  • Configure TypeScript and package.json for ES module output.
  • Create the source file structure and verify compilation.
Initialize the project and install dependencies

Every Node.js project starts with a directory and a package.json file that tracks metadata and dependencies.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, move into it, and initialize npm by running these commands:
mkdir elasticsearch-mcp-server
cd elasticsearch-mcp-server
npm init -y

What do these commands do?

  • mkdir creates a new folder called elasticsearch-mcp-server.
  • cd moves your terminal into that folder.
  • npm init -y generates a default package.json file that tracks your project's metadata and dependencies.
  • Confirm the folder and file exist by running:
ls

You should see package.json listed in the output.

  • Install the three runtime dependencies by running:
npm install @modelcontextprotocol/sdk@1.29.0 zod@3.24.4 @elastic/elasticsearch@8.18.2

What are these packages?

  • @modelcontextprotocol/sdk is the official MCP SDK that provides the server framework and stdio transport.
  • zod is a schema validation library that the MCP SDK uses to define tool input parameters.
  • @elastic/elasticsearch is the official Elasticsearch client for Node.js.

Seeing network errors?

Make sure you have an active internet connection. If you see permission errors, try running the command with sudo on macOS/Linux or running your terminal as Administrator on Windows.

help me fix npm install errors

  • Install the three development dependencies by running:
npm install --save-dev typescript@5.8.3 @types/node@22.15.21 tsx@4.19.4

What are dev dependencies?

Dev dependencies are packages needed during development but not in production. typescript compiles your code, @types/node adds type definitions for Node.js APIs, and tsx lets you run TypeScript files directly without a separate compile step.

Now you need to configure two files: package.json (to enable ES modules and add scripts) and tsconfig.json (to set compiler options).

  • Open package.json in your code editor and replace its entire contents with the following:
{
  "name": "elasticsearch-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "start": "node build/index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "1.29.0",
    "zod": "3.24.4",
    "@elastic/elasticsearch": "8.18.2"
  },
  "devDependencies": {
    "typescript": "5.8.3",
    "@types/node": "22.15.21",
    "tsx": "4.19.4"
  }
}

What did you change?

  • "type": "module" tells Node.js to treat .js files as ES modules instead of CommonJS. The MCP SDK requires this.
  • The build script compiles TypeScript to JavaScript. The dev script runs TypeScript directly for development. The start script runs the compiled output.
  • Save package.json.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

{
  "name": "elasticsearch-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "start": "node build/index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "1.29.0",
    "zod": "3.24.4",
    "@elastic/elasticsearch": "8.18.2"
  },
  "devDependencies": {
    "typescript": "5.8.3",
    "@types/node": "22.15.21",
    "tsx": "4.19.4"
  }
}
  • Create a new file called tsconfig.json in the project root (the elasticsearch-mcp-server folder) and add the following:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

What does this configure?

  • target and module set the output to ES2022 JavaScript with Node16 module syntax.
  • outDir sends compiled files to a build/ folder. rootDir tells the compiler your source lives in src/.
  • strict enables all strict type-checking options, catching errors at compile time instead of runtime.
  • Save tsconfig.json.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Seeing JSON syntax errors in your editor?

Make sure there are no trailing commas after the last item in any object or array. JSON does not allow trailing commas.

help me fix JSON syntax errors in my tsconfig.json

Create the project file structure

The TypeScript compiler expects source files inside the src/ directory you specified in tsconfig.json.

  • Create the src directory and a placeholder entry point by running these commands:
mkdir src
echo "// placeholder entry point" > src/index.ts
  • Confirm the file was created by running:
ls src

You should see index.ts in the output.

  • Verify the project compiles without errors by running:
npx tsc --noEmit

If compilation succeeds, the command produces no output and returns silently to your prompt. No news is good news.

Seeing compilation errors?

Check that your tsconfig.json has "rootDir": "./src" and that the src/index.ts file exists. Also confirm package.json has "type": "module".

help me fix TypeScript compilation errors in my MCP server project

Your project foundation is in place. Next up, you will build the Elasticsearch client and register your first MCP tool.

Build the Elasticsearch Client and List-Indices Tool

Your project compiles and all dependencies are installed. Now it's time to write the actual server code that connects to Elasticsearch and exposes tools to Claude.

You'll create a client module that authenticates with your local Elasticsearch instance, then build an MCP server skeleton with its first tool. This tool lists all available indices so Claude can discover what data is queryable.

In this step, get ready to:
  • Create an Elasticsearch client module with environment-based authentication.
  • Build an MCP server with a list-indices tool.
  • Test that the server starts without errors.
Create the Elasticsearch client module

Your MCP server needs a way to talk to Elasticsearch. The cleanest approach is a dedicated module that reads connection credentials from environment variables and exports a configured client instance.

  • Create a new file called src/elasticsearch.ts and paste the following code:
import { Client } from "@elastic/elasticsearch";

// Read connection details from environment variables
const ES_URL = process.env.ES_URL || "http://localhost:9200";
const ES_USERNAME = process.env.ES_USERNAME || "elastic";
const ES_PASSWORD = process.env.ES_PASSWORD || "";

if (!ES_PASSWORD) {
  console.error(
    "Warning: ES_PASSWORD environment variable is not set. Connection may fail."
  );
}

// Export the configured Elasticsearch client
export const esClient = new Client({
  node: ES_URL,
  auth: {
    username: ES_USERNAME,
    password: ES_PASSWORD,
  },
  tls: {
    rejectUnauthorized: false,
  },
});

What does this code do?

This module creates and exports a single Elasticsearch client instance that the rest of your server will share.

  • ES_URL, ES_USERNAME, and ES_PASSWORD are read from environment variables. This keeps credentials out of your source code.
  • The tls: { rejectUnauthorized: false } setting allows connections to local Elasticsearch instances that use self-signed certificates.
  • If no password is set, the module logs a warning to stderr so you know authentication might fail.
  • Save src/elasticsearch.ts.

You won't see a change yet. The next substep imports this client into the server.

Seeing a red underline on the import?

If your editor shows an error on import { Client } from "@elastic/elasticsearch", make sure you ran npm install in the previous step. The node_modules folder must exist for TypeScript to resolve the package.

help me fix an import error in my Elasticsearch client module

✔️ Awesome, I've got everything!

Great. Make sure you saved src/elasticsearch.ts before moving on.

ⓧ I'd like to double check the full code

import { Client } from "@elastic/elasticsearch";

const ES_URL = process.env.ES_URL || "http://localhost:9200";
const ES_USERNAME = process.env.ES_USERNAME || "elastic";
const ES_PASSWORD = process.env.ES_PASSWORD || "";

if (!ES_PASSWORD) {
  console.error(
    "Warning: ES_PASSWORD environment variable is not set. Connection may fail."
  );
}

export const esClient = new Client({
  node: ES_URL,
  auth: {
    username: ES_USERNAME,
    password: ES_PASSWORD,
  },
  tls: {
    rejectUnauthorized: false,
  },
});
Build the MCP server and register list-indices

With the Elasticsearch client ready, you can now build the MCP server itself. The server uses stdio transport, which means Claude Desktop will launch it as a subprocess and communicate over stdin/stdout.

  • Replace the placeholder content in src/index.ts with this server skeleton:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { esClient } from "./elasticsearch.js";

// Create the MCP server instance
const server = new McpServer({
  name: "elasticsearch-logs",
  version: "1.0.0",
});

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Elasticsearch MCP Server running on stdio");
}

main();

What does this code do?

  • McpServer is the core class from the MCP SDK that manages tool registration and message handling.
  • StdioServerTransport reads JSON-RPC messages from stdin and writes responses to stdout. This is how Claude Desktop communicates with local MCP servers.
  • The console.error call logs to stderr deliberately. MCP servers must never write to stdout because stdout is reserved for protocol messages.
  • The import uses ./elasticsearch.js (not .ts) because Node16 module resolution requires the compiled output extension in import paths.

This skeleton connects to stdin/stdout but has no tools registered yet. The next chunk adds the first one.

  • Between the server instantiation (the new McpServer({...}) block) and the main() function, add the list-indices tool:
// Tool: list-indices
server.tool(
  "list-indices",
  "List all available Elasticsearch indices with doc counts and sizes",
  {},
  async () => {
    try {
      const response = await esClient.cat.indices({
        format: "json",
        h: "index,docs.count,store.size,health,status",
      });

      if (!response || response.length === 0) {
        return {
          content: [{ type: "text", text: "No indices found." }],
        };
      }

      const lines = response
        .filter((idx: any) => !idx.index?.startsWith("."))
        .map(
          (idx: any) =>
            `${idx.index}  docs: ${idx["docs.count"]}  size: ${idx["store.size"]}  health: ${idx.health}`
        );

      return {
        content: [
          {
            type: "text",
            text: `Found ${lines.length} indices:\n\n${lines.join("\n")}`,
          },
        ],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error listing indices: ${error.message}`,
          },
        ],
      };
    }
  }
);

What does this tool do?

The server.tool() method registers a tool that Claude can call. It takes a name, description, input schema (empty {} here since no parameters are needed), and a handler function.

  • esClient.cat.indices() calls the Elasticsearch Cat Indices API, which returns metadata about every index.
  • The .filter() removes system indices (those starting with a dot) so Claude only sees your application indices.
  • The try/catch returns a helpful error message as tool content instead of crashing the server.
  • Save src/index.ts.
  • Confirm the project has no type errors by running:
npx tsc --noEmit

You should see no output. No output means no errors.

Seeing type errors?

  • If you see an error about ./elasticsearch.js not being found, make sure src/elasticsearch.ts exists and is saved. TypeScript resolves .js imports to the corresponding .ts file during compilation.
  • If you see errors about missing modules like @modelcontextprotocol/sdk, check that your node_modules folder exists in the project root.

help me fix TypeScript compilation errors in my MCP server

✔️ Awesome, I've got everything!

Great. Make sure src/index.ts is saved before moving on.

ⓧ I'd like to double check the full code

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { esClient } from "./elasticsearch.js";

const server = new McpServer({
  name: "elasticsearch-logs",
  version: "1.0.0",
});

// Tool: list-indices
server.tool(
  "list-indices",
  "List all available Elasticsearch indices with doc counts and sizes",
  {},
  async () => {
    try {
      const response = await esClient.cat.indices({
        format: "json",
        h: "index,docs.count,store.size,health,status",
      });

      if (!response || response.length === 0) {
        return {
          content: [{ type: "text", text: "No indices found." }],
        };
      }

      const lines = response
        .filter((idx: any) => !idx.index?.startsWith("."))
        .map(
          (idx: any) =>
            `${idx.index}  docs: ${idx["docs.count"]}  size: ${idx["store.size"]}  health: ${idx.health}`
        );

      return {
        content: [
          {
            type: "text",
            text: `Found ${lines.length} indices:\n\n${lines.join("\n")}`,
          },
        ],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error listing indices: ${error.message}`,
          },
        ],
      };
    }
  }
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Elasticsearch MCP Server running on stdio");
}

main();
Test that the server starts

An MCP server using stdio transport waits for JSON-RPC messages on stdin. When you run it directly in the terminal, it will start and then appear to hang. That is the expected behavior.

  • Start the server by running:
npx tsx src/index.ts
  • You should see Elasticsearch MCP Server running on stdio printed in your terminal. The process then waits for input.
  • Press Ctrl+C to stop the server.

Why does the server appear to hang?

Stdio transport means the server reads from stdin and writes to stdout. When Claude Desktop launches this server as a subprocess, it sends JSON-RPC messages to stdin. Running it manually in your terminal, there is no client sending messages, so the server simply waits.

Seeing the stderr message and no crash confirms your server is wired up correctly.

Seeing an error instead of the startup message?

  • If you see a module resolution error, check that your package.json contains "type": "module". ESM imports require this setting.
  • If you see a connection warning about ES_PASSWORD, that is expected for now. The warning comes from your elasticsearch.ts module. You will provide credentials via environment variables when connecting through Claude Desktop later.

help me debug my MCP server startup error

Your MCP server is alive and responding. Next up, you'll add the search-logs tool that lets Claude run full-text queries with time and level filters.

Implement the Search-Logs Tool

Your MCP server can list indices, but listing is just the starting point. The real value comes from searching those logs with flexible filters.

In this step, you will register a search-logs tool that builds Elasticsearch queries dynamically based on optional parameters. Claude will be able to call this tool with any combination of full-text search, time filtering, and log level filtering.

In this step, get ready to:
  • Register the search-logs tool with a Zod schema defining five parameters.
  • Build a dynamic Elasticsearch bool query from the parameters.
  • Format results and handle errors gracefully.
Register the search-logs tool

The search-logs tool needs to handle several optional parameters and combine them into a single Elasticsearch query. The Query DSL bool query is designed for exactly this. It lets you combine scoring clauses (must) with non-scoring filters (filter).

  • In src/index.ts, find the closing ); of the list-indices tool registration (just above the async function main() line).
  • Add the following search-logs tool registration below it:
// Tool: search-logs
server.tool(
  "search-logs",
  "Search logs in an Elasticsearch index with optional full-text query, time range, and log level filter",
  {
    index: z.string().describe("The index or data stream to search"),
    query: z
      .string()
      .optional()
      .describe("Full-text search query string"),
    timeRange: z
      .string()
      .optional()
      .describe("Time range like 15m, 1h, 24h, 7d"),
    level: z
      .string()
      .optional()
      .describe("Log level filter, e.g. error, warn, info"),
    size: z
      .number()
      .optional()
      .default(20)
      .describe("Number of results to return, defaults to 20"),
  },
  async ({ index, query, timeRange, level, size }) => {
    try {
      const must: any[] = [];
      const filter: any[] = [];

      if (query) {
        must.push({
          multi_match: {
            query,
            fields: ["message", "error.message", "log.logger", "*"],
          },
        });
      }

      if (timeRange) {
        filter.push({
          range: {
            "@timestamp": {
              gte: `now-${timeRange}`,
              lte: "now",
            },
          },
        });
      }

      if (level) {
        filter.push({
          term: { "log.level": level.toLowerCase() },
        });
      }

      const body: any = {
        size,
        sort: [{ "@timestamp": { order: "desc" } }],
      };

      if (must.length > 0 || filter.length > 0) {
        body.query = {
          bool: {
            ...(must.length > 0 && { must }),
            ...(filter.length > 0 && { filter }),
          },
        };
      }

      const response = await esClient.search({
        index,
        body,
      });

      const hits = response.hits.hits;
      const total =
        typeof response.hits.total === "number"
          ? response.hits.total
          : response.hits.total?.value ?? 0;

      if (hits.length === 0) {
        return {
          content: [
            {
              type: "text",
              text: `No results found in index "${index}" for the given criteria.`,
            },
          ],
        };
      }

      const formatted = hits.map((hit: any) => {
        const src = hit._source;
        const timestamp = src["@timestamp"] || "unknown time";
        const logLevel = src.log?.level || src.level || "\u2014";
        const message = src.message || JSON.stringify(src).slice(0, 200);
        return `[${timestamp}] [${logLevel}] ${message}`;
      });

      const header = `Found ${total} total hits. Showing ${hits.length}:\n`;
      return {
        content: [
          {
            type: "text",
            text: header + "\n" + formatted.join("\n\n"),
          },
        ],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error searching index "${index}": ${error.message}`,
          },
        ],
      };
    }
  }
);

What does this code do?

This tool registration has four key sections:

  • Zod schema defines the five parameters Claude can pass. Only index is required. The .describe() strings tell Claude what each parameter does.
  • Dynamic query building adds clauses only when the parameter is present. Full-text search goes into must (affects relevance scoring). Time range and level go into filter (yes/no matching, no scoring).
  • Result formatting extracts each hit's timestamp, level, and message into a readable line. It also reports the total hit count so Claude knows if results were truncated.
  • Error handling returns error messages as tool content instead of throwing exceptions. This ensures Claude always gets a response it can relay to you.

Why multi_match instead of match?

Log entries store text in different fields depending on the logging framework. A multi_match searches across message, error.message, log.logger, and a wildcard * to catch everything. This means Claude's search query will find relevant logs regardless of which field the text lives in.

Verify the server compiles
  • Save src/index.ts.
  • Check that TypeScript finds no errors by running:
npx tsc --noEmit

You should see no output at all. Silence means success for the TypeScript compiler.

  • Confirm the server still starts by running:
npx tsx src/index.ts

The server will wait for stdin input (this is expected for stdio transport). Press Ctrl+C to stop it.

Seeing a TypeScript error?

  • Check that you pasted the new tool AFTER the closing ); of the list-indices tool and BEFORE async function main().
  • Make sure you have the import { z } from "zod" line at the top of the file. It was added in the previous step.
  • Look for mismatched curly braces or parentheses. The tool registration has nested objects inside server.tool() that must all close correctly.

Help me debug a TypeScript error in my MCP server search-logs tool registration.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/index.ts.

ⓧ I'd like to double check the full code

Here is the full src/index.ts file including both the list-indices and search-logs tools:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { esClient } from "./elasticsearch.js";

const server = new McpServer({
  name: "elasticsearch-logs",
  version: "1.0.0",
});

// Tool: list-indices
server.tool(
  "list-indices",
  "List all available Elasticsearch indices with doc counts and sizes",
  {},
  async () => {
    try {
      const response = await esClient.cat.indices({
        format: "json",
        h: "index,docs.count,store.size,health,status",
      });

      if (!response || response.length === 0) {
        return {
          content: [{ type: "text", text: "No indices found." }],
        };
      }

      const lines = response
        .filter((idx: any) => !idx.index?.startsWith("."))
        .map(
          (idx: any) =>
            `${idx.index}  docs: ${idx["docs.count"]}  size: ${idx["store.size"]}  health: ${idx.health}`
        );

      return {
        content: [
          {
            type: "text",
            text: `Found ${lines.length} indices:\n\n${lines.join("\n")}`,
          },
        ],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error listing indices: ${error.message}`,
          },
        ],
      };
    }
  }
);

// Tool: search-logs
server.tool(
  "search-logs",
  "Search logs in an Elasticsearch index with optional full-text query, time range, and log level filter",
  {
    index: z.string().describe("The index or data stream to search"),
    query: z
      .string()
      .optional()
      .describe("Full-text search query string"),
    timeRange: z
      .string()
      .optional()
      .describe("Time range like 15m, 1h, 24h, 7d"),
    level: z
      .string()
      .optional()
      .describe("Log level filter, e.g. error, warn, info"),
    size: z
      .number()
      .optional()
      .default(20)
      .describe("Number of results to return, defaults to 20"),
  },
  async ({ index, query, timeRange, level, size }) => {
    try {
      const must: any[] = [];
      const filter: any[] = [];

      if (query) {
        must.push({
          multi_match: {
            query,
            fields: ["message", "error.message", "log.logger", "*"],
          },
        });
      }

      if (timeRange) {
        filter.push({
          range: {
            "@timestamp": {
              gte: `now-${timeRange}`,
              lte: "now",
            },
          },
        });
      }

      if (level) {
        filter.push({
          term: { "log.level": level.toLowerCase() },
        });
      }

      const body: any = {
        size,
        sort: [{ "@timestamp": { order: "desc" } }],
      };

      if (must.length > 0 || filter.length > 0) {
        body.query = {
          bool: {
            ...(must.length > 0 && { must }),
            ...(filter.length > 0 && { filter }),
          },
        };
      }

      const response = await esClient.search({
        index,
        body,
      });

      const hits = response.hits.hits;
      const total =
        typeof response.hits.total === "number"
          ? response.hits.total
          : response.hits.total?.value ?? 0;

      if (hits.length === 0) {
        return {
          content: [
            {
              type: "text",
              text: `No results found in index "${index}" for the given criteria.`,
            },
          ],
        };
      }

      const formatted = hits.map((hit: any) => {
        const src = hit._source;
        const timestamp = src["@timestamp"] || "unknown time";
        const logLevel = src.log?.level || src.level || "\u2014";
        const message = src.message || JSON.stringify(src).slice(0, 200);
        return `[${timestamp}] [${logLevel}] ${message}`;
      });

      const header = `Found ${total} total hits. Showing ${hits.length}:\n`;
      return {
        content: [
          {
            type: "text",
            text: header + "\n" + formatted.join("\n\n"),
          },
        ],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error searching index "${index}": ${error.message}`,
          },
        ],
      };
    }
  }
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Elasticsearch MCP Server running on stdio");
}

main();

Your server now has a powerful search tool that Claude can call with any combination of parameters. Next, you will add an aggregation tool for summarizing log patterns and connect everything to Claude Desktop.

Implement Aggregate-Logs and Connect to Claude Desktop

Your MCP server can search individual log entries. But sometimes you need a bird's eye view of your data.

In this step, you will add an aggregation tool that summarizes log data into counts and distributions. Then you will compile the server, configure Claude Desktop to launch it, and verify all three tools are working end to end.

In this step, get ready to:
  • Register an aggregate-logs tool that builds Elasticsearch aggregation queries dynamically.
  • Build the project to produce a runnable JavaScript file.
  • Configure Claude Desktop to launch your MCP server and verify all tools appear.
Register the aggregate-logs tool

Aggregations let you ask summary questions about your logs. Instead of fetching individual entries, you can ask "what are the top error types?" or "how many logs per hour?"

  • In src/index.ts, find the async function main() block near the bottom of the file.
  • Add the following tool registration directly above the async function main() line:
// Tool: aggregate-logs
server.tool(
  "aggregate-logs",
  "Run aggregations on log data to get summaries like error counts by level, top services, or counts over time",
  {
    index: z.string().describe("The index or data stream to aggregate"),
    field: z
      .string()
      .describe(
        "The field to aggregate on, e.g. log.level, service.name, host.name"
      ),
    timeRange: z
      .string()
      .optional()
      .describe("Time range like 15m, 1h, 24h, 7d"),
    type: z
      .enum(["terms", "date_histogram"])
      .default("terms")
      .describe("Aggregation type: terms for top values, date_histogram for counts over time"),
  },
  async ({ index, field, timeRange, type }) => {
    try {
      const body: any = {
        size: 0,
      };

      if (timeRange) {
        body.query = {
          range: {
            "@timestamp": {
              gte: `now-${timeRange}`,
              lte: "now",
            },
          },
        };
      }

      if (type === "terms") {
        body.aggs = {
          result: {
            terms: {
              field,
              size: 20,
            },
          },
        };
      } else {
        body.aggs = {
          result: {
            date_histogram: {
              field: "@timestamp",
              calendar_interval: getInterval(timeRange),
            },
          },
        };
      }

      const response = await esClient.search({
        index,
        body,
      });

      const buckets = (response.aggregations?.result as any)?.buckets || [];
      const total =
        typeof response.hits.total === "number"
          ? response.hits.total
          : response.hits.total?.value ?? 0;

      if (buckets.length === 0) {
        return {
          content: [
            {
              type: "text",
              text: `No aggregation results for field "${field}" in index "${index}".`,
            },
          ],
        };
      }

      let formatted: string;

      if (type === "terms") {
        const lines = buckets.map((b: any) => {
          const pct = total > 0 ? ((b.doc_count / total) * 100).toFixed(1) : "0";
          return `  ${b.key}: ${b.doc_count} (${pct}%)`;
        });
        formatted = `Aggregation on "${field}" (${total} total docs):\n\n${lines.join("\n")}`;
      } else {
        const lines = buckets.map((b: any) => {
          const date = b.key_as_string || new Date(b.key).toISOString();
          return `  ${date}: ${b.doc_count}`;
        });
        formatted = `Date histogram on "${field}" (${total} total docs):\n\n${lines.join("\n")}`;
      }

      return {
        content: [{ type: "text", text: formatted }],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error aggregating index "${index}": ${error.message}`,
          },
        ],
      };
    }
  }
);

What does this code do?

This tool accepts four parameters: the index to query, a field to aggregate on (like log.level or service.name), an optional time range filter, and the aggregation type.

For terms aggregations, it groups logs by unique values in the field and returns the top 20 buckets with counts and percentages. For date_histogram aggregations, it groups logs by time intervals and returns how many logs occurred in each period.

Setting size: 0 in the query body tells Elasticsearch to skip returning individual documents. You only need the aggregation results.

The tool references a getInterval helper that does not exist yet. You will add it next.

  • Add the following helper function directly below the closing ); of the aggregate-logs tool and above async function main():
function getInterval(timeRange?: string): string {
  if (!timeRange) return "1h";
  const unit = timeRange.slice(-1);
  const value = parseInt(timeRange.slice(0, -1), 10);
  if (unit === "m" && value <= 60) return "1m";
  if (unit === "h" && value <= 6) return "10m";
  if (unit === "h" && value <= 24) return "1h";
  if (unit === "d" && value <= 7) return "1d";
  return "1d";
}

What does getInterval do?

This function picks an appropriate time bucket size based on the requested time range. Shorter ranges get finer granularity (every minute for ranges under 60 minutes), while longer ranges use wider buckets (every hour or every day).

This prevents the aggregation from returning hundreds of tiny buckets for a week-long range or one giant bucket for a 15-minute range.

  • Save src/index.ts.
  • Verify the file compiles without errors by running:
npx tsc --noEmit

You should see no output, which means zero type errors.

Seeing type errors?

  • Check that the getInterval function is placed outside the server.tool() call, at the module level.
  • Make sure you did not accidentally delete the closing }); of the search-logs tool above.
  • Confirm the aggregate-logs tool is above async function main(), not inside it.

Help me fix TypeScript compilation errors in my MCP server

✔️ Awesome, I've got everything!

Great. Double check you have saved src/index.ts before continuing.

ⓧ I'd like to double check the full code

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { esClient } from "./elasticsearch.js";

const server = new McpServer({
  name: "elasticsearch-logs",
  version: "1.0.0",
});

// Tool: list-indices
server.tool(
  "list-indices",
  "List all available Elasticsearch indices with doc counts and sizes",
  {},
  async () => {
    try {
      const response = await esClient.cat.indices({
        format: "json",
        h: "index,docs.count,store.size,health,status",
      });

      if (!response || response.length === 0) {
        return {
          content: [{ type: "text", text: "No indices found." }],
        };
      }

      const lines = response
        .filter((idx: any) => !idx.index?.startsWith("."))
        .map(
          (idx: any) =>
            `${idx.index}  docs: ${idx["docs.count"]}  size: ${idx["store.size"]}  health: ${idx.health}`
        );

      return {
        content: [
          {
            type: "text",
            text: `Found ${lines.length} indices:\n\n${lines.join("\n")}`,
          },
        ],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error listing indices: ${error.message}`,
          },
        ],
      };
    }
  }
);

// Tool: search-logs
server.tool(
  "search-logs",
  "Search logs in an Elasticsearch index with optional full-text query, time range, and log level filter",
  {
    index: z.string().describe("The index or data stream to search"),
    query: z
      .string()
      .optional()
      .describe("Full-text search query string"),
    timeRange: z
      .string()
      .optional()
      .describe("Time range like 15m, 1h, 24h, 7d"),
    level: z
      .string()
      .optional()
      .describe("Log level filter, e.g. error, warn, info"),
    size: z
      .number()
      .optional()
      .default(20)
      .describe("Number of results to return, defaults to 20"),
  },
  async ({ index, query, timeRange, level, size }) => {
    try {
      const must: any[] = [];
      const filter: any[] = [];

      if (query) {
        must.push({
          multi_match: {
            query,
            fields: ["message", "error.message", "log.logger", "*"],
          },
        });
      }

      if (timeRange) {
        filter.push({
          range: {
            "@timestamp": {
              gte: `now-${timeRange}`,
              lte: "now",
            },
          },
        });
      }

      if (level) {
        filter.push({
          term: { "log.level": level.toLowerCase() },
        });
      }

      const body: any = {
        size,
        sort: [{ "@timestamp": { order: "desc" } }],
      };

      if (must.length > 0 || filter.length > 0) {
        body.query = {
          bool: {
            ...(must.length > 0 && { must }),
            ...(filter.length > 0 && { filter }),
          },
        };
      }

      const response = await esClient.search({
        index,
        body,
      });

      const hits = response.hits.hits;
      const total =
        typeof response.hits.total === "number"
          ? response.hits.total
          : response.hits.total?.value ?? 0;

      if (hits.length === 0) {
        return {
          content: [
            {
              type: "text",
              text: `No results found in index "${index}" for the given criteria.`,
            },
          ],
        };
      }

      const formatted = hits.map((hit: any) => {
        const src = hit._source;
        const timestamp = src["@timestamp"] || "unknown time";
        const logLevel = src.log?.level || src.level || "\u2014";
        const message = src.message || JSON.stringify(src).slice(0, 200);
        return `[${timestamp}] [${logLevel}] ${message}`;
      });

      const header = `Found ${total} total hits. Showing ${hits.length}:\n`;
      return {
        content: [
          {
            type: "text",
            text: header + "\n" + formatted.join("\n\n"),
          },
        ],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error searching index "${index}": ${error.message}`,
          },
        ],
      };
    }
  }
);

// Tool: aggregate-logs
server.tool(
  "aggregate-logs",
  "Run aggregations on log data to get summaries like error counts by level, top services, or counts over time",
  {
    index: z.string().describe("The index or data stream to aggregate"),
    field: z
      .string()
      .describe(
        "The field to aggregate on, e.g. log.level, service.name, host.name"
      ),
    timeRange: z
      .string()
      .optional()
      .describe("Time range like 15m, 1h, 24h, 7d"),
    type: z
      .enum(["terms", "date_histogram"])
      .default("terms")
      .describe("Aggregation type: terms for top values, date_histogram for counts over time"),
  },
  async ({ index, field, timeRange, type }) => {
    try {
      const body: any = {
        size: 0,
      };

      if (timeRange) {
        body.query = {
          range: {
            "@timestamp": {
              gte: `now-${timeRange}`,
              lte: "now",
            },
          },
        };
      }

      if (type === "terms") {
        body.aggs = {
          result: {
            terms: {
              field,
              size: 20,
            },
          },
        };
      } else {
        body.aggs = {
          result: {
            date_histogram: {
              field: "@timestamp",
              calendar_interval: getInterval(timeRange),
            },
          },
        };
      }

      const response = await esClient.search({
        index,
        body,
      });

      const buckets = (response.aggregations?.result as any)?.buckets || [];
      const total =
        typeof response.hits.total === "number"
          ? response.hits.total
          : response.hits.total?.value ?? 0;

      if (buckets.length === 0) {
        return {
          content: [
            {
              type: "text",
              text: `No aggregation results for field "${field}" in index "${index}".`,
            },
          ],
        };
      }

      let formatted: string;

      if (type === "terms") {
        const lines = buckets.map((b: any) => {
          const pct = total > 0 ? ((b.doc_count / total) * 100).toFixed(1) : "0";
          return `  ${b.key}: ${b.doc_count} (${pct}%)`;
        });
        formatted = `Aggregation on "${field}" (${total} total docs):\n\n${lines.join("\n")}`;
      } else {
        const lines = buckets.map((b: any) => {
          const date = b.key_as_string || new Date(b.key).toISOString();
          return `  ${date}: ${b.doc_count}`;
        });
        formatted = `Date histogram on "${field}" (${total} total docs):\n\n${lines.join("\n")}`;
      }

      return {
        content: [{ type: "text", text: formatted }],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error aggregating index "${index}": ${error.message}`,
          },
        ],
      };
    }
  }
);

function getInterval(timeRange?: string): string {
  if (!timeRange) return "1h";
  const unit = timeRange.slice(-1);
  const value = parseInt(timeRange.slice(0, -1), 10);
  if (unit === "m" && value <= 60) return "1m";
  if (unit === "h" && value <= 6) return "10m";
  if (unit === "h" && value <= 24) return "1h";
  if (unit === "d" && value <= 7) return "1d";
  return "1d";
}

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Elasticsearch MCP Server running on stdio");
}

main();
Build the project and configure Claude Desktop

Claude Desktop launches MCP servers as local subprocesses using compiled JavaScript. You need to compile your TypeScript to the build/ directory, then tell Claude Desktop where to find the output.

  • Compile the project by running:
npm run build
  • Confirm the compiled output exists by running:
ls build/index.js

You should see build/index.js listed in the output.

Build failed?

  • If you see TypeScript errors, run npx tsc --noEmit to see the full error list with line numbers.
  • Check that tsconfig.json has "outDir": "./build" and "rootDir": "./src".

Help me fix my TypeScript build errors for the Elasticsearch MCP server

Now you need to find the absolute path to your compiled server file. Claude Desktop requires an absolute path because its working directory is undefined when it launches subprocesses.

  • Get the absolute path to your project by running:
pwd

Copy the output. You will use it in the next step.

  • Open the Claude Desktop configuration file. You can find it at:

macOS

  • Open this file in your editor:
open ~/Library/Application\ Support/Claude/claude_desktop_config.json

File does not exist?

If the file does not exist yet, open Claude Desktop first. Click the Developer tab in the left sidebar, then click Edit Config. This creates the file for you.

Windows

  • Open this file in your editor:
notepad $env:APPDATA\Claude\claude_desktop_config.json

File does not exist?

If the file does not exist yet, open Claude Desktop first. Click the Developer tab in the left sidebar, then click Edit Config. This creates the file for you.

  • Add the following entry inside the mcpServers object (or create the object if the file is empty):
{
  "mcpServers": {
    "elasticsearch-logs": {
      "command": "node",
      "args": ["[[PROJECT_PATH="/absolute/path/to/elasticsearch-mcp-server"]]/build/index.js"],
      "env": {
        "ES_URL": "http://localhost:9200",
        "ES_USERNAME": "elastic",
        "ES_PASSWORD": "your-elasticsearch-password"
      }
    }
  }
}

What does this configuration do?

The command and args fields tell Claude Desktop to run node build/index.js when it wants to use this server. The env object passes your Elasticsearch credentials to the server process as environment variables.

MCP servers launched via stdio inherit only a limited set of environment variables from the system. The env key ensures your credentials reach the server regardless of how Claude Desktop was launched.

  • Replace the path in args with the absolute path from your pwd output followed by /build/index.js.
  • Replace your-elasticsearch-password with your actual Elasticsearch password.
  • Save the configuration file.
  • Quit Claude Desktop completely and reopen it.

After restarting, you should see a small hammer icon or tools indicator in the Claude Desktop chat input area. This confirms that your MCP server connected successfully.

  • Test the connection by typing this in Claude Desktop:
List my Elasticsearch indices

Claude should invoke the list-indices tool and display your available indices.

  • Try a follow-up query to test the aggregation tool:
What are the top log levels in the last 24 hours?

Claude should use the aggregate-logs tool with a terms aggregation on log.level.

Tools not showing up in Claude Desktop?

  • Make sure you fully quit and reopened Claude Desktop (not just closed the window).
  • Verify your JSON syntax is valid. A missing comma or bracket will prevent the server from loading.
  • Check the MCP logs for errors. On macOS, run tail -n 20 ~/Library/Logs/Claude/mcp*.log in your terminal. On Windows, run type "$env:AppData\Claude\logs\mcp*.log" in PowerShell.
  • Confirm the path in args is an absolute path (starts with / on macOS or a drive letter on Windows) and that build/index.js exists at that location.

Help me debug why Claude Desktop is not connecting to my MCP server

Your MCP server is live and Claude can query your Elasticsearch logs through natural conversation. Next up, you will take on a Secret Mission to extend the server with a document drill-down tool.

Secret mission

Add a Get-Document Tool

Your search results show log summaries, but sometimes you need the full picture. Add a get-document tool that lets Claude drill into any specific log entry by its Elasticsearch document ID. This turns your MCP server into a complete log investigation toolkit.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The elasticsearch-mcp-server project directory on your Desktop.
  • A Claude Desktop configuration entry in claude_desktop_config.json.

Keep everything running

No action needed. Choose this if you plan to keep using the MCP server with Claude Desktop for log analysis.

  • Your MCP server only runs when Claude Desktop launches it, so it uses no resources when Claude is closed.
  • The project files stay on your Desktop for future editing.
  • Your Claude Desktop config remains ready to connect to your Elasticsearch instance.

Pause - I'll come back to this later

Remove the MCP server entry from Claude Desktop so it no longer attempts to launch the server. Your project files stay on disk for when you return.

  • Open ~/Library/Application Support/Claude/claude_desktop_config.json in a text editor.
  • Remove the elasticsearch-logs entry from the mcpServers object.
  • Save the file and restart Claude Desktop.
  • When you want to reconnect, add the entry back and restart Claude Desktop.

Delete - I don't want to use this again

Remove all project resources and the Claude Desktop configuration entry.

  • Open ~/Library/Application Support/Claude/claude_desktop_config.json in a text editor.
  • Remove the elasticsearch-logs entry from the mcpServers object.
  • Save the file and restart Claude Desktop.

Delete the project directory by running this command in your terminal:

rm -rf ~/Desktop/elasticsearch-mcp-server

On Windows, run this instead:

rmdir /s /q %USERPROFILE%\Desktop\elasticsearch-mcp-server

Nice Work!

Nice Work!

Nice work! You just built a custom MCP server that gives Claude Desktop direct access to query your local Elasticsearch logs through natural conversation.

You've learned how to:

  • Build a TypeScript MCP server from scratch using the official MCP SDK with stdio transport.
  • Connect to a local Elasticsearch instance with secure basic authentication and register tools for index discovery, full-text search with time filtering, and aggregations.
  • Configure Claude Desktop to launch and communicate with your server, turning natural language questions into real Elasticsearch queries.
  • Secret Mission: Added a get-document tool for drilling into specific log entries by ID.

Ready to quiz yourself?