diff --git a/example-apps/elasticsearch-mcp-server/.gitignore b/example-apps/elasticsearch-mcp-server/.gitignore new file mode 100644 index 00000000..de4d1f00 --- /dev/null +++ b/example-apps/elasticsearch-mcp-server/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/example-apps/elasticsearch-mcp-server/.nvmrc b/example-apps/elasticsearch-mcp-server/.nvmrc new file mode 100644 index 00000000..dc0bb0f4 --- /dev/null +++ b/example-apps/elasticsearch-mcp-server/.nvmrc @@ -0,0 +1 @@ +v22.12.0 diff --git a/example-apps/elasticsearch-mcp-server/README.md b/example-apps/elasticsearch-mcp-server/README.md new file mode 100644 index 00000000..161e2fc7 --- /dev/null +++ b/example-apps/elasticsearch-mcp-server/README.md @@ -0,0 +1,150 @@ +# Elasticsearch MCP Server + +Connect to your Elasticsearch data directly from any MCP Client (like Claude Desktop) using the Model Context Protocol (MCP). + +This server connects agents to your Elasticsearch data using the Model Context Protocol (MCP). It allows you to interact with your Elasticsearch indices through natural language conversations. + +## Features + +* **List Indices**: View all available Elasticsearch indices +* **Get Mappings**: Inspect field mappings for specific indices +* **Search**: Execute Elasticsearch queries using full Query DSL capabilities with automatic highlighting + +## Prerequisites + +* Node.js (v22+) +* An Elasticsearch instance +* Elasticsearch API key with appropriate permissions +* Claude Desktop App (free version is sufficient) + +## Installation & Setup + +### Using the Published NPM Package + +The easiest way to use Elasticsearch MCP Server is through the published npm package: + +1. **Configure Claude Desktop App** + - Open Claude Desktop App + - Go to Settings > Developer > MCP Servers + - Click `Edit Config` and add a new MCP Server with the following configuration to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "elasticsearch-mcp-server": { + "command": "npx", + "args": [ + "-y", + "mcp-server-elasticsearch" + ], + "env": { + "ES_URL": "your-elasticsearch-url", + "ES_API_KEY": "your-api-key" + } + } + } +} +``` + +2. **Start a Conversation** + - Open a new conversation in Claude Desktop App + - The MCP server should connect automatically + - You can now ask Claude questions about your Elasticsearch data + +### Developing Locally + +If you want to develop or modify the server locally: + +1. **Use the correct Node.js version** +```bash +nvm use +``` + +2. **Install Dependencies** +```bash +npm install +``` + +3. **Build the Project** +```bash +npm run build +``` + +4. **Configure Claude Desktop for local development** + - Open Claude Desktop App + - Go to Settings > Developer > MCP Servers + - Click `Edit Config` and add a new MCP Server with the following configuration: + +```json +{ + "mcpServers": { + "Elasticsearch MCP Server (Local)": { + "command": "node", + "args": [ + "/path/to/your/project/dist/index.js" + ], + "env": { + "ES_URL": "your-elasticsearch-url", + "ES_API_KEY": "your-api-key" + } + } + } +} +``` + +5. **Debugging** +```bash +npm run inspector +``` + +## Example Questions + +* "What indices do I have in my Elasticsearch cluster?" +* "Show me the field mappings for the 'products' index" +* "Find all orders over $500 from last month" +* "Which products received the most 5-star reviews?" + +## How It Works + +When you ask Claude a question about your data: +1. Claude analyzes your request and determines which Elasticsearch operations are needed +2. The MCP server carries out these operations (listing indices, fetching mappings, performing searches) +3. Claude processes the results and presents them in a user-friendly format + +## Security Best Practices + +You can create a dedicated Elasticsearch API key with minimal permissions to control access to your data: + +``` +POST /_security/api_key +{ + "name": "es-mcp-server-access", + "role_descriptors": { + "claude_role": { + "cluster": [ + "monitor" + ], + "indices": [ + { + "names": [ + "index-1", + "index-2", + "index-pattern-*" + ], + "privileges": [ + "read", + "view_index_metadata" + ] + } + ] + } + } +} +``` + +## Troubleshooting + +* If the server isn't connecting, check that your MCP configuration is correct +* Ensure your Elasticsearch URL is accessible from your machine +* Verify that your API key has the necessary permissions +* Check the terminal output for any error messages diff --git a/example-apps/elasticsearch-mcp-server/index.ts b/example-apps/elasticsearch-mcp-server/index.ts new file mode 100644 index 00000000..4129708d --- /dev/null +++ b/example-apps/elasticsearch-mcp-server/index.ts @@ -0,0 +1,297 @@ +#!/usr/bin/env node + +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Client, estypes } from "@elastic/elasticsearch"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +// Simplified configuration schema with only URL and API key +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() + .trim() + .min(1, "API key is required") + .describe("API key for Elasticsearch authentication"), +}); + +type ElasticsearchConfig = z.infer; + +export async function createElasticsearchMcpServer( + config: ElasticsearchConfig +) { + const validatedConfig = ConfigSchema.parse(config); + const { url, apiKey } = validatedConfig; + + const esClient = new Client({ + node: url, + auth: { + apiKey: apiKey, + }, + }); + + const server = new McpServer({ + name: "elasticsearch-mcp-server", + version: "0.1.3", + }); + + // 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 = {}; + + 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: [""], + post_tags: [""], + }; + } + + 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) + }`, + }, + ], + }; + } + } + ); + + return server; +} + +const config: ElasticsearchConfig = { + url: process.env.ES_URL || "", + apiKey: process.env.ES_API_KEY || "", +}; + +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); +}); diff --git a/example-apps/elasticsearch-mcp-server/package.json b/example-apps/elasticsearch-mcp-server/package.json new file mode 100644 index 00000000..465ac49d --- /dev/null +++ b/example-apps/elasticsearch-mcp-server/package.json @@ -0,0 +1,44 @@ +{ + "name": "mcp-server-elasticsearch", + "description": "Elasticsearch MCP Server", + "version": "0.1.3", + "license": "MIT", + "author": "Elastic", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "bin": { + "mcp-server-elasticsearch": "./dist/index.js" + }, + "repository": "https://github.com/elastic/elasticsearch-labs", + "bugs": "https://github.com/elastic/elasticsearch-labs/issues", + "homepage": "https://www.elastic.co/", + "keywords": [ + "elasticsearch", + "search", + "mcp", + "mcp-server" + ], + "files": [ + "dist" + ], + "scripts": { + "build": "tsc && shx chmod +x dist/*.js", + "prepare": "npm run build", + "watch": "tsc --watch", + "start": "node dist/index.js", + "inspector": "npx @modelcontextprotocol/inspector node dist/index.js" + }, + "dependencies": { + "@elastic/elasticsearch": "^8.17.1", + "@modelcontextprotocol/sdk": "1.7.0" + }, + "devDependencies": { + "@types/node": "^22", + "shx": "^0.3.4", + "typescript": "^5.6.2" + }, + "engines": { + "node": "^18" + } +} diff --git a/example-apps/elasticsearch-mcp-server/tsconfig.json b/example-apps/elasticsearch-mcp-server/tsconfig.json new file mode 100644 index 00000000..a76cdbe9 --- /dev/null +++ b/example-apps/elasticsearch-mcp-server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "esModuleInterop": true, + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "declaration": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +}