#
tokens: 10942/50000 7/7 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .env.example
├── .gitignore
├── .npmignore
├── bin
│   └── cli.js
├── index.js
├── package-lock.json
├── package.json
└── README.md
```

# Files

--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------

```
 1 | # Development files
 2 | .git
 3 | .gitignore
 4 | node_modules
 5 | 
 6 | # Environment variables
 7 | .env
 8 | .env.local
 9 | .env.*
10 | 
11 | # Logs
12 | logs
13 | *.log
14 | npm-debug.log*
15 | 
16 | # Editor directories and files
17 | .idea
18 | .vscode
19 | *.swp
20 | *.swo
21 | 
22 | # Test files
23 | test
24 | tests
```

--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------

```
1 | # OpenSearch MCP Server Environment Variables
2 | 
3 | # OpenSearch Connection
4 | OPENSEARCH_URL=https://your-opensearch-endpoint:9200
5 | OPENSEARCH_USERNAME=admin
6 | OPENSEARCH_PASSWORD=your-password
7 | 
8 | # Server Configuration
9 | PORT=3000
```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Dependencies
 2 | node_modules/
 3 | npm-debug.log
 4 | yarn-debug.log
 5 | yarn-error.log
 6 | .pnpm-debug.log
 7 | 
 8 | # Environment variables
 9 | .env
10 | .env.local
11 | .env.development
12 | .env.test
13 | .env.production
14 | 
15 | # IDE files
16 | .idea/
17 | .vscode/
18 | *.sublime-project
19 | *.sublime-workspace
20 | 
21 | # OS files
22 | .DS_Store
23 | Thumbs.db
24 | 
25 | # Logs
26 | logs
27 | *.log
28 | npm-debug.log*
29 | 
30 | # Optional npm cache directory
31 | .npm
32 | 
33 | # Optional eslint cache
34 | .eslintcache
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # OpenSearch MCP Server
  2 | 
  3 | A Model Context Protocol (MCP) server for querying and analyzing Wazuh security logs stored in OpenSearch.
  4 | 
  5 | ## Features
  6 | 
  7 | - Search for security alerts with advanced filtering
  8 | - Get detailed information about specific alerts
  9 | - Generate statistics on security events
 10 | - Visualize alert trends over time
 11 | - Progress reporting for long-running operations
 12 | - Structured error handling
 13 | 
 14 | ## Prerequisites
 15 | 
 16 | - Node.js v16 or higher
 17 | - Access to an OpenSearch instance containing Wazuh security logs
 18 | 
 19 | ## Installation
 20 | 
 21 | ### Option 1: Use with npx directly from GitHub (recommended)
 22 | 
 23 | You can run this tool directly using npx without cloning the repository:
 24 | 
 25 | ```bash
 26 | # Run the latest version from GitHub
 27 | npx github:jetbalsa/mcp-opensearch-js
 28 | 
 29 | # Run with debug mode enabled
 30 | npx github:jetbalsa/mcp-opensearch-js --debug
 31 | 
 32 | # You can also specify a specific branch or commit
 33 | npx github:jetbalsa/mcp-opensearch-js#main
 34 | ```
 35 | 
 36 | ### Option 2: Local Installation
 37 | 
 38 | 1. Clone this repository:
 39 | ```bash
 40 | git clone https://github.com/jetbalsa/mcp-opensearch-js.git
 41 | cd mcp-opensearch-js
 42 | ```
 43 | 
 44 | 2. Install dependencies:
 45 | ```bash
 46 | npm install
 47 | ```
 48 | 
 49 | 3. Configure your environment variables:
 50 | ```bash
 51 | cp .env.example .env
 52 | ```
 53 | 
 54 | 4. Edit the `.env` file with your OpenSearch connection details:
 55 | ```
 56 | OPENSEARCH_URL=https://your-opensearch-endpoint:9200
 57 | OPENSEARCH_USERNAME=your-username
 58 | OPENSEARCH_PASSWORD=your-password
 59 | DEBUG=false
 60 | ```
 61 | 
 62 | ## Running the Server
 63 | 
 64 | ### Start the server:
 65 | 
 66 | ```bash
 67 | npm start
 68 | ```
 69 | 
 70 | This will start the server in stdio mode.
 71 | 
 72 | ### Enable debug logging:
 73 | 
 74 | ```bash
 75 | npm run stdio:debug
 76 | ```
 77 | 
 78 | ### Test with MCP CLI:
 79 | 
 80 | ```bash
 81 | npm run dev
 82 | ```
 83 | 
 84 | This runs the server with the FastMCP CLI tool for interactive testing.
 85 | 
 86 | ### Test with MCP Inspector:
 87 | 
 88 | ```bash
 89 | npm run inspect
 90 | ```
 91 | 
 92 | This starts the server and connects it to the MCP Inspector for visual debugging.
 93 | 
 94 | ## Server Tools
 95 | 
 96 | The server provides the following tools:
 97 | 
 98 | ### 1. Search Alerts
 99 | 
100 | Search for security alerts in Wazuh data.
101 | 
102 | **Parameters:**
103 | - `query`: The search query text
104 | - `timeRange`: Time range (e.g., 1h, 24h, 7d)
105 | - `maxResults`: Maximum number of results to return
106 | - `index`: Index pattern to search
107 | 
108 | ### 2. Get Alert Details
109 | 
110 | Get detailed information about a specific alert by ID.
111 | 
112 | **Parameters:**
113 | - `id`: The alert ID
114 | - `index`: Index pattern
115 | 
116 | ### 3. Alert Statistics
117 | 
118 | Get statistics about security alerts.
119 | 
120 | **Parameters:**
121 | - `timeRange`: Time range (e.g., 1h, 24h, 7d)
122 | - `field`: Field to aggregate by (e.g., rule.level, agent.name)
123 | - `index`: Index pattern
124 | 
125 | ### 4. Visualize Alert Trend
126 | 
127 | Visualize alert trends over time.
128 | 
129 | **Parameters:**
130 | - `timeRange`: Time range (e.g., 1h, 24h, 7d)
131 | - `interval`: Time interval for grouping (e.g., 1h, 1d)
132 | - `query`: Query to filter alerts
133 | - `index`: Index pattern
134 | 
135 | ## Example Usage
136 | 
137 | Using the MCP CLI tool:
138 | 
139 | ```
140 | > tools
141 | Available tools:
142 | - searchAlerts: Search for security alerts in Wazuh data
143 | - getAlertDetails: Get detailed information about a specific alert by ID
144 | - alertStatistics: Get statistics about security alerts
145 | - visualizeAlertTrend: Visualize alert trends over time
146 | 
147 | > tools.searchAlerts(query: "rule.level:>10", timeRange: "12h", maxResults: 5)
148 | ```
149 | 
150 | ## Using with a Client
151 | 
152 | To use this MCP server with a client implementation:
153 | 
154 | ```javascript
155 | import { Client } from "@modelcontextprotocol/sdk";
156 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
157 | 
158 | const client = new Client(
159 |   {
160 |     name: "example-client",
161 |     version: "1.0.0",
162 |   },
163 |   {
164 |     capabilities: {},
165 |   },
166 | );
167 | 
168 | const transport = new SSEClientTransport(new URL(`http://localhost:3000/sse`));
169 | 
170 | await client.connect(transport);
171 | 
172 | // Use tools
173 | const result = await client.executeTool("searchAlerts", {
174 |   query: "rule.level:>10",
175 |   timeRange: "24h",
176 |   maxResults: 10
177 | });
178 | 
179 | console.log(result);
180 | ```
181 | 
182 | ## License
183 | 
184 | MIT
```

