graylog-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/example-config.json +14 -0
- package/package.json +34 -0
- package/src/config.js +47 -0
- package/src/index.js +329 -0
- package/src/query.js +96 -0
- package/src/tools.js +144 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Leo Ruellas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Graylog MCP Server
|
|
2
|
+
|
|
3
|
+
A minimal MCP (Model Context Protocol) server in JavaScript that integrates with Graylog.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- JavaScript MCP server
|
|
8
|
+
- Tools: `fetch_graylog_messages` (query Graylog and return messages)
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Node.js 18+
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
git clone git@github.com:lcaliani/graylog-mcp.git
|
|
18
|
+
cd graylog-mcp
|
|
19
|
+
npm install
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
Set the following environment variables so the server can connect to Graylog:
|
|
25
|
+
|
|
26
|
+
- `BASE_URL`: Graylog base URL, e.g. `https://graylog.example.com`
|
|
27
|
+
- `API_TOKEN`: Graylog API token (used as the username, with password `token`)
|
|
28
|
+
|
|
29
|
+
> :exclamation: Suggestion: add these variables to your respective MCP client configuration file or app. Example in **Cursor** more below.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
## Use with an MCP client (Cursor/Claude Desktop)
|
|
33
|
+
|
|
34
|
+
1. Add this server to your MCP client configuration, poiting to the mcp entrypoint file (`src/index.js`). Common locations:
|
|
35
|
+
|
|
36
|
+
- Cursor: `~/.cursor/mcp.json`
|
|
37
|
+
- Claude Desktop (macOS): `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
38
|
+
- Claude Desktop (Linux): `~/.config/claude-desktop/claude_desktop_config.json`
|
|
39
|
+
|
|
40
|
+
Example config in **Cursor**:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"mcpServers": {
|
|
45
|
+
"simple-graylog-mcp": {
|
|
46
|
+
"command": "node",
|
|
47
|
+
"args": [
|
|
48
|
+
"/path/to/graylog-mcp/src/index.js"
|
|
49
|
+
],
|
|
50
|
+
"env": {
|
|
51
|
+
"BASE_URL": "http://your.graylog.server.net.br:9000",
|
|
52
|
+
"API_TOKEN": "your_graylog_api_token"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
2. After that, your client is already able to use the `fetch_graylog_messages` tool. Example prompt:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
Search for the latest 20 error logs of the example application, given that they occurred in the last 15 minutes.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
This should be enough for the tool to be used, but if wanted, you can also explicitly "force" the use of the tool. Example prompt:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
Search for the latest 20 error logs of the example application, given that they occurred in the last 15 minutes.
|
|
69
|
+
|
|
70
|
+
use simple-graylog-mcp
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
## Available tools
|
|
75
|
+
|
|
76
|
+
### fetch_graylog_messages
|
|
77
|
+
|
|
78
|
+
Fetch messages from Graylog.
|
|
79
|
+
|
|
80
|
+
Parameters:
|
|
81
|
+
|
|
82
|
+
- `query` (string): Search query. Example: `level:ERROR AND service:api`.
|
|
83
|
+
- `searchTimeRangeInSeconds` (number, optional): Relative time range in seconds. Default: `900` (15 minutes).
|
|
84
|
+
- `searchCountLimit` (number, optional): Max number of messages. Default: `50`.
|
|
85
|
+
- `fields` (string, optional): Comma-separated fields to include. Default: `*`.
|
|
86
|
+
|
|
87
|
+
## Troubleshooting
|
|
88
|
+
|
|
89
|
+
- Ensure `BASE_URL` and `API_TOKEN` are set.
|
|
90
|
+
- Verify Node.js version is 18+.
|
|
91
|
+
- Run `npm install` if dependencies are missing.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "graylog-mcp-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for fetching data from Graylog",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node src/index.js",
|
|
9
|
+
"dev": "node --watch src/index.js",
|
|
10
|
+
"test": "node test-server.js",
|
|
11
|
+
"test:manual": "echo \"Error: no test specified\" && exit 1"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"graylog",
|
|
16
|
+
"logging",
|
|
17
|
+
"model-context-protocol"
|
|
18
|
+
],
|
|
19
|
+
"author": "Leo Ruellas",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@modelcontextprotocol/sdk": "^1.18.0",
|
|
23
|
+
"axios": "^1.12.2",
|
|
24
|
+
"npm": "^11.6.0",
|
|
25
|
+
"zod": "^3.25.76"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^20.19.15",
|
|
29
|
+
"typescript": "^5.9.2"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
|
|
5
|
+
let connections = {};
|
|
6
|
+
const configPath = join(homedir(), ".graylog-mcp", "config.json");
|
|
7
|
+
try {
|
|
8
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
9
|
+
connections = config.connections || {};
|
|
10
|
+
} catch {
|
|
11
|
+
// No config file found
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let activeConnection = null;
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_FIELDS = [
|
|
17
|
+
"timestamp",
|
|
18
|
+
"gl2_message_id",
|
|
19
|
+
"source",
|
|
20
|
+
"env",
|
|
21
|
+
"level",
|
|
22
|
+
"message",
|
|
23
|
+
"logger_name",
|
|
24
|
+
"thread_name",
|
|
25
|
+
"PODNAME",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export function getConfigPath() {
|
|
29
|
+
return configPath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getConnections() {
|
|
33
|
+
return connections;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getActiveConnection() {
|
|
37
|
+
return activeConnection;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function setActiveConnection(name) {
|
|
41
|
+
activeConnection = name;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getActiveConnectionConfig() {
|
|
45
|
+
if (!activeConnection) return null;
|
|
46
|
+
return connections[activeConnection];
|
|
47
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import {
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
ListToolsRequestSchema
|
|
8
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import {
|
|
10
|
+
getConfigPath, getConnections, getActiveConnection,
|
|
11
|
+
setActiveConnection, getActiveConnectionConfig, DEFAULT_FIELDS
|
|
12
|
+
} from "./config.js";
|
|
13
|
+
import { buildQueryString, resolveFields, extractMessages, fetchMessageById, fetchStreams, searchGraylog } from "./query.js";
|
|
14
|
+
import { toolDefinitions } from "./tools.js";
|
|
15
|
+
|
|
16
|
+
const server = new Server({
|
|
17
|
+
name: "simple-graylog-mcp",
|
|
18
|
+
version: "1.0.0",
|
|
19
|
+
}, {
|
|
20
|
+
capabilities: {
|
|
21
|
+
tools: {},
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
26
|
+
return { tools: toolDefinitions };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
30
|
+
const { name } = request.params;
|
|
31
|
+
|
|
32
|
+
if (name === "list_connections") {
|
|
33
|
+
return listConnections();
|
|
34
|
+
}
|
|
35
|
+
if (name === "use_connection") {
|
|
36
|
+
return useConnection(request);
|
|
37
|
+
}
|
|
38
|
+
if (name === "fetch_graylog_messages") {
|
|
39
|
+
return fetchGraylogMessages(request);
|
|
40
|
+
}
|
|
41
|
+
if (name === "get_surrounding_messages") {
|
|
42
|
+
return getSurroundingMessages(request);
|
|
43
|
+
}
|
|
44
|
+
if (name === "list_streams") {
|
|
45
|
+
return listStreams();
|
|
46
|
+
}
|
|
47
|
+
if (name === "list_field_values") {
|
|
48
|
+
return listFieldValues(request);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw new Error(`Tool not found: ${name}`);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function listConnections() {
|
|
55
|
+
const connections = getConnections();
|
|
56
|
+
const names = Object.keys(connections);
|
|
57
|
+
if (names.length === 0) {
|
|
58
|
+
return {
|
|
59
|
+
content: [{
|
|
60
|
+
type: "text",
|
|
61
|
+
text: `No connections found. Add connections to ${getConfigPath()}`,
|
|
62
|
+
}],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const active = getActiveConnection();
|
|
67
|
+
const list = names.map(n => {
|
|
68
|
+
return `${active === n ? "* " : " "}${n} (${connections[n].baseUrl})`;
|
|
69
|
+
}).join("\n");
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
content: [{
|
|
73
|
+
type: "text",
|
|
74
|
+
text: `Available connections (* = active):\n${list}`,
|
|
75
|
+
}],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function useConnection(request) {
|
|
80
|
+
const connectionName = request.params.arguments?.name;
|
|
81
|
+
if (!connectionName) {
|
|
82
|
+
throw new Error("Connection name is required");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const connections = getConnections();
|
|
86
|
+
if (!connections[connectionName]) {
|
|
87
|
+
const available = Object.keys(connections).join(", ");
|
|
88
|
+
return {
|
|
89
|
+
content: [{
|
|
90
|
+
type: "text",
|
|
91
|
+
text: `Connection "${connectionName}" not found. Available: ${available || "none"}`,
|
|
92
|
+
}],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setActiveConnection(connectionName);
|
|
97
|
+
return {
|
|
98
|
+
content: [{
|
|
99
|
+
type: "text",
|
|
100
|
+
text: `Connected to "${connectionName}" (${connections[connectionName].baseUrl})`,
|
|
101
|
+
}],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function requireActiveConnection() {
|
|
106
|
+
const conn = getActiveConnectionConfig();
|
|
107
|
+
if (!conn) {
|
|
108
|
+
const available = Object.keys(getConnections()).join(", ");
|
|
109
|
+
return {
|
|
110
|
+
error: {
|
|
111
|
+
content: [{
|
|
112
|
+
type: "text",
|
|
113
|
+
text: `No active connection. Use 'use_connection' first. Available: ${available || "none"}`,
|
|
114
|
+
}],
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return { conn };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function fetchGraylogMessages(request) {
|
|
122
|
+
const { conn, error } = requireActiveConnection();
|
|
123
|
+
if (error) return error;
|
|
124
|
+
|
|
125
|
+
const args = request.params.arguments || {};
|
|
126
|
+
const queryString = buildQueryString(args.query, args.filters, args.exactMatch ?? true);
|
|
127
|
+
const fieldList = resolveFields(args.fields, DEFAULT_FIELDS);
|
|
128
|
+
const pageSize = args.pageSize ?? 50;
|
|
129
|
+
const page = args.page ?? 1;
|
|
130
|
+
const offset = (page - 1) * pageSize;
|
|
131
|
+
|
|
132
|
+
const payload = {
|
|
133
|
+
queries: [{
|
|
134
|
+
id: "q1",
|
|
135
|
+
query: { type: "elasticsearch", query_string: queryString },
|
|
136
|
+
timerange: { type: "relative", range: args.searchTimeRangeInSeconds ?? 900 },
|
|
137
|
+
search_types: [{
|
|
138
|
+
id: "st1",
|
|
139
|
+
type: "messages",
|
|
140
|
+
limit: pageSize,
|
|
141
|
+
offset,
|
|
142
|
+
}]
|
|
143
|
+
}]
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const data = await searchGraylog(conn.baseUrl, conn.apiToken, payload);
|
|
148
|
+
const { totalResults, extracted } = extractMessages(data, fieldList);
|
|
149
|
+
const totalPages = Math.ceil(totalResults / pageSize);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
content: [{
|
|
153
|
+
type: "text",
|
|
154
|
+
text: JSON.stringify({
|
|
155
|
+
total_results: totalResults,
|
|
156
|
+
page,
|
|
157
|
+
page_size: pageSize,
|
|
158
|
+
total_pages: totalPages,
|
|
159
|
+
messages: extracted,
|
|
160
|
+
}),
|
|
161
|
+
}],
|
|
162
|
+
};
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return {
|
|
165
|
+
content: [{
|
|
166
|
+
type: "text",
|
|
167
|
+
text: `Error fetching messages: ${err.message}`,
|
|
168
|
+
}],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function getSurroundingMessages(request) {
|
|
174
|
+
const { conn, error } = requireActiveConnection();
|
|
175
|
+
if (error) return error;
|
|
176
|
+
|
|
177
|
+
const args = request.params.arguments || {};
|
|
178
|
+
let messageTimestamp = args.messageTimestamp;
|
|
179
|
+
|
|
180
|
+
// Resolve timestamp from messageId if provided
|
|
181
|
+
if (args.messageId) {
|
|
182
|
+
const msg = await fetchMessageById(conn.baseUrl, conn.apiToken, args.messageId);
|
|
183
|
+
if (!msg) {
|
|
184
|
+
return {
|
|
185
|
+
content: [{
|
|
186
|
+
type: "text",
|
|
187
|
+
text: `Message not found: ${args.messageId}`,
|
|
188
|
+
}],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
messageTimestamp = msg.timestamp;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!messageTimestamp) {
|
|
195
|
+
throw new Error("Either messageId or messageTimestamp is required");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const targetTime = new Date(messageTimestamp);
|
|
199
|
+
if (isNaN(targetTime.getTime())) {
|
|
200
|
+
throw new Error(`Invalid timestamp: ${messageTimestamp}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const surroundingSeconds = args.surroundingSeconds ?? 5;
|
|
204
|
+
const from = new Date(targetTime.getTime() - surroundingSeconds * 1000).toISOString();
|
|
205
|
+
const to = new Date(targetTime.getTime() + surroundingSeconds * 1000).toISOString();
|
|
206
|
+
const queryString = buildQueryString(args.query, args.filters, args.exactMatch ?? true);
|
|
207
|
+
const fieldList = resolveFields(args.fields, DEFAULT_FIELDS);
|
|
208
|
+
|
|
209
|
+
const payload = {
|
|
210
|
+
queries: [{
|
|
211
|
+
id: "q1",
|
|
212
|
+
query: { type: "elasticsearch", query_string: queryString },
|
|
213
|
+
timerange: { type: "absolute", from, to },
|
|
214
|
+
search_types: [{
|
|
215
|
+
id: "st1",
|
|
216
|
+
type: "messages",
|
|
217
|
+
limit: args.limit ?? 50,
|
|
218
|
+
sort: [{ field: "timestamp", order: "ASC" }],
|
|
219
|
+
}]
|
|
220
|
+
}]
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const data = await searchGraylog(conn.baseUrl, conn.apiToken, payload);
|
|
225
|
+
const { totalResults, extracted } = extractMessages(data, fieldList);
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
content: [{
|
|
229
|
+
type: "text",
|
|
230
|
+
text: JSON.stringify({
|
|
231
|
+
target_timestamp: messageTimestamp,
|
|
232
|
+
window: { from, to },
|
|
233
|
+
total_results: totalResults,
|
|
234
|
+
messages: extracted,
|
|
235
|
+
}),
|
|
236
|
+
}],
|
|
237
|
+
};
|
|
238
|
+
} catch (err) {
|
|
239
|
+
return {
|
|
240
|
+
content: [{
|
|
241
|
+
type: "text",
|
|
242
|
+
text: `Error fetching surrounding messages: ${err.message}`,
|
|
243
|
+
}],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function listStreams() {
|
|
249
|
+
const { conn, error } = requireActiveConnection();
|
|
250
|
+
if (error) return error;
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const data = await fetchStreams(conn.baseUrl, conn.apiToken);
|
|
254
|
+
const streams = (data.streams || []).map(s => ({
|
|
255
|
+
id: s.id,
|
|
256
|
+
title: s.title,
|
|
257
|
+
description: s.description || "",
|
|
258
|
+
}));
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
content: [{
|
|
262
|
+
type: "text",
|
|
263
|
+
text: JSON.stringify({ total: data.total || streams.length, streams }),
|
|
264
|
+
}],
|
|
265
|
+
};
|
|
266
|
+
} catch (err) {
|
|
267
|
+
return {
|
|
268
|
+
content: [{
|
|
269
|
+
type: "text",
|
|
270
|
+
text: `Error fetching streams: ${err.message}`,
|
|
271
|
+
}],
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function listFieldValues(request) {
|
|
277
|
+
const { conn, error } = requireActiveConnection();
|
|
278
|
+
if (error) return error;
|
|
279
|
+
|
|
280
|
+
const args = request.params.arguments || {};
|
|
281
|
+
const field = args.field;
|
|
282
|
+
if (!field) {
|
|
283
|
+
throw new Error("field is required");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const limit = args.limit ?? 20;
|
|
287
|
+
const queryString = buildQueryString(args.query, args.filters, args.exactMatch ?? true);
|
|
288
|
+
|
|
289
|
+
const payload = {
|
|
290
|
+
queries: [{
|
|
291
|
+
id: "q1",
|
|
292
|
+
query: { type: "elasticsearch", query_string: queryString },
|
|
293
|
+
timerange: { type: "relative", range: args.timeRangeInSeconds ?? 3600 },
|
|
294
|
+
search_types: [{
|
|
295
|
+
id: "st1",
|
|
296
|
+
type: "pivot",
|
|
297
|
+
row_groups: [{ type: "values", field, limit }],
|
|
298
|
+
series: [{ type: "count", id: "count" }],
|
|
299
|
+
rollup: false,
|
|
300
|
+
}]
|
|
301
|
+
}]
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const data = await searchGraylog(conn.baseUrl, conn.apiToken, payload);
|
|
306
|
+
const rows = data?.results?.q1?.search_types?.st1?.rows || [];
|
|
307
|
+
const values = rows.map(r => ({
|
|
308
|
+
value: r.key?.[0],
|
|
309
|
+
count: r.values?.[0]?.value ?? 0,
|
|
310
|
+
}));
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
content: [{
|
|
314
|
+
type: "text",
|
|
315
|
+
text: JSON.stringify({ field, total: values.length, values }),
|
|
316
|
+
}],
|
|
317
|
+
};
|
|
318
|
+
} catch (err) {
|
|
319
|
+
return {
|
|
320
|
+
content: [{
|
|
321
|
+
type: "text",
|
|
322
|
+
text: `Error fetching field values: ${err.message}`,
|
|
323
|
+
}],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const transport = new StdioServerTransport();
|
|
329
|
+
await server.connect(transport);
|
package/src/query.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
|
|
3
|
+
export function buildQueryString(query, filters, exactMatch = true) {
|
|
4
|
+
let qs;
|
|
5
|
+
if (!query || query === "*") {
|
|
6
|
+
qs = "*";
|
|
7
|
+
} else if (exactMatch && !query.startsWith('"') && !/[*?:()]/.test(query)) {
|
|
8
|
+
qs = `"${query}"`;
|
|
9
|
+
} else {
|
|
10
|
+
qs = query;
|
|
11
|
+
}
|
|
12
|
+
if (filters && typeof filters === "object") {
|
|
13
|
+
for (const [field, value] of Object.entries(filters)) {
|
|
14
|
+
if (value === undefined || value === null) continue;
|
|
15
|
+
if (typeof value === "number") {
|
|
16
|
+
qs += ` AND ${field}:${value}`;
|
|
17
|
+
} else {
|
|
18
|
+
qs += ` AND ${field}:"${value}"`;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return qs;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveFields(fieldsParam, defaultFields) {
|
|
26
|
+
if (fieldsParam === "*") return null; // null = return all fields
|
|
27
|
+
if (fieldsParam) return fieldsParam.split(",").map(s => s.trim());
|
|
28
|
+
return defaultFields;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function extractMessages(responseData, fieldList) {
|
|
32
|
+
const results = responseData?.results?.q1?.search_types?.st1;
|
|
33
|
+
const messages = results?.messages || [];
|
|
34
|
+
const totalResults = results?.total_results || 0;
|
|
35
|
+
|
|
36
|
+
const extracted = messages.map(m => {
|
|
37
|
+
const msg = m.message || {};
|
|
38
|
+
if (!fieldList) return msg; // null = all fields
|
|
39
|
+
const picked = {};
|
|
40
|
+
for (const f of fieldList) {
|
|
41
|
+
if (msg[f] !== undefined) picked[f] = msg[f];
|
|
42
|
+
}
|
|
43
|
+
return picked;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return { totalResults, extracted };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function fetchMessageById(baseUrl, apiToken, messageId) {
|
|
50
|
+
const payload = {
|
|
51
|
+
queries: [{
|
|
52
|
+
id: "q1",
|
|
53
|
+
query: { type: "elasticsearch", query_string: `gl2_message_id:${messageId}` },
|
|
54
|
+
timerange: { type: "relative", range: 86400 },
|
|
55
|
+
search_types: [{
|
|
56
|
+
id: "st1",
|
|
57
|
+
type: "messages",
|
|
58
|
+
limit: 1,
|
|
59
|
+
}]
|
|
60
|
+
}]
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const data = await searchGraylog(baseUrl, apiToken, payload);
|
|
64
|
+
const messages = data?.results?.q1?.search_types?.st1?.messages || [];
|
|
65
|
+
if (messages.length === 0) return null;
|
|
66
|
+
return messages[0].message;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function fetchStreams(baseUrl, apiToken) {
|
|
70
|
+
const response = await axios.get(`${baseUrl}/api/streams`, {
|
|
71
|
+
headers: {
|
|
72
|
+
'Accept': 'application/json',
|
|
73
|
+
'X-Requested-By': 'graylog-mcp',
|
|
74
|
+
},
|
|
75
|
+
auth: {
|
|
76
|
+
username: apiToken,
|
|
77
|
+
password: 'token',
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
return response.data;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function searchGraylog(baseUrl, apiToken, payload) {
|
|
84
|
+
const response = await axios.post(`${baseUrl}/api/views/search/sync`, payload, {
|
|
85
|
+
headers: {
|
|
86
|
+
'Accept': 'application/json',
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
'X-Requested-By': 'graylog-mcp',
|
|
89
|
+
},
|
|
90
|
+
auth: {
|
|
91
|
+
username: apiToken,
|
|
92
|
+
password: 'token',
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
return response.data;
|
|
96
|
+
}
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export const toolDefinitions = [
|
|
2
|
+
{
|
|
3
|
+
name: "list_connections",
|
|
4
|
+
description: "List all available Graylog connections configured in ~/.graylog-mcp/config.json",
|
|
5
|
+
inputSchema: {
|
|
6
|
+
type: "object",
|
|
7
|
+
properties: {},
|
|
8
|
+
},
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
name: "use_connection",
|
|
12
|
+
description: "Connect to a specific Graylog instance by name. Must be called before fetching messages.",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
name: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "The connection name as defined in ~/.graylog-mcp/config.json",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
required: ["name"],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "fetch_graylog_messages",
|
|
26
|
+
description: "Fetch messages from the active Graylog connection. Use 'use_connection' first to select a connection.",
|
|
27
|
+
inputSchema: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
query: {
|
|
31
|
+
type: "string",
|
|
32
|
+
description: "The query to search for, with the respective fields and values",
|
|
33
|
+
},
|
|
34
|
+
searchTimeRangeInSeconds: {
|
|
35
|
+
type: "number",
|
|
36
|
+
description: "The time range to search for, in seconds",
|
|
37
|
+
},
|
|
38
|
+
pageSize: {
|
|
39
|
+
type: "number",
|
|
40
|
+
description: "Number of messages per page. Default: 50",
|
|
41
|
+
},
|
|
42
|
+
page: {
|
|
43
|
+
type: "number",
|
|
44
|
+
description: "Page number (starts at 1). Default: 1",
|
|
45
|
+
},
|
|
46
|
+
fields: {
|
|
47
|
+
type: "string",
|
|
48
|
+
description: "Comma-separated field names to return, or '*' for all fields. Default: returns key fields only (timestamp, gl2_message_id, source, env, level, message, logger_name, thread_name, PODNAME)",
|
|
49
|
+
},
|
|
50
|
+
filters: {
|
|
51
|
+
type: "object",
|
|
52
|
+
description: "Field filters (e.g. {\"env\": \"marketplace_loki\", \"level\": 7, \"source\": \"prefr-management\"})",
|
|
53
|
+
},
|
|
54
|
+
exactMatch: {
|
|
55
|
+
type: "boolean",
|
|
56
|
+
description: "If true (default), wraps the query in quotes for exact match. Set to false for fuzzy/wildcard search.",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "get_surrounding_messages",
|
|
63
|
+
description: "Get messages surrounding a specific message. Provide messageId (preferred) or messageTimestamp to identify the target message.",
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
messageId: {
|
|
68
|
+
type: "string",
|
|
69
|
+
description: "gl2_message_id of the target message (preferred). The timestamp will be looked up automatically.",
|
|
70
|
+
},
|
|
71
|
+
messageTimestamp: {
|
|
72
|
+
type: "string",
|
|
73
|
+
description: "ISO timestamp of the target message (fallback if messageId is not available)",
|
|
74
|
+
},
|
|
75
|
+
surroundingSeconds: {
|
|
76
|
+
type: "number",
|
|
77
|
+
description: "Time window in seconds (± around the timestamp). Default: 5",
|
|
78
|
+
},
|
|
79
|
+
query: {
|
|
80
|
+
type: "string",
|
|
81
|
+
description: "Additional query filter to narrow context",
|
|
82
|
+
},
|
|
83
|
+
filters: {
|
|
84
|
+
type: "object",
|
|
85
|
+
description: "Field filters (e.g. {\"env\": \"marketplace_loki\", \"level\": 7})",
|
|
86
|
+
},
|
|
87
|
+
exactMatch: {
|
|
88
|
+
type: "boolean",
|
|
89
|
+
description: "If true (default), wraps the query in quotes for exact match. Set to false for fuzzy/wildcard search.",
|
|
90
|
+
},
|
|
91
|
+
limit: {
|
|
92
|
+
type: "number",
|
|
93
|
+
description: "Maximum number of messages to return. Default: 50",
|
|
94
|
+
},
|
|
95
|
+
fields: {
|
|
96
|
+
type: "string",
|
|
97
|
+
description: "Comma-separated field names to return, or '*' for all fields. Default: returns key fields only (timestamp, gl2_message_id, source, env, level, message, logger_name, thread_name, PODNAME)",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "list_streams",
|
|
104
|
+
description: "List all available Graylog streams in the active connection.",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: {},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "list_field_values",
|
|
112
|
+
description: "List distinct values of a field with message counts. Useful for discovering available sources, environments, logger names, etc. Results are sorted by count descending.",
|
|
113
|
+
inputSchema: {
|
|
114
|
+
type: "object",
|
|
115
|
+
properties: {
|
|
116
|
+
field: {
|
|
117
|
+
type: "string",
|
|
118
|
+
description: "The field to get distinct values for (e.g. 'source', 'env', 'logger_name', 'level')",
|
|
119
|
+
},
|
|
120
|
+
query: {
|
|
121
|
+
type: "string",
|
|
122
|
+
description: "Query to scope the results (e.g. search within specific messages)",
|
|
123
|
+
},
|
|
124
|
+
filters: {
|
|
125
|
+
type: "object",
|
|
126
|
+
description: "Field filters to narrow scope (e.g. {\"env\": \"marketplace_loki\"})",
|
|
127
|
+
},
|
|
128
|
+
exactMatch: {
|
|
129
|
+
type: "boolean",
|
|
130
|
+
description: "If true (default), wraps the query in quotes for exact match. Set to false for fuzzy/wildcard search.",
|
|
131
|
+
},
|
|
132
|
+
timeRangeInSeconds: {
|
|
133
|
+
type: "number",
|
|
134
|
+
description: "Time range in seconds. Default: 3600 (1 hour)",
|
|
135
|
+
},
|
|
136
|
+
limit: {
|
|
137
|
+
type: "number",
|
|
138
|
+
description: "Maximum number of distinct values to return. Default: 20",
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
required: ["field"],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
];
|