{
  "markdown": "# Graylog MCP Server\n\n[![CI](https://github.com/jperelli/graylog-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/jperelli/graylog-mcp/actions/workflows/ci.yml)\n[![npm version](https://img.shields.io/npm/v/@jperelli/graylog-mcp.svg)](https://www.npmjs.com/package/@jperelli/graylog-mcp)\n[![npm downloads](https://img.shields.io/npm/dm/@jperelli/graylog-mcp.svg)](https://www.npmjs.com/package/@jperelli/graylog-mcp)\n[![node](https://img.shields.io/node/v/@jperelli/graylog-mcp.svg)](https://nodejs.org)\n[![license](https://img.shields.io/npm/l/@jperelli/graylog-mcp.svg)](LICENSE)\n\nA minimal MCP (Model Context Protocol) server in JavaScript that integrates with Graylog.\n\n## Features\n\n- JavaScript MCP server\n- Tools: `search` (read matching log lines across multiple streams), `analyze` (aggregate matches by a field, with an optional time histogram), `list_fields` (which fields exist), `list_streams` (discover readable streams), and `get_message` (fetch one full document)\n- **Empty results explain themselves.** A zero-match search reports whether the query is wrong, the window is quiet, or Graylog simply hasn't *indexed* the logs yet — three causes that otherwise look identical and send an agent in circles\n- **Discovery over guessing.** `list_fields` and `analyze`'s `valueContains` let an agent look up real field names and values instead of inventing them, since a wrong guess returns 0 hits and reads as \"no logs exist\"\n- Token-efficient by design, `search` returns a concise projection of high-signal fields by default; opt into full documents with `verbose`\n- A server-level \"instructions\" manual teaches the client the query syntax, stream-scoping rules, and severity quirks up front\n- Multi-instance support, query multiple Graylog servers from a single MCP server\n\n## Requirements\n\n- Node.js 18+\n\n## Configuration\n\nConfigure one or more Graylog instances using numbered env vars:\n\n| Variable | Required | Description |\n|---|---|---|\n| `GRAYLOG_BASE_URL_INSTANCE_N` | yes | Graylog base URL for instance N |\n| `GRAYLOG_API_TOKEN_INSTANCE_N` | yes | API token for instance N |\n| `GRAYLOG_LABEL_INSTANCE_N` | no | Human-readable label (default: `instance_N`) |\n\nReplace `N` with `1`, `2`, `3`, … to register as many instances as needed. Only instances with both `BASE_URL` and `API_TOKEN` set will be active.\n\n## Use with an MCP client\n\nNo installation needed, `npx` downloads and runs the server automatically.\n\n### Claude Code\n\n```bash\nclaude mcp add graylog-mcp \\\n  -e GRAYLOG_BASE_URL_INSTANCE_1=http://your-graylog-production.example.com:9000 \\\n  -e GRAYLOG_API_TOKEN_INSTANCE_1=your_production_token \\\n  -e GRAYLOG_LABEL_INSTANCE_1=production \\\n  -e GRAYLOG_BASE_URL_INSTANCE_2=http://your-graylog-staging.example.com:9000 \\\n  -e GRAYLOG_API_TOKEN_INSTANCE_2=your_staging_token \\\n  -e GRAYLOG_LABEL_INSTANCE_2=staging \\\n  -- npx -y @jperelli/graylog-mcp@latest\n```\n\nOr add it manually to `~/.claude.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"graylog-mcp\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@jperelli/graylog-mcp@latest\"],\n      \"env\": {\n        \"GRAYLOG_BASE_URL_INSTANCE_1\":  \"http://your-graylog-production.example.com:9000\",\n        \"GRAYLOG_API_TOKEN_INSTANCE_1\": \"your_production_token\",\n        \"GRAYLOG_LABEL_INSTANCE_1\":     \"production\",\n\n        \"GRAYLOG_BASE_URL_INSTANCE_2\":  \"http://your-graylog-staging.example.com:9000\",\n        \"GRAYLOG_API_TOKEN_INSTANCE_2\": \"your_staging_token\",\n        \"GRAYLOG_LABEL_INSTANCE_2\":     \"staging\"\n      }\n    }\n  }\n}\n```\n\n### Cursor\n\nAdd to `~/.cursor/mcp.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"graylog-mcp\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@jperelli/graylog-mcp@latest\"],\n      \"env\": {\n        \"GRAYLOG_BASE_URL_INSTANCE_1\":  \"http://your-graylog-production.example.com:9000\",\n        \"GRAYLOG_API_TOKEN_INSTANCE_1\": \"your_production_token\",\n        \"GRAYLOG_LABEL_INSTANCE_1\":     \"production\",\n\n        \"GRAYLOG_BASE_URL_INSTANCE_2\":  \"http://your-graylog-staging.example.com:9000\",\n        \"GRAYLOG_API_TOKEN_INSTANCE_2\": \"your_staging_token\",\n        \"GRAYLOG_LABEL_INSTANCE_2\":     \"staging\"\n      }\n    }\n  }\n}\n```\n\n### Claude Desktop\n\nConfig file locations:\n\n- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`\n- Linux: `~/.config/claude-desktop/claude_desktop_config.json`\n- Windows: `%APPDATA%\\Claude\\claude_desktop_config.json`\n\nUse the same JSON structure shown above for Cursor.\n\n---\n\n## Use\n\nOnce configured, the tools become available and are called automatically when needed. The usual flow is to search the Default Stream (`000000000000000000000001`), which covers everything the token can read, then `analyze` to spot patterns, `search` to read individual lines, and `get_message` to inspect one hit in full. Reach for `list_streams` only to scope to a specific named stream. Example prompts:\n\n```\nSearch Graylog for errors in the payments namespace in the last 15 minutes.\nQuery the \"staging\" instance.\n```\n\n```\nWhich containers produced the most errors in the last hour?\n```\n\n```\nWhich namespaces have \"payments\" in the name?\n```\n\n**Don't let the agent guess field names or values.** A query on a field or value that doesn't exist matches nothing, which looks exactly like \"there are no logs\". `list_fields` answers which fields exist, and `analyze` with `valueContains` answers which values a field actually has.\n\n\n## Available tools\n\n### list_streams\n\nList the streams the configured API token can read (id + title). Only readable ones are returned.\n\nYou usually **don't need this**: pass the Default Stream id `000000000000000000000001` to `search`/`analyze` to cover everything the token can read. A cluster can hold thousands of streams (the author's has 1,205), so output is capped — use `titleContains` when you want one specific named stream.\n\nParameters:\n\n- `instance` (string, optional): Label of the Graylog instance to query. Defaults to the first configured instance.\n- `titleContains` (string, optional): Case-insensitive substring filter on the stream title (e.g. `catalogue`).\n- `limit` (number, optional): Max streams to return. Default: `50`. The Default Stream is always included and never counts against the cap.\n\n### list_fields\n\nList the message fields that actually exist in the index. Use it **before** searching on a field you haven't seen in a result, so you never guess a field name.\n\nA cluster indexes thousands of fields (the author's: 3,269), and near-duplicates are common — `namespace_name`, `Pod_namespace`, `pod_namespace` and `Namespace` may all exist while only one is populated by your shipper. Pass `contains` to narrow.\n\nParameters:\n\n- `contains` (string, optional): Case-insensitive substring filter on the field name, e.g. `namespace`, `pod`, `level`.\n- `limit` (number, optional): Max field names to return. Default: `100`.\n- `instance` (string, optional): Instance label. Defaults to the first configured instance.\n\n### search\n\nRead matching log lines across one or more streams, merged newest-first. By default it returns a **concise projection** of high-signal fields (`timestamp`, `source`, `level`, `container_name`, `pod_name`, `namespace_name`, `application_name`, `service`, `logger_name`, `name`, `msg`, `err`, `stack`, `message`) plus each hit's `_id`/`_index`, with the raw `message` body truncated to 500 chars, this keeps the agent's context small. Set `verbose: true` (or pass explicit `fields`) to get every populated field, untruncated. Stream IDs are **required**, an all-streams search is not performed implicitly, because a limited-permission token would be rejected with `403 Not authorized`.\n\n`name`/`msg`/`err`/`stack` are there because a shipper that parses a JSON log line (pino, bunyan, structlog) extracts its keys into real fields. Those fields *are* the summary of the event, and they're cheap — the raw `message` body that contains them is neither, which is why it's truncated hard by default.\n\n> **Reach for `analyze` before `search`.** Raw lines are the most expensive thing this server returns. A hundred repetitions of one error cost a hundred times as much via `search` as one aggregated row via `analyze`, and tell you less. Use `search` once you know which line you want.\n\n> **Tip:** to search everything the token can see (including messages not routed to a named stream), pass the Default Stream id `000000000000000000000001`. `list_streams` also surfaces it.\n\nParameters:\n\n- `query` (string, **required**): Search query, using Graylog/Elasticsearch syntax. Examples: `msg:Error`, `namespace_name:app-payments-qa AND error`, `source:api-*`, `*`.\n- `streams` (string, **required**): Comma-separated stream IDs to search. Get them from `list_streams`.\n- `instance` (string, optional): Label of the Graylog instance to query. Defaults to the first configured instance.\n- `searchTimeRangeInSeconds` (number, optional): Relative time range in seconds. Default: `900` (15 minutes).\n- `from` / `to` (string, optional): Absolute window in ISO-8601 UTC (e.g. `2026-07-11 14:00:00`). When both are set they override the relative range, use them to investigate a known incident window.\n- `searchCountLimit` (number, optional): Max number of messages. Default: `50`.\n- `messageChars` (number, optional): Max characters of the raw message body per hit. Default: `500`. Raise it only when the detail you need lives in the raw body rather than the parsed fields.\n- `verbose` (boolean, optional): Return every populated field, untruncated, instead of the concise projection. Default: `false`.\n- `fields` (string, optional): Comma-separated explicit field list to return. Overrides the concise projection.\n\nThe response is `{ returned, total_matched, streams, messages, note?, projection?, why_no_results? }`, where `total_matched` is the total number of hits across the streams (may exceed `returned`, which is capped by `searchCountLimit`); when it does, `note` explains how to see more.\n\n**When a search matches nothing, it tells you why.** A bare `total_matched: 0` is ambiguous, and the three causes need opposite responses, so `why_no_results` names the one that applies:\n\n- *The window has messages, but none match.* The streams and time range are fine, so the query is wrong — usually a guessed field name or value. Confirm with `list_fields` / `analyze`.\n- *The window is empty, and indexing is current.* These streams are genuinely quiet.\n- *The window is empty, and the newest indexed message is hours old.* **Graylog is still indexing.** The logs exist and have been received; they just aren't searchable yet. The response reports how far behind indexing is and the journal backlog. Don't conclude the logs are missing — widen the range or retry.\n\nThat last case is easy to misread as \"this service produced no logs\", and it's the reason this project exists in its current shape: the pipeline can lag ingestion by hours under load.\n\n> **Note on log levels — severity must be discovered, not guessed.** Some services emit a top-level Graylog `level` (syslog: 3=error, 4=warn). Others log JSON, and the shipper extracts its keys into their *own* fields: pino's `{\"level\":50,\"msg\":\"Error\",\"name\":\"SvcX\"}` typically becomes fields `msg` and `name`, while its numeric `level` is **lost** — it collides with the container's own `level` (often `7`), so `level:50` and `level:ERROR` both match **nothing** even though the errors are plainly there. Guessing a disjunction like `level:ERROR OR level:50 OR error OR exception OR fatal` is how agents waste turns. Instead run `list_fields` (`contains: \"level\"`, `\"msg\"`, `\"err\"`), then `analyze` on `msg` to see the actual values. Free-text `error` works as a fallback; don't assume `exception` or `fatal` exist. And avoid `\"level\":50` as a query, a quoted string before `:` is invalid Lucene.\n\n### analyze\n\nAggregate matching messages by the **top values of a field** instead of returning raw lines, e.g. which `source`, `container_name`, or `level` dominates the errors in a window. Optionally add a **time histogram** of total match volume. Two uses:\n\n1. **Find what is failing.** Aggregate on a message field (`msg`, or whatever short summary field `list_fields` reveals) to collapse a thousand repetitions of one error into a single row with a count; on `name` / `container_name` / `source` to see who is emitting them. This is the fastest route from \"is anything weird?\" to an answer — and it costs a few hundred tokens where the equivalent `search` costs tens of thousands:\n\n   ```\n   analyze field:msg    → 1386  Error\n                            60  rabbitmq pub/sub: publishing to …exchange failed\n   analyze field:name   → 1290  CatalogueService\n                            96  ShippingService\n   ```\n\n2. **Discover a value before filtering on it.** Set `valueContains` to find the exact name of a namespace/pod/service you only half-know. Elasticsearch rejects a leading wildcard, so `namespace_name:*catalogue*` is a hard error, not an empty result — this is the only way to substring-match a value.\n\nParameters:\n\n- `field` (string, **required**): Field to break down by, e.g. `source`, `namespace_name`, `container_name`, `level`. Confirm it exists with `list_fields` if you haven't seen it in a result.\n- `streams` (string, **required**): Comma-separated stream IDs, or the Default Stream id.\n- `query` (string, optional): Lucene filter applied before aggregating. Default: `*`.\n- `valueContains` (string, optional): Case-insensitive substring filter on the returned **values**, applied locally over a wide bucket scan.\n- `instance` (string, optional): Instance label. Defaults to the first configured instance.\n- `searchTimeRangeInSeconds` (number, optional): Relative range in seconds. Default: `900`. Or use `from`/`to` for an absolute window.\n- `size` (number, optional): Number of top values to return. Default: `20`.\n- `histogramInterval` (string, optional): One of `minute`, `hour`, `day`, `week`, `month`. When set, the response also includes a time histogram of match counts.\n\nThe response is `{ field, query, streams, total_matched, top_values: [{ value, count }], not_in_top_values, histogram?, note?, warning?, failed_streams?, why_no_results? }`, where `not_in_top_values` counts matches that the returned values don't account for — messages with no value for the field, plus any bucket past the cut-off.\n\nA stream the token can't read is **skipped, not fatal**: you get the aggregation over the readable streams plus `failed_streams` and a `warning` naming the ones excluded, in the same response.\n\n> Implemented on Graylog's **Views/Aggregations API** (`POST /api/views/search/sync`), which takes every stream in a single request. The legacy `search/universal/*/terms` and `/histogram` endpoints this originally used were **removed in Graylog 6.0** and return `404` there.\n\n### get_message\n\nFetch the full, untruncated document for a single message by its `_id` and `_index` (both returned by `search`). Use it after a concise `search` to inspect one hit in detail without pulling every result verbose.\n\nParameters:\n\n- `messageId` (string, **required**): The `_id` from a search result.\n- `index` (string, **required**): The `_index` from a search result.\n- `instance` (string, optional): Instance label. Defaults to the first configured instance.\n\n## Design rationale\n\nThe tools here are shaped around published guidance on building MCP servers that AI agents can actually use well, rather than mirroring the Graylog REST API one endpoint at a time. The key ideas and where they come from:\n\n- **Design for the agent's task, not the API surface, fewer, outcome-oriented tools.** David Cramer (Sentry) makes the case that most MCP servers are still weak because they wrap raw endpoints instead of the jobs an agent needs to do; Sentry ships a curated, modest toolset instead. So this server exposes four task-shaped tools (discover → aggregate → read → drill in), not a wrapper per endpoint.\n, David Cramer, [*MCP Is Not Good Yet*](https://www.youtube.com/watch?v=FCi4jT86gSw) · [*Yes, Sentry has an MCP Server (…and it's pretty good)*](https://blog.sentry.io/yes-sentry-has-an-mcp-server-and-its-pretty-good/)\n\n- **Return high-signal context and protect the token budget.** Anthropic's guidance is that tools should return concise, relevant results and support filtering/truncation/pagination rather than dumping raw data into the model's context. Hence `search` returns a concise projection by default (with `verbose` and `get_message` as opt-in escalation) and emits a `note` when results are capped.\n, Anthropic, [*Writing effective tools for agents*](https://www.anthropic.com/engineering/writing-tools-for-agents)\n\n- **Pair raw retrieval with an aggregation/analysis tool.** New Relic's logging MCP does not only list log lines; it offers keyword search plus an analysis tool that surfaces error patterns and recurring issues. `analyze` fills that role for Graylog (top values of a field + optional histogram) so an agent can find patterns cheaply before reading individual lines.\n, New Relic, [*MCP tool reference*](https://docs.newrelic.com/docs/agentic-ai/mcp/tool-reference/)\n\n- **Put the \"user manual\" in server instructions, not in every tool description.** The MCP project recommends a top-level `instructions` field for cross-tool workflow, constraints, and quirks, keeping individual tool descriptions tight. This server's `instructions` teach the query syntax, the mandatory stream-scoping rule (a limited token gets `403` otherwise), and the pino numeric-severity gotcha once, up front.\n, Model Context Protocol, [*Server Instructions: Giving LLMs a user manual for your server*](https://blog.modelcontextprotocol.io/posts/2025-11-03-using-server-instructions/)\n\n## Troubleshooting\n\n- Ensure at least `GRAYLOG_BASE_URL_INSTANCE_1` and `GRAYLOG_API_TOKEN_INSTANCE_1` are set.\n- Verify Node.js 18+ is installed.\n- Set `DEBUG=true` in the env to enable verbose logging to stderr.\n\n## Credits\n\nCurrent implementation by Julian Perelli. Based on previous work from Leo Ruellas, [lcaliani/graylog-mcp](https://github.com/lcaliani/graylog-mcp).\n\n## License\n\nMIT\n",
  "bytes": 18072,
  "sha": "de579f8b6ddb0613ee77c3fb4f3d1ab152eecf350942265662080cf02d99e7ff",
  "repo_slug": "jperelli/graylog-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_jperelli_graylog_mcp_ee2d28b4/readme"
}