--------------------------------------------------------------------------------
/bin/cli.js:
--------------------------------------------------------------------------------

```javascript
 1 | #!/usr/bin/env node
 2 | 
 3 | import path from 'path';
 4 | import { fileURLToPath } from 'url';
 5 | import { spawnSync } from 'child_process';
 6 | 
 7 | // Get the directory of the current module
 8 | const __filename = fileURLToPath(import.meta.url);
 9 | const __dirname = path.dirname(__filename);
10 | 
11 | // Path to the main index.js file
12 | const indexPath = path.join(__dirname, '..', 'index.js');
13 | 
14 | // Check for debug flag
15 | const debugMode = process.argv.includes('--debug');
16 | 
17 | // Set environment variables
18 | if (debugMode) {
19 |   process.env.DEBUG = 'true';
20 | }
21 | 
22 | // Run the main application
23 | console.log('Starting OpenSearch MCP Server...');
24 | if (debugMode) console.log('Debug mode enabled');
25 | 
26 | // Execute the main index.js file
27 | const result = spawnSync('node', [indexPath], { 
28 |   stdio: 'inherit',
29 |   env: process.env
30 | });
31 | 
32 | // Handle exit
33 | process.exit(result.status);
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "name": "mcp-opensearch-js",
 3 |   "version": "1.0.0",
 4 |   "description": "FastMCP server for searching OpenSearch with Wazuh security logs",
 5 |   "main": "index.js",
 6 |   "type": "module",
 7 |   "bin": {
 8 |     "mcp-opensearch": "./bin/cli.js"
 9 |   },
10 |   "scripts": {
11 |     "start": "node index.js",
12 |     "dev": "npx fastmcp dev index.js",
13 |     "inspect": "npx fastmcp inspect index.js",
14 |     "stdio": "node index.js",
15 |     "stdio:debug": "DEBUG=true node index.js",
16 |     "test": "echo \"Error: no test specified\" && exit 1"
17 |   },
18 |   "dependencies": {
19 |     "@opensearch-project/opensearch": "^2.5.0",
20 |     "dotenv": "^16.3.1",
21 |     "fastmcp": "^1.20.5",
22 |     "zod": "^3.24.2"
23 |   },
24 |   "keywords": [
25 |     "opensearch",
26 |     "mcp",
27 |     "wazuh",
28 |     "security",
29 |     "logs",
30 |     "fastmcp"
31 |   ],
32 |   "author": "",
33 |   "license": "MIT",
34 |   "repository": {
35 |     "type": "git",
36 |     "url": "https://github.com/jetbalsa/mcp-opensearch-js"
37 |   },
38 |   "engines": {
39 |     "node": ">=16.0.0"
40 |   }
41 | }
42 | 
```

--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------

