Astrolabe / ajxpublic /korvus
Repository

Loki MCP Server

reference scope mcp-servers/loki Verification failed
  • mcp-servers/loki/internal/loki/client.go
  • mcp-servers/loki/internal/loki/format.go
  • mcp-servers/loki/internal/tools/tools.go
  • QueryRangeInput
  • LabelsInput
  • LabelValuesInput
  • Entry
  • Stream
  • MetricSeries
  • Result
Relevant source files · 5

The Loki MCP Server (mcp-loki) is Korvus's read-only window into Grafana Loki. It exposes three LogQL tools — a range query, a label-name listing and a label-value listing — over MCP streamable HTTP, so the agent can move from "the error rate rose" to the actual failing log lines without leaving its tool loop. It is a standalone deployment unit: Korvus never spawns it, the container runtime does, and Korvus reaches it over the URL configured under mcp.servers run. The command imports nothing from the root module, which keeps the observability tooling separable from the agent that consumes it.

Where it sits among the bundled servers is described in Bundled MCP Servers Overview; this page covers only the Loki one. Its two siblings, Prometheus MCP Server and Tempo MCP Server, follow the same three-layer shape — a hand-written HTTP client, a formatting layer, and a thin tool layer — and this server's code repeatedly borrows its reasoning from theirs (the downsample path in particular names the Prometheus behaviour it deliberately differs from).

How it works

