index.ts (299 lines of code) (raw):

#!/usr/bin/env node /* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Client, estypes, ClientOptions } from "@elastic/elasticsearch"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import fs from "fs"; // Configuration schema with auth options const ConfigSchema = z .object({ url: z .string() .trim() .min(1, "Elasticsearch URL cannot be empty") .url("Invalid Elasticsearch URL format") .describe("Elasticsearch server URL"), apiKey: z .string() .optional() .describe("API key for Elasticsearch authentication"), username: z .string() .optional() .describe("Username for Elasticsearch authentication"), password: z .string() .optional() .describe("Password for Elasticsearch authentication"), caCert: z .string() .optional() .describe("Path to custom CA certificate for Elasticsearch"), }) .refine( (data) => { // If username is provided, password must be provided if (data.username) { return !!data.password; } // If password is provided, username must be provided if (data.password) { return !!data.username; } // If apiKey is provided, it's valid if (data.apiKey) { return true; } // No auth is also valid (for local development) return true; }, { message: "Either ES_API_KEY or both ES_USERNAME and ES_PASSWORD must be provided, or no auth for local development", path: ["username", "password"], } ); type ElasticsearchConfig = z.infer<typeof ConfigSchema>; export async function createElasticsearchMcpServer( config: ElasticsearchConfig ) { const validatedConfig = ConfigSchema.parse(config); const { url, apiKey, username, password, caCert } = validatedConfig; const clientOptions: ClientOptions = { node: url, }; // Set up authentication if (apiKey) { clientOptions.auth = { apiKey }; } else if (username && password) { clientOptions.auth = { username, password }; } // Set up SSL/TLS certificate if provided if (caCert) { try { const ca = fs.readFileSync(caCert); clientOptions.tls = { ca }; } catch (error) { console.error( `Failed to read certificate file: ${ error instanceof Error ? error.message : String(error) }` ); } } const esClient = new Client(clientOptions); const server = new McpServer({ name: "elasticsearch-mcp-server", version: "0.1.1", }); // Tool 1: List indices server.tool( "list_indices", "List all available Elasticsearch indices", {}, async () => { try { const response = await esClient.cat.indices({ format: "json" }); const indicesInfo = response.map((index) => ({ index: index.index, health: index.health, status: index.status, docsCount: index.docsCount, })); return { content: [ { type: "text" as const, text: `Found ${indicesInfo.length} indices`, }, { type: "text" as const, text: JSON.stringify(indicesInfo, null, 2), }, ], }; } catch (error) { console.error( `Failed to list indices: ${ error instanceof Error ? error.message : String(error) }` ); return { content: [ { type: "text" as const, text: `Error: ${ error instanceof Error ? error.message : String(error) }`, }, ], }; } } ); // Tool 2: Get mappings for an index server.tool( "get_mappings", "Get field mappings for a specific Elasticsearch index", { index: z .string() .trim() .min(1, "Index name is required") .describe("Name of the Elasticsearch index to get mappings for"), }, async ({ index }) => { try { const mappingResponse = await esClient.indices.getMapping({ index, }); return { content: [ { type: "text" as const, text: `Mappings for index: ${index}`, }, { type: "text" as const, text: `Mappings for index ${index}: ${JSON.stringify( mappingResponse[index]?.mappings || {}, null, 2 )}`, }, ], }; } catch (error) { console.error( `Failed to get mappings: ${ error instanceof Error ? error.message : String(error) }` ); return { content: [ { type: "text" as const, text: `Error: ${ error instanceof Error ? error.message : String(error) }`, }, ], }; } } ); // Tool 3: Search an index with simplified parameters server.tool( "search", "Perform an Elasticsearch search with the provided query DSL. Highlights are always enabled.", { index: z .string() .trim() .min(1, "Index name is required") .describe("Name of the Elasticsearch index to search"), queryBody: z .record(z.any()) .refine( (val) => { try { JSON.parse(JSON.stringify(val)); return true; } catch (e) { return false; } }, { message: "queryBody must be a valid Elasticsearch query DSL object", } ) .describe( "Complete Elasticsearch query DSL object that can include query, size, from, sort, etc." ), }, async ({ index, queryBody }) => { try { // Get mappings to identify text fields for highlighting const mappingResponse = await esClient.indices.getMapping({ index, }); const indexMappings = mappingResponse[index]?.mappings || {}; const searchRequest: estypes.SearchRequest = { index, ...queryBody, }; // Always do highlighting if (indexMappings.properties) { const textFields: Record<string, estypes.SearchHighlightField> = {}; for (const [fieldName, fieldData] of Object.entries( indexMappings.properties )) { if (fieldData.type === "text" || "dense_vector" in fieldData) { textFields[fieldName] = {}; } } searchRequest.highlight = { fields: textFields, pre_tags: ["<em>"], post_tags: ["</em>"], }; } const result = await esClient.search(searchRequest); // Extract the 'from' parameter from queryBody, defaulting to 0 if not provided const from = queryBody.from || 0; const contentFragments = result.hits.hits.map((hit) => { const highlightedFields = hit.highlight || {}; const sourceData = hit._source || {}; let content = ""; for (const [field, highlights] of Object.entries(highlightedFields)) { if (highlights && highlights.length > 0) { content += `${field} (highlighted): ${highlights.join( " ... " )}\n`; } } for (const [field, value] of Object.entries(sourceData)) { if (!(field in highlightedFields)) { content += `${field}: ${JSON.stringify(value)}\n`; } } return { type: "text" as const, text: content.trim(), }; }); const metadataFragment = { type: "text" as const, text: `Total results: ${ typeof result.hits.total === "number" ? result.hits.total : result.hits.total?.value || 0 }, showing ${result.hits.hits.length} from position ${from}`, }; return { content: [metadataFragment, ...contentFragments], }; } catch (error) { console.error( `Search failed: ${ error instanceof Error ? error.message : String(error) }` ); return { content: [ { type: "text" as const, text: `Error: ${ error instanceof Error ? error.message : String(error) }`, }, ], }; } } ); // Tool 4: Get shard information server.tool( "get_shards", "Get shard information for all or specific indices", { index: z .string() .optional() .describe("Optional index name to get shard information for"), }, async ({ index }) => { try { const response = await esClient.cat.shards({ index, format: "json", }); const shardsInfo = response.map((shard) => ({ index: shard.index, shard: shard.shard, prirep: shard.prirep, state: shard.state, docs: shard.docs, store: shard.store, ip: shard.ip, node: shard.node, })); const metadataFragment = { type: "text" as const, text: `Found ${shardsInfo.length} shards${ index ? ` for index ${index}` : "" }`, }; return { content: [ metadataFragment, { type: "text" as const, text: JSON.stringify(shardsInfo, null, 2), }, ], }; } catch (error) { console.error( `Failed to get shard information: ${ error instanceof Error ? error.message : String(error) }` ); return { content: [ { type: "text" as const, text: `Error: ${ error instanceof Error ? error.message : String(error) }`, }, ], }; } } ); return server; } const config: ElasticsearchConfig = { url: process.env.ES_URL || "", apiKey: process.env.ES_API_KEY || "", username: process.env.ES_USERNAME || "", password: process.env.ES_PASSWORD || "", caCert: process.env.ES_CA_CERT || "", }; async function main() { const transport = new StdioServerTransport(); const server = await createElasticsearchMcpServer(config); await server.connect(transport); process.on("SIGINT", async () => { await server.close(); process.exit(0); }); } main().catch((error) => { console.error( "Server error:", error instanceof Error ? error.message : String(error) ); process.exit(1); });