```javascript
  1 | // OpenSearch MCP Server
  2 | import { FastMCP, UserError, imageContent } from "fastmcp";
  3 | import { Client } from "@opensearch-project/opensearch";
  4 | import { z } from "zod";
  5 | import dotenv from 'dotenv';
  6 | import util from 'util';
  7 | 
  8 | // Load environment variables
  9 | dotenv.config();
 10 | 
 11 | // Configure debug logging
 12 | const DEBUG = process.env.DEBUG === 'true' || process.env.DEBUG === '1';
 13 | function debugLog(...args) {
 14 |   if (DEBUG) {
 15 |     const timestamp = new Date().toISOString();
 16 |     const formattedArgs = args.map(arg => 
 17 |       typeof arg === 'object' ? util.inspect(arg, { depth: 3, colors: true }) : arg
 18 |     );
 19 |     console.error(`[${timestamp}] [DEBUG]`, ...formattedArgs);
 20 |   }
 21 | }
 22 | 
 23 | console.log('Starting OpenSearch MCP Server (stdio mode)');
 24 | debugLog('Debug logging enabled');
 25 | 
 26 | // Configure OpenSearch client with increased timeout
 27 | const client = new Client({
 28 |   // Get connection details from environment variables
 29 |   node: process.env.OPENSEARCH_URL || "https://localhost:9200",
 30 |   auth: {
 31 |     username: process.env.OPENSEARCH_USERNAME || "admin",
 32 |     password: process.env.OPENSEARCH_PASSWORD || "admin",
 33 |   },
 34 |   ssl: {
 35 |     rejectUnauthorized: false, // Set to true in production with proper certificates
 36 |   },
 37 |   // Add increased timeouts to avoid MCP timeout errors
 38 |   requestTimeout: 30000, // 30 seconds for API requests
 39 |   connectionTimeout: 10000, // 10 seconds for initial connection
 40 |   maxRetries: 3, // Allow retries on failure
 41 | });
 42 | 
 43 | debugLog('OpenSearch client configured with:', {
 44 |   node: process.env.OPENSEARCH_URL || "https://localhost:9200",
 45 |   requestTimeout: 30000,
 46 |   connectionTimeout: 10000,
 47 |   maxRetries: 3
 48 | });
 49 | 
 50 | // Initialize MCP Server with increased timeout
 51 | const server = new FastMCP({
 52 |   name: "OpenSearch Security Analytics",
 53 |   version: "1.0.0",
 54 |   description: "MCP server for querying Wazuh security logs in OpenSearch",
 55 |   // Increase the default MCP execution timeout
 56 |   defaultExecutionTimeoutMs: 120000, // 2 minutes
 57 | });
 58 | 
 59 | debugLog('MCP Server initialized with timeout:', 120000);
 60 | 
 61 | // Helper function to safely execute OpenSearch queries
 62 | async function safeOpenSearchQuery(operation, fallbackMessage) {
 63 |   try {
 64 |     debugLog('Executing OpenSearch query');
 65 |     const result = await operation();
 66 |     debugLog('OpenSearch query completed successfully');
 67 |     return result;
 68 |   } catch (error) {
 69 |     console.error(`OpenSearch error: ${error.message}`, error);
 70 |     debugLog('OpenSearch query failed:', error);
 71 |     
 72 |     // Check for common OpenSearch errors
 73 |     if (error.message.includes('timeout')) {
 74 |       throw new UserError(`OpenSearch request timed out. The query may be too complex or the cluster is under heavy load.`);
 75 |     } else if (error.message.includes('connect')) {
 76 |       throw new UserError(`Cannot connect to OpenSearch. Please check your connection settings in .env file.`);
 77 |     } else if (error.message.includes('no such index')) {
 78 |       throw new UserError(`The specified index doesn't exist in OpenSearch.`);
 79 |     } else if (error.message.includes('unauthorized')) {
 80 |       throw new UserError(`Authentication failed with OpenSearch. Please check your credentials in .env file.`);
 81 |     }
 82 |     
 83 |     // For any other errors
 84 |     throw new UserError(fallbackMessage || `OpenSearch operation failed: ${error.message}`);
 85 |   }
 86 | }
 87 | 
 88 | // Tool to list all available indexes
 89 | server.addTool({
 90 |   name: "listIndexes",
 91 |   description: "List all available indexes in OpenSearch",
 92 |   parameters: z.object({
 93 |     pattern: z.string().default("*").describe("Index pattern to filter (e.g., 'logs-*')"),
 94 |   }),
 95 |   execute: async (args, { log }) => {
 96 |     log.info("Listing indexes", { pattern: args.pattern });
 97 | 
 98 |     return safeOpenSearchQuery(async () => {
 99 |       const response = await client.cat.indices({
100 |         format: "json",
101 |         index: args.pattern,
102 |         // Add timeout parameter to OpenSearch request
103 |         timeout: "30s",
104 |       });
105 | 
106 |       const indexes = response.body;
107 |       
108 |       if (!indexes || indexes.length === 0) {
109 |         return "No indexes found matching your pattern.";
110 |       }
111 | 
112 |       // Sort indexes by size (descending)
113 |       indexes.sort((a, b) => {
114 |         // Handle missing or undefined values
115 |         const sizeA = a.pri?.store?.size ? parseInt(a.pri.store.size) : 0;
116 |         const sizeB = b.pri?.store?.size ? parseInt(b.pri.store.size) : 0;
117 |         return sizeB - sizeA;
118 |       });
119 | 
120 |       let resultText = `## Available Indexes (${indexes.length} total)\n\n`;
121 |       resultText += "| Index | Docs Count | Size | Status | Health |\n";
122 |       resultText += "|-------|------------|------|--------|--------|\n";
123 |       
124 |       indexes.forEach(idx => {
125 |         // Safely handle potentially missing fields
126 |         const docsCount = idx.docs?.count || 'N/A';
127 |         const size = idx.pri?.store?.size || 'N/A';
128 |         const status = idx.status || 'N/A';
129 |         const health = idx.health || 'N/A';
130 |         
131 |         resultText += `| ${idx.index} | ${docsCount} | ${size} | ${status} | ${health} |\n`;
132 |       });
133 | 
134 |       return resultText;
135 |     }, "Failed to list OpenSearch indexes. Please check your connection and try again.");
136 |   },
137 | });
138 | 
139 | // Tool to search any logs
140 | server.addTool({
141 |   name: "searchLogs",
142 |   description: "Search for logs in any OpenSearch index",
143 |   parameters: z.object({
144 |     query: z.string().describe("The search query text"),
145 |     index: z.string().describe("Index pattern to search"),
146 |     timeField: z.string().default("@timestamp").describe("Name of the timestamp field"),
147 |     timeRange: z.string().default("24h").describe("Time range (e.g., 1h, 24h, 7d)"),
148 |     maxResults: z.number().default(20).describe("Maximum number of results to return"),
149 |     fields: z.string().optional().describe("Comma-separated list of fields to return"),
150 |   }),
151 |   execute: async (args, { log }) => {
152 |     log.info("Searching logs", { 
153 |       query: args.query, 
154 |       index: args.index,
155 |       timeRange: args.timeRange 
156 |     });
157 | 
158 |     return safeOpenSearchQuery(async () => {
159 |       const timeRangeMs = parseTimeRange(args.timeRange);
160 |       const now = new Date();
161 |       const from = new Date(now.getTime() - timeRangeMs);
162 | 
163 |       // Build the query body
164 |       const queryBody = {
165 |         size: args.maxResults,
166 |         query: {
167 |           bool: {
168 |             must: [
169 |               { query_string: { query: args.query } }
170 |             ]
171 |           }
172 |         },
173 |         sort: [{ [args.timeField]: { order: "desc" } }],
174 |         // Add timeout parameter directly in the query
175 |         timeout: "25s"
176 |       };
177 | 
178 |       // Add time range if timeField is specified
179 |       if (args.timeField) {
180 |         queryBody.query.bool.must.push({
181 |           range: {
182 |             [args.timeField]: {
183 |               gte: from.toISOString(),
184 |               lte: now.toISOString(),
185 |             },
186 |           },
187 |         });
188 |       }
189 | 
190 |       // Add source filtering if fields are specified
191 |       if (args.fields) {
192 |         const fieldList = args.fields.split(',').map(f => f.trim());
193 |         queryBody._source = fieldList;
194 |       }
195 | 
196 |       const response = await client.search({
197 |         index: args.index,
198 |         body: queryBody
199 |       });
200 | 
201 |       const hits = response.body.hits.hits || [];
202 |       const total = response.body.hits.total?.value || 0;
203 | 
204 |       log.info(`Found ${total} matching logs`, { count: total });
205 | 
206 |       if (hits.length === 0) {
207 |         return "No logs found matching your criteria.";
208 |       }
209 | 
210 |       let resultText = `Found ${total} logs matching your criteria. Showing top ${hits.length}:\n\n`;
211 |       
212 |       // Display results in a readable format
213 |       hits.forEach((hit, i) => {
214 |         const source = hit._source;
215 |         resultText += `### Log ${i+1} (${hit._index})\n`;
216 |         resultText += `- **ID**: ${hit._id}\n`;
217 |         
218 |         // Display timestamp if it exists
219 |         if (source[args.timeField]) {
220 |           resultText += `- **Time**: ${source[args.timeField]}\n`;
221 |         }
222 |         
223 |         // Display top-level fields for a summary
224 |         const topFields = Object.keys(source)
225 |           .filter(key => 
226 |             typeof source[key] !== 'object' && 
227 |             key !== args.timeField
228 |           )
229 |           .slice(0, 5);
230 |         
231 |         topFields.forEach(field => {
232 |           resultText += `- **${field}**: ${source[field]}\n`;
233 |         });
234 |         
235 |         resultText += `\n\`\`\`json\n${JSON.stringify(source, null, 2)}\n\`\`\`\n\n`;
236 |       });
237 | 
238 |       return resultText;
239 |     }, "Failed to search logs. Please check your query and connection settings.");
240 |   },
241 | });
242 | 
243 | // Tool to get index mappings
244 | server.addTool({
245 |   name: "getIndexMapping",
246 |   description: "Get the field mappings for an index",
247 |   parameters: z.object({
248 |     index: z.string().describe("Index name to inspect"),
249 |   }),
250 |   execute: async (args, { log }) => {
251 |     log.info("Getting index mapping", { index: args.index });
252 | 
253 |     return safeOpenSearchQuery(async () => {
254 |       const response = await client.indices.getMapping({
255 |         index: args.index,
256 |         timeout: "20s"
257 |       });
258 | 
259 |       const mappings = response.body;
260 |       if (!mappings) {
261 |         return `No mappings found for index ${args.index}.`;
262 |       }
263 |       
264 |       const indexName = Object.keys(mappings)[0];
265 |       const properties = mappings[indexName]?.mappings?.properties || {};
266 |       
267 |       if (Object.keys(properties).length === 0) {
268 |         return `No field mappings found for index ${args.index}.`;
269 |       }
270 | 
271 |       let resultText = `## Field Mappings for ${args.index}\n\n`;
272 |       
273 |       function processProperties(props, prefix = '') {
274 |         Object.entries(props).forEach(([field, details]) => {
275 |           const fullPath = prefix ? `${prefix}.${field}` : field;
276 |           
277 |           if (details.type) {
278 |             resultText += `- **${fullPath}**: ${details.type}`;
279 |             if (details.fields) {
280 |               resultText += ` (has multi-fields)`;
281 |             }
282 |             resultText += '\n';
283 |           }
284 |           
285 |           // Recursively process nested fields
286 |           if (details.properties) {
287 |             processProperties(details.properties, fullPath);
288 |           }
289 |           
290 |           // Process multi-fields
291 |           if (details.fields) {
292 |             Object.entries(details.fields).forEach(([subField, subDetails]) => {
293 |               resultText += `  - ${fullPath}.${subField}: ${subDetails.type}\n`;
294 |             });
295 |           }
296 |         });
297 |       }
298 |       
299 |       processProperties(properties);
300 |       
301 |       return resultText;
302 |     }, `Failed to get mapping for index ${args.index}.`);
303 |   },
304 | });
305 | 
306 | // Tool to explore field values
307 | server.addTool({
308 |   name: "exploreFieldValues",
309 |   description: "Explore possible values for a field in an index",
310 |   parameters: z.object({
311 |     index: z.string().describe("Index pattern to search"),
312 |     field: z.string().describe("Field name to explore"),
313 |     query: z.string().default("*").describe("Optional query to filter documents"),
314 |     maxValues: z.number().default(20).describe("Maximum number of values to return"),
315 |   }),
316 |   execute: async (args, { log }) => {
317 |     log.info("Exploring field values", { 
318 |       index: args.index,
319 |       field: args.field,
320 |       query: args.query 
321 |     });
322 | 
323 |     return safeOpenSearchQuery(async () => {
324 |       const response = await client.search({
325 |         index: args.index,
326 |         body: {
327 |           size: 0,
328 |           query: {
329 |             query_string: {
330 |               query: args.query
331 |             }
332 |           },
333 |           aggs: {
334 |             field_values: {
335 |               terms: {
336 |                 field: args.field,
337 |                 size: args.maxValues
338 |               }
339 |             }
340 |           },
341 |           timeout: "25s"
342 |         }
343 |       });
344 | 
345 |       const buckets = response.body.aggregations?.field_values?.buckets || [];
346 |       const total = response.body.hits.total?.value || 0;
347 |       
348 |       if (buckets.length === 0) {
349 |         return `No values found for field "${args.field}" in index ${args.index}.\n\nPossible reasons:\n- The field does not exist\n- The field is not indexed for aggregations\n- No documents match your query\n- The field has no values`;
350 |       }
351 | 
352 |       let resultText = `## Values for field "${args.field}" in ${args.index}\n\n`;
353 |       resultText += `Found ${total} matching documents. Top ${buckets.length} values:\n\n`;
354 |       
355 |       // Calculate percentage of total for each value
356 |       let totalCount = buckets.reduce((sum, bucket) => sum + bucket.doc_count, 0);
357 |       
358 |       // Format results as a table
359 |       resultText += "| Value | Count | Percentage |\n";
360 |       resultText += "|-------|-------|------------|\n";
361 |       
362 |       buckets.forEach(bucket => {
363 |         const percentage = ((bucket.doc_count / totalCount) * 100).toFixed(2);
364 |         resultText += `| ${bucket.key} | ${bucket.doc_count} | ${percentage}% |\n`;
365 |       });
366 |       
367 |       return resultText;
368 |     }, `Failed to explore values for field "${args.field}" in index ${args.index}.`);
369 |   },
370 | });
371 | 
372 | // Tool to monitor logs in real-time
373 | server.addTool({
374 |   name: "monitorLogs",
375 |   description: "Monitor logs in real-time (simulated)",
376 |   parameters: z.object({
377 |     index: z.string().describe("Index pattern to monitor"),
378 |     query: z.string().default("*").describe("Filter query"),
379 |     refreshInterval: z.number().default(5).describe("Refresh interval in seconds"),
380 |     maxResults: z.number().default(10).describe("Number of logs to show"),
381 |   }),
382 |   execute: async (args, { log, reportProgress }) => {
383 |     log.info("Monitoring logs", { 
384 |       index: args.index,
385 |       query: args.query,
386 |       interval: args.refreshInterval
387 |     });
388 | 
389 |     // This is a simulated implementation since real-time monitoring
390 |     // would require a persistent connection
391 |     reportProgress({
392 |       progress: 10,
393 |       total: 100,
394 |       message: "Preparing log monitoring..."
395 |     });
396 | 
397 |     return safeOpenSearchQuery(async () => {
398 |       // Get an initial set of logs
399 |       const response = await client.search({
400 |         index: args.index,
401 |         body: {
402 |           size: args.maxResults,
403 |           query: {
404 |             query_string: {
405 |               query: args.query
406 |             }
407 |           },
408 |           sort: [
409 |             { "@timestamp": { order: "desc" } }
410 |           ],
411 |           timeout: "20s"
412 |         }
413 |       });
414 | 
415 |       const hits = response.body.hits.hits || [];
416 |       reportProgress({
417 |         progress: 100,
418 |         total: 100,
419 |         message: "Log monitoring ready"
420 |       });
421 | 
422 |       if (hits.length === 0) {
423 |         return "No logs found matching your criteria.";
424 |       }
425 | 
426 |       let resultText = `## Log Monitor for ${args.index}\n\n`;
427 |       resultText += `Query: ${args.query}\n\n`;
428 |       resultText += `To implement real-time monitoring, you would need to:\n`;
429 |       resultText += `1. Set up an interval to poll for new logs every ${args.refreshInterval} seconds\n`;
430 |       resultText += `2. Track the timestamp of the most recent log\n`;
431 |       resultText += `3. Query only for logs newer than that timestamp\n\n`;
432 |       
433 |       resultText += `### Most Recent Logs\n\n`;
434 |       
435 |       // Display results in a readable format
436 |       hits.forEach((hit, i) => {
437 |         const source = hit._source;
438 |         // Safely access timestamp fields
439 |         const timestamp = source['@timestamp'] || source.timestamp;
440 |         const timeDisplay = timestamp ? new Date(timestamp).toLocaleString() : 'Unknown time';
441 |         
442 |         resultText += `**Log ${i+1}** (${timeDisplay}):\n`;
443 |         
444 |         // Show a summary with key fields
445 |         const importantFields = ['message', 'level', 'logger_name', 'status', 'method', 'path'];
446 |         let foundFields = false;
447 |         
448 |         importantFields.forEach(field => {
449 |           if (source[field]) {
450 |             resultText += `- **${field}**: ${source[field]}\n`;
451 |             foundFields = true;
452 |           }
453 |         });
454 |         
455 |         // If none of the important fields were found, show the first few fields
456 |         if (!foundFields) {
457 |           Object.entries(source)
458 |             .filter(([key]) => typeof source[key] !== 'object' && key !== '@timestamp' && key !== 'timestamp')
459 |             .slice(0, 3)
460 |             .forEach(([key, value]) => {
461 |               resultText += `- **${key}**: ${value}\n`;
462 |             });
463 |         }
464 |         
465 |         resultText += '\n';
466 |       });
467 | 
468 |       resultText += `\nTo set up real monitoring, you could use the OpenSearch _search API with a persistent connection or implement a polling mechanism in your application.`;
469 |       
470 |       return resultText;
471 |     }, `Failed to monitor logs for index ${args.index}. The index may not exist or the connection timed out.`);
472 |   },
473 | });
474 | 
475 | // Tool to search Wazuh alerts
476 | server.addTool({
477 |   name: "searchAlerts",
478 |   description: "Search for security alerts in Wazuh data",
479 |   parameters: z.object({
480 |     query: z.string().describe("The search query text"),
481 |     timeRange: z.string().default("24h").describe("Time range (e.g., 1h, 24h, 7d)"),
482 |     maxResults: z.number().default(10).describe("Maximum number of results to return"),
483 |     index: z.string().default("wazuh-alerts-*").describe("Index pattern to search"),
484 |   }),
485 |   execute: async (args, { log }) => {
486 |     log.info("Searching alerts", { query: args.query, timeRange: args.timeRange });
487 | 
488 |     return safeOpenSearchQuery(async () => {
489 |       const timeRangeMs = parseTimeRange(args.timeRange);
490 |       const now = new Date();
491 |       const from = new Date(now.getTime() - timeRangeMs);
492 | 
493 |       const response = await client.search({
494 |         index: args.index,
495 |         body: {
496 |           size: args.maxResults,
497 |           query: {
498 |             bool: {
499 |               must: [
500 |                 { query_string: { query: args.query } },
501 |                 {
502 |                   range: {
503 |                     timestamp: {
504 |                       gte: from.toISOString(),
505 |                       lte: now.toISOString(),
506 |                     },
507 |                   },
508 |                 },
509 |               ],
510 |             },
511 |           },
512 |           sort: [{ timestamp: { order: "desc" } }],
513 |           timeout: "25s"
514 |         },
515 |       });
516 | 
517 |       const hits = response.body.hits.hits || [];
518 |       const total = response.body.hits.total?.value || 0;
519 | 
520 |       log.info(`Found ${total} matching alerts`, { count: total });
521 | 
522 |       if (hits.length === 0) {
523 |         return "No alerts found matching your criteria.";
524 |       }
525 | 
526 |       const results = hits.map(hit => {
527 |         const source = hit._source;
528 |         return {
529 |           id: hit._id,
530 |           timestamp: source.timestamp,
531 |           rule: source.rule?.description || "No description",
532 |           level: source.rule?.level || 0,
533 |           agent: source.agent?.name || "Unknown",
534 |           message: source.data?.title || source.rule?.description || "No message",
535 |           details: JSON.stringify(source, null, 2)
536 |         };
537 |       });
538 | 
539 |       let resultText = `Found ${total} alerts matching your criteria. Showing top ${hits.length}:\n\n`;
540 |       
541 |       results.forEach((alert, i) => {
542 |         resultText += `### Alert ${i+1}\n`;
543 |         resultText += `- **Time**: ${alert.timestamp}\n`;
544 |         resultText += `- **Level**: ${alert.level}\n`;
545 |         resultText += `- **Rule**: ${alert.rule}\n`;
546 |         resultText += `- **Agent**: ${alert.agent}\n`;
547 |         resultText += `- **Message**: ${alert.message}\n\n`;
548 |       });
549 | 
550 |       return resultText;
551 |     }, "Failed to search alerts. The query may be invalid or the server connection timed out.");
552 |   },
553 | });
554 | 
555 | // Tool to get alert details
556 | server.addTool({
557 |   name: "getAlertDetails",
558 |   description: "Get detailed information about a specific alert by ID",
559 |   parameters: z.object({
560 |     id: z.string().describe("The alert ID"),
561 |     index: z.string().default("wazuh-alerts-*").describe("Index pattern"),
562 |   }),
563 |   execute: async (args, { log }) => {
564 |     log.info("Getting alert details", { id: args.id });
565 | 
566 |     return safeOpenSearchQuery(async () => {
567 |       const response = await client.get({
568 |         index: args.index,
569 |         id: args.id,
570 |         timeout: "15s"
571 |       });
572 | 
573 |       const source = response.body._source;
574 |       
575 |       return `## Alert Details\n\n\`\`\`json\n${JSON.stringify(source, null, 2)}\n\`\`\``;
576 |     }, `Failed to get details for alert ID ${args.id}. The alert may not exist or the connection timed out.`);
577 |   },
578 | });
579 | 
580 | // Tool to generate alert statistics
581 | server.addTool({
582 |   name: "alertStatistics",
583 |   description: "Get statistics about security alerts",
584 |   parameters: z.object({
585 |     timeRange: z.string().default("24h").describe("Time range (e.g., 1h, 24h, 7d)"),
586 |     field: z.string().default("rule.level").describe("Field to aggregate by"),
587 |     index: z.string().default("wazuh-alerts-*").describe("Index pattern"),
588 |   }),
589 |   execute: async (args, { log }) => {
590 |     log.info("Getting alert statistics", { timeRange: args.timeRange, field: args.field });
591 | 
592 |     return safeOpenSearchQuery(async () => {
593 |       const timeRangeMs = parseTimeRange(args.timeRange);
594 |       const now = new Date();
595 |       const from = new Date(now.getTime() - timeRangeMs);
596 | 
597 |       const response = await client.search({
598 |         index: args.index,
599 |         body: {
600 |           size: 0,
601 |           query: {
602 |             range: {
603 |               timestamp: {
604 |                 gte: from.toISOString(),
605 |                 lte: now.toISOString(),
606 |               },
607 |             },
608 |           },
609 |           aggs: {
610 |             stats: {
611 |               terms: {
612 |                 field: args.field,
613 |                 size: 20,
614 |               },
615 |             },
616 |           },
617 |           timeout: "25s"
618 |         },
619 |       });
620 | 
621 |       const buckets = response.body.aggregations?.stats?.buckets || [];
622 |       const total = buckets.reduce((sum, bucket) => sum + bucket.doc_count, 0);
623 | 
624 |       log.info(`Found statistics for ${total} alerts`, { count: total });
625 | 
626 |       if (total === 0) {
627 |         return "No alerts found in the specified time range.";
628 |       }
629 | 
630 |       let resultText = `## Alert Statistics for the past ${args.timeRange}\n\n`;
631 |       resultText += `Total alerts: ${total}\n\n`;
632 |       
633 |       resultText += `### Breakdown by ${args.field}\n\n`;
634 |       buckets.forEach(bucket => {
635 |         const percentage = ((bucket.doc_count / total) * 100).toFixed(2);
636 |         resultText += `- **${bucket.key}**: ${bucket.doc_count} (${percentage}%)\n`;
637 |       });
638 | 
639 |       return resultText;
640 |     }, `Failed to get alert statistics. The field "${args.field}" may not be aggregatable or the connection timed out.`);
641 |   },
642 | });
643 | 
644 | // Tool to create a dashboard visualization
645 | server.addTool({
646 |   name: "visualizeAlertTrend",
647 |   description: "Visualize alert trends over time",
648 |   parameters: z.object({
649 |     timeRange: z.string().default("7d").describe("Time range (e.g., 1h, 24h, 7d)"),
650 |     interval: z.string().default("1d").describe("Time interval for grouping (e.g., 1h, 1d)"),
651 |     query: z.string().default("*").describe("Query to filter alerts"),
652 |     index: z.string().default("wazuh-alerts-*").describe("Index pattern"),
653 |   }),
654 |   execute: async (args, { log, reportProgress }) => {
655 |     log.info("Generating visualization", { 
656 |       timeRange: args.timeRange, 
657 |       interval: args.interval,
658 |       query: args.query
659 |     });
660 | 
661 |     reportProgress({
662 |       progress: 0,
663 |       total: 100,
664 |       message: "Starting visualization generation..."
665 |     });
666 | 
667 |     return safeOpenSearchQuery(async () => {
668 |       const timeRangeMs = parseTimeRange(args.timeRange);
669 |       const now = new Date();
670 |       const from = new Date(now.getTime() - timeRangeMs);
671 | 
672 |       reportProgress({
673 |         progress: 30,
674 |         total: 100,
675 |         message: "Querying OpenSearch..."
676 |       });
677 | 
678 |       const response = await client.search({
679 |         index: args.index,
680 |         body: {
681 |           size: 0,
682 |           query: {
683 |             bool: {
684 |               must: [
685 |                 { query_string: { query: args.query } },
686 |                 {
687 |                   range: {
688 |                     timestamp: {
689 |                       gte: from.toISOString(),
690 |                       lte: now.toISOString(),
691 |                     },
692 |                   },
693 |                 },
694 |               ],
695 |             },
696 |           },
697 |           aggs: {
698 |             alerts_over_time: {
699 |               date_histogram: {
700 |                 field: "timestamp",
701 |                 calendar_interval: args.interval,
702 |                 format: "yyyy-MM-dd HH:mm:ss",
703 |               },
704 |               aggs: {
705 |                 rule_levels: {
706 |                   terms: {
707 |                     field: "rule.level",
708 |                     size: 15,
709 |                   },
710 |                 },
711 |               },
712 |             },
713 |           },
714 |           timeout: "45s" // Longer timeout for visualization requests
715 |         },
716 |       });
717 | 
718 |       reportProgress({
719 |         progress: 70,
720 |         total: 100,
721 |         message: "Processing visualization data..."
722 |       });
723 | 
724 |       const buckets = response.body.aggregations?.alerts_over_time?.buckets || [];
725 |       
726 |       if (buckets.length === 0) {
727 |         return "No data available for visualization in the specified time range.";
728 |       }
729 |       
730 |       // Generate visualization from data
731 |       const timePoints = buckets.map(b => b.key_as_string.split(' ')[0]);
732 |       const counts = buckets.map(b => b.doc_count);
733 |       
734 |       let resultText = `## Alert Trend for the past ${args.timeRange}\n\n`;
735 |       resultText += `Query: ${args.query}\n\n`;
736 |       
737 |       // Simple text-based chart
738 |       const maxCount = Math.max(...counts);
739 |       const chartHeight = 10;
740 |       
741 |       resultText += "```\n";
742 |       for (let i = chartHeight; i > 0; i--) {
743 |         const threshold = maxCount * (i / chartHeight);
744 |         let line = counts.map(count => count >= threshold ? '█' : ' ').join('');
745 |         resultText += line + "\n";
746 |       }
747 |       
748 |       // X-axis dates
749 |       resultText += timePoints.map(d => d.substring(5)).join(' ') + "\n";
750 |       resultText += "```\n\n";
751 |       
752 |       // Table format
753 |       resultText += "| Date | Count |\n";
754 |       resultText += "|------|-------|\n";
755 |       for (let i = 0; i < timePoints.length; i++) {
756 |         resultText += `| ${timePoints[i]} | ${counts[i]} |\n`;
757 |       }
758 |       
759 |       reportProgress({
760 |         progress: 100,
761 |         total: 100,
762 |         message: "Visualization complete"
763 |       });
764 | 
765 |       return resultText;
766 |     }, "Failed to generate alert visualization. The query may be too complex or the connection timed out.");
767 |   },
768 | });
769 | 
770 | // Helper function to parse time range strings like "1h", "24h", "7d"
771 | function parseTimeRange(timeRange) {
772 |   const unit = timeRange.slice(-1);
773 |   const value = parseInt(timeRange.slice(0, -1));
774 |   
775 |   debugLog('Parsing time range:', timeRange, 'to milliseconds');
776 |   
777 |   switch (unit) {
778 |     case 'h':
779 |       return value * 60 * 60 * 1000; // hours to ms
780 |     case 'd':
781 |       return value * 24 * 60 * 60 * 1000; // days to ms
782 |     case 'w':
783 |       return value * 7 * 24 * 60 * 60 * 1000; // weeks to ms
784 |     case 'm':
785 |       return value * 30 * 24 * 60 * 60 * 1000; // months to ms (approximate)
786 |     default:
787 |       const error = `Invalid time range format: ${timeRange}`;
788 |       debugLog('Error:', error);
789 |       throw new Error(error);
790 |   }
791 | }
792 | 
793 | // Start the MCP server with stdio transport and handle errors
794 | debugLog('Starting MCP server with stdio transport');
795 | 
796 | process.on('uncaughtException', (error) => {
797 |   console.error('Uncaught Exception:', error);
798 |   if (error.code === 'ERR_UNHANDLED_ERROR' && error.context?.error?.code === -32001) {
799 |     console.error('MCP timeout error occurred. Consider:');
800 |     console.error('1. Increasing the defaultExecutionTimeoutMs in server configuration');
801 |     console.error('2. Checking if OpenSearch is responsive');
802 |     console.error('3. Reducing the complexity of your queries');
803 |   }
804 |   // Exit with error code
805 |   process.exit(1);
806 | });
807 | 
808 | try {
809 |   server.start({
810 |     transportType: "stdio"
811 |   });
812 |   console.log('OpenSearch MCP Server running in stdio mode');
813 |   console.log('To enable debug logging, set DEBUG=true in your .env file');
814 | } catch (error) {
815 |   console.error('Failed to start MCP server:', error);
816 |   process.exit(1);
817 | }
```