The process is deliberately small. run reads two flags (-addr, defaulting to :9101, and -loki-url, defaulting to http://localhost:3100), builds a Client with a 30-second per-request timeout, registers the tools on a single sdkmcp.Server, and mounts a streamable-HTTP handler on /mcp plus a /healthz liveness probe main. One Server instance serves every session because the tools are stateless and the upstream client is safe for concurrent use. The HTTP server's write timeout is two minutes — deliberately above the query timeout — so a long range query is not cut off while Loki is still working main.

The client layer

internal/loki is a minimal client for three Loki HTTP endpoints. Its package comment is explicit about why: the official Grafana client would pull in the whole Loki server tree for three GETs, and this module's dependency budget is one SDK client.go. NewClient trims the trailing slash off the base URL and stores an http.Client whose timeout bounds each request end to end.

The three calls mirror the three tools. Client::QueryRange hits /loki/api/v1/query_range with the query, the window, the limit and — importantly — direction=forward, so entries come back oldest first rather than in Loki's default backward order. The comment there explains why: a transcript read bottom-up is the wrong artifact for reasoning about a sequence of events, and backward truncation silently drops the start of an incident rather than its tail. A step is only sent when non-zero, because it applies to metric queries alone and Loki picks a sensible default from the range otherwise.

Client::Labels and Client::LabelValues hit /loki/api/v1/labels and /loki/api/v1/label/<name>/values. The label name is a path segment and is escaped with url.PathEscape, so an unescaped value cannot reach a different endpoint entirely. Timestamps are rendered as nanosecond unix epochs by formatTime — the API's native unit, chosen because a numeric parameter cannot acquire a timezone on the way through a proxy.

The response body is bounded by maxResponseBytes (64 MiB) before decoding client.go. A wide query_range over a busy stream legitimately produces hundreds of megabytes, and the rendered result is truncated to a few KiB regardless, so reading it all would only risk OOM-ing the server.

Decoding

Loki returns two shapes, and one tool has to carry both because the model chooses which by what it writes. A log query yields streams; a metric query yields a matrix. Result holds Streams, Matrix, the ResultType, and the two counters worth showing — LinesProcessed and BytesProcessed, which are how a model learns its query was wide.

Entry decodes Loki's ["<nanosecond epoch as string>", "<line>"] pair with a custom UnmarshalJSON. The timestamp is deliberately a string: nanosecond precision does not survive float64. The decoder tolerates a third element, because structured metadata adds one on servers that send it, and a stricter check would break there. Stream is one label set plus its entries; MetricSeries is one label set plus its Sample values, and a sample's value stays a string exactly as Loki sent it, since parsing and reformatting loses precision and turns NaN into something the model has to decode.

Decoding dispatches on resultType in decodeQueryData. The vector case fails loudly rather than quietly: a vector sample is "value" where a matrix series is "values", so decoding one into a MetricSeries would keep the labels and drop every sample — reading as "these series exist and reported nothing", a false RCA signal. Since query_range cannot return a vector, the branch is unreachable today, but it forces whoever adds an instant-query tool to handle the shape instead of inheriting silent data loss.

The tool layer

internal/tools defines the MCP surface, and its file header states the design rule: the tool descriptions and schemas are the contract the model programs against, so every argument states its exact format and every description says what the tool is for rather than what it wraps tools.go.

One timestamp representation is used everywhere. TimeFormatHelp holds the single accepted form — RFC 3339 UTC, e.g. "2026-06-29T02:14:00Z" — and the sentence is repeated verbatim in every time argument, because struct tags are literal text and cannot reference a constant. The reason is stated plainly: most bad tool calls are a model guessing at an under-specified schema, and a timestamp argument that accepts nanoseconds in one place and RFC 3339 in another invites guessing. Loki's native unit is nanoseconds, but RFC 3339 is the form already in the model's context window — alert and incident timestamps arrive in exactly that shape — so the tool asks for what the model has and converts at the edge.

Tool Input type What it answers
loki_query_range QueryRangeInput The matching log lines, or the series when the query aggregates
loki_labels LabelsInput Which label names exist in the window
loki_label_values LabelValuesInput Which values one label takes, optionally within a selector

QueryRangeInput is the interesting one. Its query description carries both a log-query and a metric-query example, and insists on a stream selector in braces because LogQL cannot search all streams at once. Its limit description notes that the result budget is bytes, so a bigger limit mostly buys more omitted lines, and its step description marks the argument as metric-only.

Each handler validates before it calls out. The range handler rejects an empty query, parses start and end through parseTime, refuses a window where the end is not after the start, and parses step through parseStep, which accepts only suffixed durations ("15s", "1m") so "300" can never be read as 300ns. The label-values handler requires a label name and points the model at loki_labels, because a name that does not exist returns an empty list — indistinguishable from a label that exists but has no values.

The requested line count is passed through clampLimit: a non-positive limit becomes the default of 100, and a limit above the package's ceiling of 5000 is lowered to it. The function's own comment gives the rationale for clamping instead of erroring — spending a whole tool call teaching the model a server constant is worse than answering the question it meant to ask. The ceiling itself is the package constant MaxLineLimit, documented there as the value Loki enforces per query (max_entries_limit_per_query, 5000 by default), with the note that the server rejects an over-large limit outright rather than truncating it, and that the tool therefore clamps locally.

Rendering

internal/loki/format.go turns decoded results into the text the model actually reads, and it is where most of the care in this server lives.

The governing constraint is Korvus's own truncation: the agent loop truncates any tool result above 32 KiB head-and-tail and replaces the middle with a generic notice. That notice is worse than trimming here, because it cuts mid-line and cannot say which lines were lost. So the formatter renders under a budget of 28 KiB (maxResultBytes) with 1 KiB reserved for trailers (trailerReserve), and reserves enough headroom to keep every log line whole plus an accurate account of what was dropped format.go.

FormatRange prints the query and the window, then dispatches on the result type. For log queries, formatStreams merges all streams into one chronological transcript, sorted stably so two entries sharing a timestamp keep their stream order and repeated identical queries render byte-identically. The reason for merging is that Loki groups by stream, but an investigation reads time-forward across services: interleaved, a request failing in checkout and the matching error in payments sit next to each other, while stream-grouped output forces the model to merge two blocks by eye and invites it to read two unrelated sequences as one causal chain.

Two distinct notices follow the lines. One counts the lines omitted to stay within the byte budget. The other fires when the number of returned lines reached the requested limit, because that means Loki stopped early and the window holds more than was seen — a different fact from the budget trim, and the one most likely to make a model conclude from a partial window. The limit notice names the maximum it can raise the limit to format.go.

Individual lines are bounded by maxLineWidth (512 bytes) and cut on a rune boundary by truncateLine, with a marker telling the model the line was longer. Without this, one JSON-encoded application log could spend the whole budget before the second line and hide the sequence that makes a log query worth reading.

Metric queries take a different path. formatMatrix renders a series whole or not at all, since half a series reads as a series that stopped reporting. If nothing with data fit, the widest series that actually carries samples is downsampled rather than cut short, via writeDownsampled: thinning across the whole window keeps both endpoints and the shape between them, and the trailer names the effective resolution and the remedy. The remedy deliberately differs from the Prometheus server's — there a point-count cap means a coarser step buys nothing, but Loki passes the model's step straight through, so a coarser step genuinely reduces the point count at full precision.

Finally writeStats reports how many lines and bytes Loki scanned to answer, because a model receiving 100 lines back cannot otherwise tell whether it filtered a busy stream well or asked a question so wide the answer is arbitrary.

Error handling

Loki reports failures as an HTTP status plus a bare text/plain body — there is no JSON error envelope and no errorType field, unlike Prometheus. QueryError therefore carries a Kind this package derives itself and a Msg that is the body verbatim, since the body is the part the model can act on. The kinds are bad_query (400), not_found (404), too_many_requests (429), server_error (5xx), timeout, unreachable and bad_response client.go, mapped from status codes in kindForStatus.

explain turns each kind into a sentence that tells the model what to do next: a bad query is a bad query but the fix is its own expression; a timeout means narrow the range, add a label matcher or add a line filter; a rate-limit means wait and make the next query narrower; a server error usually means the query was too expensive for the deployment; and a not-found or unreachable is a configuration or infrastructure fault, explicitly "not a problem with the query; do not retry it repeatedly". The raw Loki text — a parse error at line 1, col 6: syntax error: unexpected $end — is kept verbatim inside that sentence, trimmed to a readable length by snippet.

Configuration

The server is configured by flags with environment fallbacks: -addr / MCP_LOKI_ADDR (default :9101) and -loki-url / LOKI_URL (default http://localhost:3100) main. Korvus reaches it through mcp.servers, where the URL must include the endpoint path — http://localhost:9101/mcp — because a bare host:port fails the initialize handshake with "session not found" and the server is dropped from the catalog at startup with no error korvus.example.yaml. How that connection is made and catalogued is covered in MCP Client, Registry and Server Connection.

Relationships

flowchart TD
    agent["Korvus agent loop"] --> registry["MCP registry"]
    registry -->|http streamable| mcp["/mcp"]
    subgraph process["mcp-loki process"]
        mcp --> register["Register"]
        register --> qr["loki_query_range"]
        register --> lb["loki_labels"]
        register --> lv["loki_label_values"]
        qr --> client["Client"]
        lb --> client
        lv --> client
        client --> result["Result"]
        result --> format["FormatRange"]
        format --> text["CallToolResult"]
    end
    client --> lokiapi["Loki HTTP API"]
    client --> entry["Entry"]
    client --> stream["Stream"]
    client --> series["MetricSeries"]

The tool layer's input structs (QueryRangeInput, LabelsInput, LabelValuesInput) are the schema the model sees; the client layer's types (Entry, Stream, MetricSeries, Result) are the decoded wire shapes; and internal/loki/format.go is the only place where either becomes text.

Reference

Symbol Kind What it does Location
Client struct Holds the base URL and the shared HTTP client client.go
NewClient func Builds a client for a Loki base URL with a per-request timeout client.go
QueryError struct Error carrying a derived Kind and Loki's own message client.go
Entry struct One log line and its nanosecond timestamp client.go
Stream struct One labelled stream and its entries client.go
Sample struct One metric point, value kept as a string client.go
MetricSeries struct One labelled series of a metric query client.go
Result struct Decoded query_range response: streams or matrix plus scan stats client.go
MaxLineLimit const The package's per-query line ceiling, 5000, documented as Loki's own client.go
Client::QueryRange method Runs a forward-direction range query and decodes streams or matrix client.go
Client::Labels method Lists label names in a window client.go
Client::LabelValues method Lists the values of one label, optionally within a selector client.go
FormatRange func Renders a query result as the transcript or series block format.go
formatStreams func Merges streams into one chronological transcript under the byte budget format.go
formatMatrix func Renders series whole, or downsamples one series that overflows the budget format.go
FormatLabels func Renders the sorted label-name list format.go
FormatLabelValues func Renders the sorted values of one label format.go
TimeFormatHelp const The single accepted timestamp form, repeated in every time argument tools.go
QueryRangeInput struct Argument set of the range-query tool tools.go
LabelsInput struct Argument set of the label-name tool tools.go
LabelValuesInput struct Argument set of the label-value tool tools.go
Register func Adds the three tools to the MCP server tools.go
clampLimit func Clamps the requested line count to the default and the ceiling tools.go
parseTime func Parses RFC 3339 only, with an actionable error tools.go
explain func Turns a Loki failure into a sentence the model can act on tools.go

Generated by astrolabe from commit 0c2dca73 on branch main.