@mgcrea/mcp-loki 0.0.0 → 0.1.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.
@@ -0,0 +1,1593 @@
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
+ import { readFileSync } from "node:fs";
3
+ import { z } from "zod";
4
+ // src/build-info.ts
5
+ const readPackageJson = () => {
6
+ try {
7
+ const pkgUrl = new URL("../package.json", import.meta.url);
8
+ return JSON.parse(readFileSync(pkgUrl, "utf8"));
9
+ } catch {
10
+ return {
11
+ name: "@mgcrea/mcp-loki",
12
+ version: "0.0.0"
13
+ };
14
+ }
15
+ };
16
+ const pkg = readPackageJson();
17
+ const BUILD_INFO = {
18
+ name: pkg.name,
19
+ version: pkg.version,
20
+ gitCommit: "24221ba",
21
+ gitCommitDate: "2026-11-04T10:22:14+01:00"
22
+ };
23
+ // src/config.ts
24
+ const ConfigSchema = z.object({
25
+ /**
26
+ * The Loki base URL: the gateway, query-frontend or single binary, including
27
+ * any path prefix it is mounted under. API paths are appended to it, so
28
+ * `https://host/loki-prefix` reaches `https://host/loki-prefix/loki/api/v1/query`.
29
+ */
30
+ url: z.url().optional(),
31
+ username: z.string().min(1).optional(),
32
+ password: z.string().min(1).optional(),
33
+ token: z.string().min(1).optional(),
34
+ /**
35
+ * Sent as `X-Scope-OrgID`. Needed when talking to Loki directly with
36
+ * `auth_enabled: true`; behind the Loki chart's nginx gateway with basic
37
+ * auth it is NOT needed, because the gateway sets it from the username.
38
+ */
39
+ tenantId: z.string().min(1).optional(),
40
+ /** Extra request headers, e.g. a Cloudflare Access service token. */
41
+ headers: z.record(z.string(), z.string()).default({}),
42
+ /** Seconds. Bounds every HTTP request; Loki's own query_timeout applies server-side. */
43
+ timeoutSeconds: z.number().positive().max(600).default(60),
44
+ maxRetries: z.number().int().nonnegative().max(10).default(2),
45
+ /**
46
+ * Problems found while reading the environment. Deliberately data, not a
47
+ * thrown error: an MCP server that exits on startup shows up in the client
48
+ * as a bare "Connection closed", with stderr swallowed, so the one message
49
+ * that would have explained the problem never reaches anyone. These are
50
+ * reported through loki_auth_status instead.
51
+ */
52
+ problems: z.array(z.string()).default([])
53
+ }).strict();
54
+ const parseNumber = (value) => {
55
+ const t = trimmed(value);
56
+ if (t === void 0) return void 0;
57
+ const n = Number(t);
58
+ return Number.isFinite(n) ? n : void 0;
59
+ };
60
+ /** Maps "" to undefined, so an empty env var means "unset" rather than "empty". */
61
+ const trimmed = (value) => {
62
+ const t = value?.trim();
63
+ return t ? t : void 0;
64
+ };
65
+ const parseHeaders = (value, problems) => {
66
+ const t = trimmed(value);
67
+ if (t === void 0) return void 0;
68
+ try {
69
+ const parsed = JSON.parse(t);
70
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) && Object.values(parsed).every((v) => typeof v === "string")) return parsed;
71
+ } catch {}
72
+ problems.push("LOKI_HEADERS must be a JSON object of string values, e.g. {\"CF-Access-Client-Id\":\"...\"}.");
73
+ };
74
+ /**
75
+ * Environment only: the server persists nothing of its own, so there is no
76
+ * config file to merge with.
77
+ */
78
+ const loadConfig = (env = process.env) => {
79
+ const problems = [];
80
+ let url = trimmed(env.LOKI_URL);
81
+ if (url !== void 0) try {
82
+ const parsed = new URL(url);
83
+ if (parsed.username || parsed.password) {
84
+ problems.push("LOKI_URL contains credentials. Move them to LOKI_USERNAME and LOKI_PASSWORD, which are never echoed back.");
85
+ url = void 0;
86
+ } else if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
87
+ problems.push(`LOKI_URL must be http(s), got ${parsed.protocol}`);
88
+ url = void 0;
89
+ }
90
+ } catch {
91
+ problems.push("LOKI_URL is not a valid URL, e.g. https://loki.example.com");
92
+ url = void 0;
93
+ }
94
+ const username = trimmed(env.LOKI_USERNAME);
95
+ const password = trimmed(env.LOKI_PASSWORD);
96
+ const token = trimmed(env.LOKI_TOKEN);
97
+ if (username === void 0 !== (password === void 0)) problems.push("Set both LOKI_USERNAME and LOKI_PASSWORD for basic auth, or neither.");
98
+ if (token !== void 0 && (username !== void 0 || password !== void 0)) problems.push("LOKI_TOKEN and LOKI_USERNAME/PASSWORD are both set; keep only one.");
99
+ const timeoutSeconds = parseNumber(env.LOKI_TIMEOUT);
100
+ const maxRetries = parseNumber(env.LOKI_MAX_RETRIES);
101
+ return ConfigSchema.parse({
102
+ url,
103
+ username,
104
+ password,
105
+ token,
106
+ tenantId: trimmed(env.LOKI_TENANT_ID),
107
+ headers: parseHeaders(env.LOKI_HEADERS, problems),
108
+ timeoutSeconds: timeoutSeconds !== void 0 && timeoutSeconds > 0 && timeoutSeconds <= 600 ? timeoutSeconds : void 0,
109
+ maxRetries: maxRetries !== void 0 && Number.isInteger(maxRetries) && maxRetries >= 0 && maxRetries <= 10 ? maxRetries : void 0,
110
+ problems
111
+ });
112
+ };
113
+ const authMode = (config) => config.token ? "bearer" : config.username && config.password ? "basic" : "none";
114
+ /** A URL and no contradictions. A local Loki needs no auth, so none is required. */
115
+ const isConfigured = (config) => Boolean(config.url) && config.problems.length === 0;
116
+ /**
117
+ * Which tenant's logs every answer comes from. Loki never echoes it back — no
118
+ * endpoint says "you are tenant X" — so this is what the server sends or what
119
+ * the gateway is known to derive, and it is labelled as such.
120
+ *
121
+ * The Loki chart's nginx gateway with basicAuth sets `X-Scope-OrgID` from the
122
+ * basic-auth username and overwrites any header the client sent (observed on
123
+ * Loki 3.7.9 / chart 20.2.1: a bogus X-Scope-OrgID still answered with the
124
+ * user's tenant). So with basic auth the username is the tenant, and a
125
+ * LOKI_TENANT_ID beside it is probably ignored.
126
+ */
127
+ const resolveTenant = (config) => {
128
+ if (config.tenantId) return {
129
+ tenant: config.tenantId,
130
+ source: authMode(config) === "basic" ? "LOKI_TENANT_ID (sent as X-Scope-OrgID; a gateway that maps the basic-auth username may override it)" : "LOKI_TENANT_ID (sent as X-Scope-OrgID)"
131
+ };
132
+ if (authMode(config) === "basic" && config.username) return {
133
+ tenant: config.username,
134
+ source: "basic-auth username (the Loki chart gateway sets X-Scope-OrgID from it)"
135
+ };
136
+ };
137
+ /** Returned by loki_auth_status and printed to stderr at startup. */
138
+ const setupInstructions = (config) => [...config.problems, ...config.url || config.problems.some((p) => p.startsWith("LOKI_URL")) ? [] : ["Set LOKI_URL to the Loki base URL (e.g. https://loki.example.com, the gateway or query-frontend, including any path prefix), then restart the server. Add LOKI_USERNAME and LOKI_PASSWORD for basic auth (behind the Loki chart's gateway the username is the tenant), or LOKI_TOKEN for a bearer token. Set LOKI_TENANT_ID when talking to a multi-tenant Loki directly."]];
139
+ // src/client/auth.ts
140
+ /**
141
+ * The headers every request carries. Loki has no login of its own: basic or
142
+ * bearer auth satisfies whatever sits in front of it (the chart's nginx
143
+ * gateway, an ingress, Grafana Cloud), and `X-Scope-OrgID` picks the tenant
144
+ * when Loki runs with `auth_enabled: true`. Nothing to refresh, so a 401 is
145
+ * always final.
146
+ */
147
+ const authHeaders = (config) => {
148
+ const mode = authMode(config);
149
+ const auth = mode === "bearer" ? { Authorization: `Bearer ${config.token}` } : mode === "basic" ? { Authorization: `Basic ${Buffer.from(`${config.username}:${config.password}`).toString("base64")}` } : {};
150
+ const tenant = config.tenantId ? { "X-Scope-OrgID": config.tenantId } : {};
151
+ return {
152
+ ...auth,
153
+ ...tenant,
154
+ ...config.headers
155
+ };
156
+ };
157
+ // src/client/errors.ts
158
+ var LokiApiError = class extends Error {
159
+ name = "LokiApiError";
160
+ /** HTTP status, or 0 when no response arrived at all (DNS, TLS, timeout). */
161
+ status;
162
+ /**
163
+ * What to do about it, kept separate from `message`: the message says what
164
+ * happened, the remedy is the half a model should act on, and fail() lifts it
165
+ * to a top-level field so it is not buried inside the upstream text.
166
+ */
167
+ remedy;
168
+ errors;
169
+ constructor(message, opts) {
170
+ super(message);
171
+ this.status = opts.status;
172
+ this.remedy = opts.remedy;
173
+ this.errors = opts.errors;
174
+ }
175
+ };
176
+ // src/client/loki.ts
177
+ const LONG_URL = 4e3;
178
+ const backoffMs = (attempt) => Math.min(500 * 2 ** attempt, 4e3);
179
+ const retryAfterMs = (res) => {
180
+ const header = res.headers.get("Retry-After");
181
+ if (header === null) return void 0;
182
+ const seconds = Number(header);
183
+ return Number.isFinite(seconds) ? Math.min(Math.max(seconds, 0), 30) * 1e3 : void 0;
184
+ };
185
+ const encodeParams = (params = {}) => {
186
+ const search = new URLSearchParams();
187
+ for (const [k, v] of Object.entries(params)) {
188
+ if (v === void 0) continue;
189
+ for (const item of Array.isArray(v) ? v : [v]) search.append(k, String(item));
190
+ }
191
+ return search;
192
+ };
193
+ const isHtml = (value) => /^\s*</.test(value);
194
+ const isEnvelope = (value) => typeof value === "object" && value !== null && "status" in value && (value.status === "success" || value.status === "error");
195
+ /**
196
+ * What to do about a failed request — the half of an error a model acts on.
197
+ * Loki's error text is specific, so it is read before the status.
198
+ */
199
+ const remedyFor = (status, text = "") => {
200
+ if (/at least one (regexp or )?equality matcher|empty-compatible value/.test(text)) return "Every selector needs one matcher that cannot match the empty string: `{app=~\".+\"}`, not `{app=~\".*\"}` or `{}`. Use loki_list_labels / loki_list_label_values to find one.";
201
+ if (/parse error/.test(text)) return "The LogQL does not parse; the position is line:col into the expression. String values need double quotes or backticks (`{app=\"api\"}`), and a metric query wraps a range: `count_over_time({app=\"api\"} |= \"error\" [5m])`. Fix it rather than retrying.";
202
+ if (/not supported as an instant query/.test(text)) return "That is a log query, not a metric query. Use loki_query_logs for log lines.";
203
+ if (/query time range exceeds the limit|max_query_length/.test(text)) return "The range is longer than this Loki allows (see limits in loki_auth_status). Shorten start..end.";
204
+ if (/max entries limit|max_entries_limit/.test(text)) return "Lower limit, and page with the `next` cursor instead of asking for everything at once.";
205
+ if (/maximum (number )?of series|max_query_series/.test(text)) return "The query produces more series than allowed. Aggregate (`sum by (namespace) (...)`), add label matchers, or drop high-cardinality labels with `without(pod)`.";
206
+ if (/max_query_bytes_read|bytes read/.test(text)) return "The query would read more data than allowed. Narrow the selector or shorten the range.";
207
+ if (/context deadline exceeded|timeout|timed out/i.test(text) || status === 504) return "The query ran out of time. Narrow the stream selector with more label matchers, shorten the range, and put line filters (|= \"...\") before parsers (| json).";
208
+ if (status === 401) return "The credentials were rejected. Check LOKI_USERNAME / LOKI_PASSWORD (basic auth; behind the Loki chart gateway the username is the tenant) or LOKI_TOKEN (bearer).";
209
+ if (/no org id|X-Scope-OrgID/i.test(text)) return "This Loki is multi-tenant and no tenant was sent. Set LOKI_TENANT_ID.";
210
+ if (status === 403) return "Authenticated, but not allowed to reach this endpoint.";
211
+ if (status === 404) return "Not found. Check that LOKI_URL includes any path prefix. Some endpoints only exist when their component is enabled (the ruler, the pattern ingester, volume_enabled), and a gateway may not route every path.";
212
+ if (status === 429) return "Rate limited or too many outstanding requests. Wait a moment before calling again.";
213
+ };
214
+ /**
215
+ * Only failures a retry can fix: 429 and a proxy's 502/503. Never a 504 — on
216
+ * Loki that is almost always a slow query, and retrying it triples the load
217
+ * that caused it.
218
+ */
219
+ const retryable = (status) => status === 429 || status === 502 || status === 503;
220
+ var LokiClient = class {
221
+ baseUrl;
222
+ timeoutSeconds;
223
+ headers;
224
+ maxRetries;
225
+ userAgent;
226
+ fetchImpl;
227
+ logger;
228
+ constructor(opts) {
229
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
230
+ this.headers = opts.headers;
231
+ this.timeoutSeconds = opts.timeoutSeconds;
232
+ this.maxRetries = opts.maxRetries;
233
+ this.userAgent = opts.userAgent;
234
+ this.fetchImpl = opts.fetch ?? fetch;
235
+ this.logger = opts.logger;
236
+ }
237
+ async get(path, params, headers) {
238
+ return this.request("GET", path, {
239
+ params,
240
+ ...headers ? { headers } : {}
241
+ });
242
+ }
243
+ /** A read on an endpoint that also takes a form POST: GET unless the URL would be too long. */
244
+ async read(path, params, headers) {
245
+ const search = encodeParams(params);
246
+ const extra = headers ? { headers } : {};
247
+ return this.baseUrl.length + path.length + search.toString().length > LONG_URL ? this.request("POST", path, {
248
+ form: params,
249
+ ...extra
250
+ }) : this.request("GET", path, {
251
+ params,
252
+ ...extra
253
+ });
254
+ }
255
+ async request(method, path, opts = {}) {
256
+ const url = new URL(`${this.baseUrl}${path}`);
257
+ url.search = encodeParams(opts.params).toString();
258
+ const body = opts.form ? encodeParams(opts.form).toString() : void 0;
259
+ let attempt = 0;
260
+ for (;;) {
261
+ this.logger?.debug?.(`${method} ${url.pathname} (attempt ${attempt + 1})`);
262
+ let res;
263
+ try {
264
+ res = await this.fetchImpl(url, {
265
+ method,
266
+ headers: {
267
+ "User-Agent": this.userAgent,
268
+ Accept: "application/json",
269
+ ...body !== void 0 ? { "Content-Type": "application/x-www-form-urlencoded" } : {},
270
+ ...opts.headers,
271
+ ...this.headers
272
+ },
273
+ ...body !== void 0 ? { body } : {},
274
+ signal: AbortSignal.timeout(this.timeoutSeconds * 1e3)
275
+ });
276
+ } catch (err) {
277
+ throw networkError(method, path, err);
278
+ }
279
+ const text = await res.text();
280
+ if (!res.ok && retryable(res.status) && attempt < this.maxRetries) {
281
+ const delay = retryAfterMs(res) ?? backoffMs(attempt);
282
+ this.logger?.warn?.(`HTTP ${res.status} on ${path} — retrying in ${delay}ms`);
283
+ await new Promise((r) => setTimeout(r, delay));
284
+ attempt += 1;
285
+ continue;
286
+ }
287
+ let parsed = void 0;
288
+ let isJson = false;
289
+ if (text) try {
290
+ parsed = JSON.parse(text);
291
+ isJson = true;
292
+ } catch {
293
+ parsed = text;
294
+ }
295
+ const envelope = isEnvelope(parsed) ? parsed : void 0;
296
+ if (!res.ok || envelope?.status === "error") {
297
+ const upstream = envelope?.error ?? (typeof parsed === "string" && !isHtml(parsed) ? parsed.trim().slice(0, 1e3) : void 0);
298
+ const remedy = remedyFor(res.status, upstream);
299
+ throw new LokiApiError(upstream ? `HTTP ${res.status}: ${upstream}` : `${method} ${path} failed: HTTP ${res.status} ${res.statusText}`.trim(), {
300
+ status: res.status,
301
+ ...remedy ? { remedy } : {},
302
+ ...isJson && !envelope ? { errors: parsed } : {}
303
+ });
304
+ }
305
+ if (!envelope) return { data: parsed };
306
+ return {
307
+ data: envelope.data,
308
+ ...envelope.warnings?.length ? { warnings: envelope.warnings } : {}
309
+ };
310
+ }
311
+ }
312
+ };
313
+ const networkError = (method, path, err) => {
314
+ const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
315
+ const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : void 0;
316
+ return new LokiApiError(`${method} ${path} failed: ${timedOut ? "timed out" : cause ?? (err instanceof Error ? err.message : String(err))}`, {
317
+ status: 0,
318
+ remedy: timedOut ? "No answer before LOKI_TIMEOUT. Narrow the selector or the range, or raise LOKI_TIMEOUT." : "Loki could not be reached. Check LOKI_URL, DNS and VPN; a self-signed certificate needs NODE_EXTRA_CA_CERTS."
319
+ });
320
+ };
321
+ // src/client/identity.ts
322
+ /** "0s" and "0B" mean "no limit" in Loki's config; leaving them out says the same thing. */
323
+ const limitString = (v) => typeof v === "string" && v !== "0s" && v !== "0B" && v !== "0" ? v : void 0;
324
+ const limitNumber = (v) => typeof v === "number" && v > 0 ? v : void 0;
325
+ const bool = (v) => typeof v === "boolean" ? v : void 0;
326
+ const shapeLimits = (d) => {
327
+ const l = d.limits ?? {};
328
+ const out = {
329
+ retention: limitString(l.retention_period),
330
+ maxQueryLength: limitString(l.max_query_length),
331
+ maxQueryRange: limitString(l.max_query_range),
332
+ maxQueryLookback: limitString(l.max_query_lookback),
333
+ maxEntriesPerQuery: limitNumber(l.max_entries_limit_per_query),
334
+ maxQuerySeries: limitNumber(l.max_query_series),
335
+ maxQueryBytesRead: limitString(l.max_query_bytes_read),
336
+ queryTimeout: limitString(l.query_timeout),
337
+ volumeEnabled: bool(l.volume_enabled),
338
+ patternIngesterEnabled: bool(d.pattern_ingester_enabled),
339
+ discoverLogLevels: bool(l.discover_log_levels)
340
+ };
341
+ return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== void 0));
342
+ };
343
+ const fetchIdentity = async (client) => {
344
+ const [build, limits] = await Promise.allSettled([client.get("/loki/api/v1/status/buildinfo"), client.get("/loki/api/v1/drilldown-limits")]);
345
+ if (build.status === "rejected" && limits.status === "rejected") throw build.reason;
346
+ const unavailable = [];
347
+ const b = build.status === "fulfilled" ? build.value.data : (unavailable.push("buildinfo"), void 0);
348
+ const l = limits.status === "fulfilled" ? limits.value.data : (unavailable.push("drilldown-limits"), void 0);
349
+ const version = b?.version ?? l?.version;
350
+ return {
351
+ ...version ? { version } : {},
352
+ ...b?.revision ? { revision: b.revision } : {},
353
+ ...l ? { limits: shapeLimits(l) } : {},
354
+ ...unavailable.length ? { unavailable } : {}
355
+ };
356
+ };
357
+ // src/client/time.ts
358
+ const UNITS = {
359
+ ms: .001,
360
+ s: 1,
361
+ m: 60,
362
+ h: 3600,
363
+ d: 86400,
364
+ w: 604800,
365
+ y: 31536e3
366
+ };
367
+ const DURATION_RE = /^(?:\d+(?:ms|[smhdwy]))+$/;
368
+ /** `1h30m` → 5400. Undefined when the string is not a Prometheus duration. */
369
+ const parseDuration = (value) => {
370
+ const t = value.trim();
371
+ if (/^\d+(\.\d+)?$/.test(t)) return Number(t);
372
+ if (!DURATION_RE.test(t)) return void 0;
373
+ let total = 0;
374
+ for (const [, n, unit] of t.matchAll(/(\d+)(ms|[smhdwy])/g)) total += Number(n) * UNITS[unit];
375
+ return total;
376
+ };
377
+ /** 5400 → `1h30m`; the inverse of parseDuration for whole seconds. */
378
+ const formatDuration = (seconds) => {
379
+ if (seconds <= 0) return "0s";
380
+ if (!Number.isInteger(seconds)) return `${Math.round(seconds * 1e3)}ms`;
381
+ let rest = seconds;
382
+ let out = "";
383
+ for (const [unit, size] of [
384
+ ["d", 86400],
385
+ ["h", 3600],
386
+ ["m", 60],
387
+ ["s", 1]
388
+ ]) if (rest >= size) {
389
+ out += `${Math.floor(rest / size)}${unit}`;
390
+ rest %= size;
391
+ }
392
+ return out;
393
+ };
394
+ var TimeArgError = class extends Error {
395
+ name = "TimeArgError";
396
+ };
397
+ /**
398
+ * Resolve a time argument to unix seconds. Accepts `now`, `now-1h`, `now+5m`,
399
+ * a bare `-1h` (read as now-1h), RFC3339 / ISO 8601, and unix seconds.
400
+ */
401
+ const parseTime = (value, nowSeconds) => {
402
+ const t = value.trim();
403
+ if (t === "now") return nowSeconds;
404
+ const relative = /^(?:now)?\s*([+-])\s*(.+)$/.exec(t);
405
+ if (relative) {
406
+ const seconds = parseDuration(relative[2]);
407
+ if (seconds !== void 0) return relative[1] === "-" ? nowSeconds - seconds : nowSeconds + seconds;
408
+ }
409
+ if (/^\d+(\.\d+)?$/.test(t)) {
410
+ const n = Number(t);
411
+ if (n > 0x16345785d8a0000) return n / 1e9;
412
+ if (n > 0x5af3107a4000) return n / 1e6;
413
+ return n > 1e11 ? n / 1e3 : n;
414
+ }
415
+ const ms = Date.parse(t);
416
+ if (Number.isFinite(ms)) return ms / 1e3;
417
+ throw new TimeArgError(`Cannot read "${value}" as a time. Use now, now-1h, an RFC3339 timestamp (2026-01-31T12:00:00Z) or unix seconds.`);
418
+ };
419
+ /** Unix seconds → RFC3339 in UTC, without the milliseconds when there are none. */
420
+ const isoTime = (seconds) => (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().replace(".000Z", "Z");
421
+ const NICE_STEPS = [
422
+ 1,
423
+ 5,
424
+ 10,
425
+ 15,
426
+ 30,
427
+ 60,
428
+ 120,
429
+ 300,
430
+ 600,
431
+ 900,
432
+ 1800,
433
+ 3600,
434
+ 7200,
435
+ 10800,
436
+ 21600,
437
+ 43200,
438
+ 86400,
439
+ 172800,
440
+ 604800
441
+ ];
442
+ /**
443
+ * The smallest round step that keeps a range at or under `targetPoints`.
444
+ * Round steps matter beyond looks: they line up with how people read time, and
445
+ * with the evaluation grid recording rules already use.
446
+ */
447
+ const autoStep = (rangeSeconds, targetPoints) => {
448
+ const raw = rangeSeconds / targetPoints;
449
+ return NICE_STEPS.find((s) => s >= raw) ?? Math.ceil(raw / 86400) * 86400;
450
+ };
451
+ const NS_PER_S$1 = 1000000000n;
452
+ /** Unix seconds (float) → integer nanoseconds. Millisecond precision is all a double carries here. */
453
+ const secondsToNs = (seconds) => BigInt(Math.round(seconds * 1e3)) * 1000000n;
454
+ /**
455
+ * Resolve a time argument to exact nanoseconds. On top of everything
456
+ * parseTime accepts, a 19-digit integer is taken as nanoseconds verbatim, and
457
+ * an RFC3339 timestamp keeps all nine fractional digits — which is how the
458
+ * `ts` of a returned log line and a `next` cursor round-trip without loss.
459
+ */
460
+ const parseTimeNs = (value, nowSeconds) => {
461
+ const t = value.trim();
462
+ if (/^\d{19}$/.test(t)) return BigInt(t);
463
+ const iso = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)\.(\d{4,9})(Z|[+-]\d\d:\d\d)$/.exec(t);
464
+ if (iso) {
465
+ const whole = Date.parse(`${iso[1]}${iso[3]}`);
466
+ if (Number.isFinite(whole)) {
467
+ const frac = BigInt(iso[2].padEnd(9, "0"));
468
+ return BigInt(whole) / 1000n * NS_PER_S$1 + frac;
469
+ }
470
+ }
471
+ return secondsToNs(parseTime(t, nowSeconds));
472
+ };
473
+ /**
474
+ * Nanoseconds → RFC3339 in UTC with as many fractional digits as are not
475
+ * zero, e.g. `2026-11-03T22:13:37.968458546Z`. Exact: parseTimeNs reads it
476
+ * back to the same nanosecond.
477
+ */
478
+ const nsToIso = (ns) => {
479
+ const n = typeof ns === "bigint" ? ns : BigInt(ns);
480
+ const seconds = n / NS_PER_S$1;
481
+ const frac = (n % NS_PER_S$1).toString().padStart(9, "0").replace(/0+$/, "");
482
+ const base = (/* @__PURE__ */ new Date(Number(seconds) * 1e3)).toISOString().replace(".000Z", "");
483
+ return frac ? `${base}.${frac}Z` : `${base}Z`;
484
+ };
485
+ /** The usual query window: `start`/`end` (or `since`) resolved against one `now`, in nanoseconds. */
486
+ const resolveRange = (args, nowSeconds, defaultSpanSeconds = 3600) => {
487
+ const end = args.end !== void 0 ? parseTimeNs(args.end, nowSeconds) : secondsToNs(nowSeconds);
488
+ const start = args.start !== void 0 ? parseTimeNs(args.start, nowSeconds) : end - BigInt(defaultSpanSeconds) * NS_PER_S$1;
489
+ if (start >= end) throw new TimeArgError("start must be before end.");
490
+ return {
491
+ start,
492
+ end
493
+ };
494
+ };
495
+ // src/tools/util.ts
496
+ /** Compact, not pretty-printed: indentation adds 19-41% to every row and no model needs it. */
497
+ const ok = (data) => ({ content: [{
498
+ type: "text",
499
+ text: JSON.stringify(data ?? { ok: true })
500
+ }] });
501
+ /**
502
+ * `extra` is spread at the top level, so a `remedy` lands beside the error
503
+ * rather than inside the upstream text, where it gets skimmed past.
504
+ */
505
+ const fail = (message, extra) => ({
506
+ content: [{
507
+ type: "text",
508
+ text: JSON.stringify({
509
+ error: message,
510
+ ...extra
511
+ })
512
+ }],
513
+ isError: true
514
+ });
515
+ /** A thrown value as `{error, remedy?, status?, details?}`, for a tool error or a status field. */
516
+ const describeError = (err) => {
517
+ if (err instanceof LokiApiError) return {
518
+ error: err.message,
519
+ ...err.remedy ? { remedy: err.remedy } : {},
520
+ status: err.status,
521
+ ...err.errors !== void 0 ? { details: err.errors } : {}
522
+ };
523
+ if (err instanceof TimeArgError) return { error: err.message };
524
+ if (err instanceof Error) return { error: err.message };
525
+ return {
526
+ error: "Unknown error",
527
+ details: err
528
+ };
529
+ };
530
+ /** Render a thrown value as a tool error, preserving upstream detail. */
531
+ const toFailure = (err) => {
532
+ const { error, ...extra } = describeError(err);
533
+ return fail(error, extra);
534
+ };
535
+ /** Run a tool body, JSON-formatting the result and turning errors into a tool error. */
536
+ const wrap = async (fn) => {
537
+ try {
538
+ return ok(await fn());
539
+ } catch (err) {
540
+ return toFailure(err);
541
+ }
542
+ };
543
+ /** Carry Loki's warnings onto a shaped result: they are the part of a success most worth reading. */
544
+ const withNotes = (shaped, res) => ({
545
+ ...shaped,
546
+ ...res.warnings ? { warnings: res.warnings } : {}
547
+ });
548
+ /** Case-insensitive substring match, the filter every list tool offers. */
549
+ const matches = (needle, ...haystack) => {
550
+ if (!needle) return true;
551
+ const n = needle.toLowerCase();
552
+ return haystack.some((h) => h?.toLowerCase().includes(n));
553
+ };
554
+ const TIME_FORMS = "now, now-1h, -30m, an RFC3339 timestamp (2026-01-31T12:00:00Z, nanosecond fractions kept) or unix seconds/nanoseconds";
555
+ const timeArg = (what, fallback) => z.string().optional().describe(`${what}: ${TIME_FORMS}.${fallback ? ` Defaults to ${fallback}.` : ""}`);
556
+ const MATCHER_RULE = "At least one matcher must not match the empty string: `{app=~\".+\"}` works, `{app=~\".*\"}` and `{}` are rejected.";
557
+ /** A stream selector only — what /series, /labels, /index/* and /patterns take. */
558
+ const selectorArg = (required) => {
559
+ const base = z.string().min(1).describe(`A stream selector, e.g. \`{namespace="payments", app="api"}\`. Label matchers only: no line filters or parsers. ${MATCHER_RULE}`);
560
+ return required ? base : base.optional();
561
+ };
562
+ const logQueryArg = z.string().min(1).describe(`A LogQL log query: a stream selector, then filters and parsers, e.g. \`{namespace="payments", app="api"} |= "error" != "healthcheck" | json | status >= 500\`. Put the cheap parts first: label matchers decide how much is read at all, then line filters (|=, !=, |~), and only then parsers (| json, | logfmt). ${MATCHER_RULE}`);
563
+ const metricQueryArg = z.string().min(1).describe("A LogQL metric query: a range aggregation over a log query, usually aggregated by a label, e.g. `sum by (namespace) (count_over_time({namespace=~\".+\"} |= \"error\" [5m]))` or `sum by (status) (rate({app=\"nginx\"} | json [1m]))`. Unwrapped values: `avg_over_time({app=\"api\"} | json | unwrap duration_ms [5m])`. Always aggregate (sum by) unless you want one series per stream.");
564
+ const precisionArg = z.number().int().min(0).max(17).default(4).describe("Significant digits to round fractional values to (whole numbers are never rounded). Defaults to 4; 0 returns the exact value.");
565
+ const searchArg = z.string().min(1).optional().describe("Case-insensitive substring to filter by, e.g. `api`.");
566
+ // src/tools/auth.ts
567
+ /**
568
+ * Registered first and unconditionally, so an unconfigured server is still a
569
+ * useful one rather than a connection that closes.
570
+ */
571
+ const registerAuthTools = (server, ctx) => {
572
+ server.registerTool("loki_auth_status", {
573
+ title: "Loki: Auth Status",
574
+ description: "Report which Loki this server reads and whether it answers: the URL, auth mode, the tenant (and where that comes from), the Loki version, and this tenant's limits — retention, max query length, max lines per query, max series, query timeout, and whether volume and pattern endpoints are enabled. When the server is not configured, says exactly what to set. Call this first when a tool you expected is missing, when a query fails on a limit, or before trusting logs from an instance you have not checked.",
575
+ inputSchema: z.object({}),
576
+ annotations: { readOnlyHint: true }
577
+ }, async () => wrap(async () => {
578
+ const { config } = ctx;
579
+ const base = {
580
+ configured: ctx.hasCredentials,
581
+ ...config.url ? { url: config.url } : {},
582
+ auth: authMode(config),
583
+ ...ctx.tenant ? {
584
+ tenant: ctx.tenant.tenant,
585
+ tenant_source: ctx.tenant.source
586
+ } : { tenant_note: "No tenant configured. Fine for a single-tenant Loki (auth_enabled: false); a multi-tenant one answers 401 'no org id' until LOKI_TENANT_ID is set." },
587
+ ...Object.keys(config.headers).length ? { headers: Object.keys(config.headers) } : {}
588
+ };
589
+ if (!ctx.hasCredentials) return {
590
+ ...base,
591
+ setup: setupInstructions(config)
592
+ };
593
+ try {
594
+ return {
595
+ ...base,
596
+ reachable: true,
597
+ loki: await ctx.identity()
598
+ };
599
+ } catch (err) {
600
+ return {
601
+ ...base,
602
+ reachable: false,
603
+ ...describeError(err)
604
+ };
605
+ }
606
+ }));
607
+ };
608
+ // src/client/shape.ts
609
+ /**
610
+ * Round to `digits` significant digits, but never into the integer part:
611
+ * a byte count of 807227 stays 807227, 0.005732144858 becomes 0.005732.
612
+ * `digits` of 0 means exact.
613
+ */
614
+ const round = (n, digits) => {
615
+ if (digits <= 0 || !Number.isFinite(n) || Number.isInteger(n) || n === 0) return n;
616
+ if (Math.floor(Math.log10(Math.abs(n))) + 1 >= digits) return Math.round(n);
617
+ return Number(n.toPrecision(digits));
618
+ };
619
+ const toValue = (raw, digits) => {
620
+ if (raw === "NaN" || raw === "+Inf" || raw === "-Inf") return raw;
621
+ return round(Number(raw), digits);
622
+ };
623
+ /** A native histogram has no single value; its count and sum are what a reader can use. */
624
+ const histogramValue = (h, digits) => ({
625
+ count: toValue(h.count, digits),
626
+ sum: toValue(h.sum, digits)
627
+ });
628
+ /**
629
+ * Split off the labels every series shares. With one series there is nothing
630
+ * to compare against, so its labels stay where they are.
631
+ */
632
+ const splitCommonLabels = (metrics) => {
633
+ const [first, ...others] = metrics;
634
+ if (!first || others.length === 0) return {
635
+ common: {},
636
+ rest: metrics
637
+ };
638
+ const common = Object.fromEntries(Object.entries(first).filter(([k, v]) => others.every((m) => m[k] === v)));
639
+ return {
640
+ common,
641
+ rest: metrics.map((m) => Object.fromEntries(Object.entries(m).filter(([k]) => !(k in common))))
642
+ };
643
+ };
644
+ const withLabels = (labels) => Object.keys(labels).length > 0 ? { labels } : {};
645
+ const numeric = (values) => values.filter((v) => typeof v === "number");
646
+ const stats = (values, digits) => {
647
+ const nums = numeric(values);
648
+ if (nums.length === 0) return void 0;
649
+ let min = Infinity;
650
+ let max = -Infinity;
651
+ let sum = 0;
652
+ for (const n of nums) {
653
+ if (n < min) min = n;
654
+ if (n > max) max = n;
655
+ sum += n;
656
+ }
657
+ let last = null;
658
+ for (let i = values.length - 1; i >= 0; i--) if (values[i] !== null && values[i] !== void 0) {
659
+ last = values[i];
660
+ break;
661
+ }
662
+ return {
663
+ min: round(min, digits),
664
+ max: round(max, digits),
665
+ avg: round(sum / nums.length, digits),
666
+ last
667
+ };
668
+ };
669
+ /** An instant query. Upstream order is kept, so sort(), sort_desc() and topk() mean what they say. */
670
+ const shapeInstant = (data, opts) => {
671
+ const { limit, precision } = opts;
672
+ if (opts.sort === "value" && data.resultType === "vector") {
673
+ const num = (s) => {
674
+ const n = Number(s.value?.[1] ?? s.histogram?.[1].count);
675
+ return Number.isNaN(n) ? -Infinity : n;
676
+ };
677
+ data = {
678
+ ...data,
679
+ result: data.result.toSorted((a, b) => num(b) - num(a))
680
+ };
681
+ }
682
+ if (data.resultType === "scalar" || data.resultType === "string") {
683
+ const [t, v] = data.result;
684
+ return {
685
+ resultType: data.resultType,
686
+ time: isoTime(t),
687
+ value: data.resultType === "scalar" ? toValue(v, precision) : v
688
+ };
689
+ }
690
+ const total = data.result.length;
691
+ if (data.resultType === "matrix") {
692
+ const kept = data.result.slice(0, limit);
693
+ const { common, rest } = splitCommonLabels(kept.map((s) => s.metric));
694
+ return {
695
+ resultType: "matrix",
696
+ total,
697
+ ...total > kept.length ? { truncated: true } : {},
698
+ ...Object.keys(common).length ? { common_labels: common } : {},
699
+ series: kept.map((s, i) => ({
700
+ ...withLabels(rest[i]),
701
+ samples: s.values ? s.values.map(([t, v]) => [t, toValue(v, precision)]) : (s.histograms ?? []).map(([t, h]) => [t, histogramValue(h, precision)])
702
+ }))
703
+ };
704
+ }
705
+ const kept = data.result.slice(0, limit);
706
+ const { common, rest } = splitCommonLabels(kept.map((s) => s.metric));
707
+ const first = kept[0];
708
+ const time = first?.value?.[0] ?? first?.histogram?.[0];
709
+ return {
710
+ resultType: "vector",
711
+ ...time !== void 0 ? { time: isoTime(time) } : {},
712
+ total,
713
+ ...total > kept.length ? { truncated: true } : {},
714
+ ...Object.keys(common).length ? { common_labels: common } : {},
715
+ series: kept.map((s, i) => ({
716
+ ...withLabels(rest[i]),
717
+ value: s.value ? toValue(s.value[1], precision) : s.histogram ? histogramValue(s.histogram[1], precision) : null
718
+ }))
719
+ };
720
+ };
721
+ const sortKey = (s, by) => {
722
+ if (!s) return -Infinity;
723
+ if (by === "last") return typeof s.last === "number" ? s.last : -Infinity;
724
+ return by === "max" ? s.max : s.avg;
725
+ };
726
+ /** A range query, laid on its step grid. */
727
+ const shapeRange = (data, opts) => {
728
+ const { start, end, step, limit, precision, sort, view } = opts;
729
+ const points = Math.floor((end - start) / step) + 1;
730
+ const all = data.result.map((s) => {
731
+ const values = Array.from({ length: points }, () => null);
732
+ for (const [t, v] of s.values ?? []) {
733
+ const i = Math.round((t - start) / step);
734
+ if (i >= 0 && i < points) values[i] = toValue(v, precision);
735
+ }
736
+ for (const [t, h] of s.histograms ?? []) {
737
+ const i = Math.round((t - start) / step);
738
+ if (i >= 0 && i < points) values[i] = toValue(h.count, precision);
739
+ }
740
+ return {
741
+ metric: s.metric,
742
+ values,
743
+ stats: stats(values, precision)
744
+ };
745
+ });
746
+ if (sort !== "labels") all.sort((a, b) => sortKey(b.stats, sort) - sortKey(a.stats, sort));
747
+ const kept = all.slice(0, limit);
748
+ const { common, rest } = splitCommonLabels(kept.map((s) => s.metric));
749
+ return {
750
+ resultType: "matrix",
751
+ start: isoTime(start),
752
+ end: isoTime(start + (points - 1) * step),
753
+ step: formatDuration(step),
754
+ points,
755
+ total: all.length,
756
+ ...all.length > kept.length ? { truncated: true } : {},
757
+ ...Object.keys(common).length ? { common_labels: common } : {},
758
+ series: kept.map((s, i) => {
759
+ const gaps = s.values.filter((v) => v === null).length;
760
+ return {
761
+ ...withLabels(rest[i]),
762
+ ...s.stats ? { stats: s.stats } : {},
763
+ ...gaps > 0 && view === "stats" ? { missing_points: gaps } : {},
764
+ ...view === "values" ? { values: s.values } : {}
765
+ };
766
+ })
767
+ };
768
+ };
769
+ const shapeRule = (group, r, includeQuery) => ({
770
+ group,
771
+ name: r.name,
772
+ type: r.type === "alerting" ? "alert" : "record",
773
+ ...r.state ? { state: r.state } : {},
774
+ health: r.health,
775
+ ...r.lastError ? { lastError: r.lastError } : {},
776
+ ...includeQuery ? { query: r.query } : {},
777
+ ...r.duration ? { for: formatDuration(r.duration) } : {},
778
+ ...r.keepFiringFor ? { keepFiringFor: formatDuration(r.keepFiringFor) } : {},
779
+ ...r.labels && Object.keys(r.labels).length ? { labels: r.labels } : {},
780
+ ...r.annotations?.summary ? { summary: r.annotations.summary } : {}
781
+ });
782
+ const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[A-Za-z]`, "g");
783
+ /** Strip ANSI colour codes and the trailing newline, then cut at `max` characters. */
784
+ const cleanLine = (raw, max) => {
785
+ const line = raw.replace(ANSI_RE, "").replace(/\r?\n$/, "");
786
+ if (line.length <= max) return {
787
+ line,
788
+ cut: false
789
+ };
790
+ return {
791
+ line: `${line.slice(0, max)}… [+${line.length - max} chars]`,
792
+ cut: true
793
+ };
794
+ };
795
+ const omit = (labels, keys) => Object.fromEntries(Object.entries(labels).filter(([k]) => !keys.has(k)));
796
+ /** The levels Loki assigns when it found none; saying so on every line is noise. */
797
+ const NO_LEVEL = /* @__PURE__ */ new Set(["unknown", ""]);
798
+ /**
799
+ * Lay a log query's streams out as one time-ordered list of lines. Labels
800
+ * every stream shares go to `common_labels` (all of them, when there is only
801
+ * one stream); each line keeps only what tells its stream apart.
802
+ */
803
+ const shapeStreams = (result, opts) => {
804
+ const hide = new Set(opts.hideLabels);
805
+ hide.add("detected_level");
806
+ const metrics = result.map((s) => omit(s.stream, hide));
807
+ const split = metrics.length === 1 ? {
808
+ common: metrics[0],
809
+ rest: [{}]
810
+ } : splitCommonLabels(metrics);
811
+ const flat = [];
812
+ result.forEach((s, i) => {
813
+ for (const entry of s.values) flat.push({
814
+ ns: BigInt(entry[0]),
815
+ stream: i,
816
+ entry
817
+ });
818
+ });
819
+ const sign = opts.direction === "backward" ? -1 : 1;
820
+ flat.sort((a, b) => a.ns === b.ns ? 0 : a.ns > b.ns ? sign : -sign);
821
+ let cutLines = 0;
822
+ const parseErrors = {};
823
+ const entries = flat.map(({ ns, stream, entry }) => {
824
+ const [, raw, meta] = entry;
825
+ const { line, cut } = cleanLine(raw, opts.maxLineChars);
826
+ if (cut) cutLines += 1;
827
+ const sm = { ...meta?.structuredMetadata };
828
+ const parsed = { ...meta?.parsed };
829
+ const level = sm.detected_level ?? result[stream]?.stream.detected_level;
830
+ delete sm.detected_level;
831
+ let parseError;
832
+ if (parsed.__error__) {
833
+ parseError = parsed.__error__;
834
+ const key = parsed.__error_details__ ? `${parsed.__error__}: ${parsed.__error_details__}` : parsed.__error__;
835
+ parseErrors[key] = (parseErrors[key] ?? 0) + 1;
836
+ delete parsed.__error__;
837
+ delete parsed.__error_details__;
838
+ }
839
+ const fields = opts.includeParsed ? {
840
+ ...sm,
841
+ ...parsed
842
+ } : sm;
843
+ const labels = split.rest[stream] ?? {};
844
+ return {
845
+ ts: nsToIso(ns),
846
+ ...withLabels(labels),
847
+ ...level && !NO_LEVEL.has(level) ? { level } : {},
848
+ line,
849
+ ...Object.keys(fields).length ? { fields } : {},
850
+ ...parseError ? { parse_error: parseError } : {}
851
+ };
852
+ });
853
+ const shaped = {
854
+ streams: result.length,
855
+ lines: entries.length,
856
+ ...Object.keys(split.common).length ? { common_labels: split.common } : {},
857
+ ...cutLines ? { lines_cut: cutLines } : {},
858
+ ...Object.keys(parseErrors).length ? { parse_errors: parseErrors } : {},
859
+ entries
860
+ };
861
+ const head = flat[0];
862
+ const tail = flat[flat.length - 1];
863
+ return {
864
+ shaped,
865
+ ...head && tail ? {
866
+ first: head.ns,
867
+ last: tail.ns
868
+ } : {}
869
+ };
870
+ };
871
+ /** 26677090 → "26.7 MB". Decimal units, as Grafana shows them. */
872
+ const humanBytes = (bytes) => {
873
+ if (!Number.isFinite(bytes)) return String(bytes);
874
+ const units = [
875
+ "B",
876
+ "kB",
877
+ "MB",
878
+ "GB",
879
+ "TB",
880
+ "PB"
881
+ ];
882
+ let v = bytes;
883
+ let i = 0;
884
+ while (Math.abs(v) >= 1e3 && i < units.length - 1) {
885
+ v /= 1e3;
886
+ i += 1;
887
+ }
888
+ return `${i === 0 ? v : Number(v.toPrecision(3))} ${units[i]}`;
889
+ };
890
+ const shapeStats = (s) => ({
891
+ streams: s.streams,
892
+ chunks: s.chunks,
893
+ entries: s.entries,
894
+ bytes: s.bytes,
895
+ size: humanBytes(s.bytes)
896
+ });
897
+ /** /index/volume: bytes per label set, largest first. */
898
+ const shapeVolume = (data, limit) => {
899
+ const rows = data.result.map((r) => ({
900
+ labels: r.metric,
901
+ bytes: Number(r.value?.[1] ?? 0)
902
+ })).toSorted((a, b) => b.bytes - a.bytes);
903
+ const total = rows.reduce((sum, r) => sum + r.bytes, 0);
904
+ const kept = rows.slice(0, limit);
905
+ return {
906
+ total_size: humanBytes(total),
907
+ total: rows.length,
908
+ ...rows.length > kept.length ? { truncated: true } : {},
909
+ volumes: kept.map((r) => ({
910
+ ...r.labels,
911
+ bytes: r.bytes,
912
+ size: humanBytes(r.bytes),
913
+ share: total > 0 ? `${round(r.bytes / total * 100, 4)}%` : "0%"
914
+ }))
915
+ };
916
+ };
917
+ /**
918
+ * Patterns are line templates with `<_>` for the variable parts. The samples
919
+ * are per-bucket counts; what a reader wants is the total and when it was
920
+ * seen, so the buckets are summed rather than returned.
921
+ */
922
+ const shapePatterns = (patterns, opts) => {
923
+ const rows = patterns.map((p) => {
924
+ let count = 0;
925
+ let first = Infinity;
926
+ let last = -Infinity;
927
+ for (const [t, n] of p.samples ?? []) {
928
+ count += n;
929
+ if (t < first) first = t;
930
+ if (t > last) last = t;
931
+ }
932
+ return {
933
+ p,
934
+ count,
935
+ first,
936
+ last
937
+ };
938
+ }).toSorted((a, b) => b.count - a.count);
939
+ const total = rows.reduce((sum, r) => sum + r.count, 0);
940
+ const kept = rows.slice(0, opts.limit);
941
+ return {
942
+ total_patterns: rows.length,
943
+ total_lines: total,
944
+ ...rows.length > kept.length ? { truncated: true } : {},
945
+ patterns: kept.map(({ p, count, first, last }) => ({
946
+ count,
947
+ share: total > 0 ? `${round(count / total * 100, 4)}%` : "0%",
948
+ ...p.level && !NO_LEVEL.has(p.level) ? { level: p.level } : {},
949
+ pattern: cleanLine(p.pattern, opts.maxChars).line,
950
+ ...Number.isFinite(first) ? {
951
+ first_seen: isoTime(first),
952
+ last_seen: isoTime(last)
953
+ } : {}
954
+ }))
955
+ };
956
+ };
957
+ // src/tools/insights.ts
958
+ const registerIndexTools = (server, client, ctx) => {
959
+ server.registerTool("loki_get_volume", {
960
+ title: "Loki: Get Volume",
961
+ description: "How many bytes of logs each service, namespace or stream produced over a range, largest first, from the index alone — no lines are read, so it is cheap over days. Use it to find the noisiest sources, to see what a selector covers before querying it, or to compare: `{namespace=~\".+\"}` by [\"namespace\"] ranks every namespace. Needs volume_enabled (see loki_auth_status).",
962
+ inputSchema: z.object({
963
+ selector: selectorArg(true),
964
+ by: z.array(z.string()).optional().describe("Labels to group by, e.g. [\"namespace\"] or [\"namespace\", \"app\"]. Defaults to the labels named in the selector."),
965
+ aggregate_by: z.enum(["series", "labels"]).default("series").describe("series: bytes per value of the `by` labels (the usual answer). labels: bytes per label NAME, i.e. how much of the volume carries each label at all."),
966
+ start: timeArg("Range start", "1 hour before end"),
967
+ end: timeArg("Range end", "now"),
968
+ limit: z.number().int().min(1).max(1e3).default(20).describe("Maximum rows to return (1-1000), largest first. Defaults to 20.")
969
+ }),
970
+ annotations: {
971
+ readOnlyHint: true,
972
+ openWorldHint: true
973
+ }
974
+ }, async ({ selector, by, aggregate_by, start, end, limit }) => wrap(async () => {
975
+ const range = resolveRange({
976
+ start,
977
+ end
978
+ }, ctx.now());
979
+ const res = await client.read("/loki/api/v1/index/volume", {
980
+ query: selector,
981
+ start: range.start,
982
+ end: range.end,
983
+ limit: Math.max(limit, 1e3),
984
+ ...by?.length ? { targetLabels: by.join(",") } : {},
985
+ aggregateBy: aggregate_by
986
+ });
987
+ const data = aggregate_by === "labels" ? { result: res.data.result.map((r) => ({
988
+ ...r,
989
+ metric: { label: Object.keys(r.metric)[0] ?? "" }
990
+ })) } : res.data;
991
+ return withNotes({
992
+ ...ctx.tag(),
993
+ ...shapeVolume(data, limit)
994
+ }, res);
995
+ }));
996
+ server.registerTool("loki_get_stats", {
997
+ title: "Loki: Get Stats",
998
+ description: "How much data a stream selector covers over a range — streams, chunks, log lines and bytes — from the index, without reading any of it. Call it before an expensive loki_query_logs or a long metric range: a selector covering tens of GB will be slow or time out, and should be narrowed first.",
999
+ inputSchema: z.object({
1000
+ selector: selectorArg(true),
1001
+ start: timeArg("Range start", "1 hour before end"),
1002
+ end: timeArg("Range end", "now")
1003
+ }),
1004
+ annotations: {
1005
+ readOnlyHint: true,
1006
+ openWorldHint: true
1007
+ }
1008
+ }, async ({ selector, start, end }) => wrap(async () => {
1009
+ const range = resolveRange({
1010
+ start,
1011
+ end
1012
+ }, ctx.now());
1013
+ const res = await client.read("/loki/api/v1/index/stats", {
1014
+ query: selector,
1015
+ start: range.start,
1016
+ end: range.end
1017
+ });
1018
+ return {
1019
+ ...ctx.tag(),
1020
+ selector,
1021
+ ...shapeStats(res.data)
1022
+ };
1023
+ }));
1024
+ server.registerTool("loki_list_patterns", {
1025
+ title: "Loki: List Patterns",
1026
+ description: "Summarize what a set of streams is logging as line templates (`<_>` marks the variable parts) with how often each occurred, most frequent first. The fastest way to see what a service is doing, or which new message started flooding, without reading lines. Patterns are built by the pattern ingester from recent data (typically the last few hours) and need it to be enabled (see loki_auth_status).",
1027
+ inputSchema: z.object({
1028
+ selector: selectorArg(true),
1029
+ level: z.string().optional().describe("Keep only patterns of this detected level, e.g. `error` or `warn`."),
1030
+ search: searchArg,
1031
+ start: timeArg("Range start", "1 hour before end"),
1032
+ end: timeArg("Range end", "now"),
1033
+ limit: z.number().int().min(1).max(500).default(30).describe("Maximum patterns to return (1-500), most frequent first. Defaults to 30."),
1034
+ max_chars: z.number().int().min(80).max(1e4).default(500).describe("Cut each pattern at this many characters. Defaults to 500.")
1035
+ }),
1036
+ annotations: {
1037
+ readOnlyHint: true,
1038
+ openWorldHint: true
1039
+ }
1040
+ }, async ({ selector, level, search, start, end, limit, max_chars }) => wrap(async () => {
1041
+ const range = resolveRange({
1042
+ start,
1043
+ end
1044
+ }, ctx.now());
1045
+ const res = await client.read("/loki/api/v1/patterns", {
1046
+ query: selector,
1047
+ start: range.start,
1048
+ end: range.end
1049
+ });
1050
+ const kept = (res.data ?? []).filter((p) => (level === void 0 || p.level?.toLowerCase() === level.toLowerCase()) && matches(search, p.pattern));
1051
+ return withNotes({
1052
+ ...ctx.tag(),
1053
+ ...shapePatterns(kept, {
1054
+ limit,
1055
+ maxChars: max_chars
1056
+ })
1057
+ }, res);
1058
+ }));
1059
+ };
1060
+ // src/tools/labels.ts
1061
+ /**
1062
+ * Label discovery defaults to the last 6 hours, as Loki itself does: long
1063
+ * enough to catch a cron job, short enough not to list every pod that ever ran.
1064
+ */
1065
+ const DISCOVERY_SPAN = 21600;
1066
+ const registerLabelTools = (server, client, ctx) => {
1067
+ server.registerTool("loki_list_labels", {
1068
+ title: "Loki: List Labels",
1069
+ description: "List the indexed stream labels with how many distinct values each has, optionally only for streams matching a selector. The starting point for writing any query: low-cardinality labels (namespace, app, service_name, container) make good selectors; high-cardinality ones (pod, filename) are better left to line filters or `by`. Then loki_list_label_values for the values of one label.",
1070
+ inputSchema: z.object({
1071
+ selector: selectorArg(false),
1072
+ start: timeArg("Range start", "6 hours before end"),
1073
+ end: timeArg("Range end", "now")
1074
+ }),
1075
+ annotations: {
1076
+ readOnlyHint: true,
1077
+ openWorldHint: true
1078
+ }
1079
+ }, async ({ selector, start, end }) => wrap(async () => {
1080
+ const range = resolveRange({
1081
+ start,
1082
+ end
1083
+ }, ctx.now(), DISCOVERY_SPAN);
1084
+ const res = await client.get("/loki/api/v1/detected_labels", {
1085
+ query: selector,
1086
+ start: range.start,
1087
+ end: range.end
1088
+ });
1089
+ const labels = (res.data.detectedLabels ?? []).filter((l) => l.label !== "__stream_shard__").toSorted((a, b) => a.label.localeCompare(b.label));
1090
+ return withNotes({
1091
+ ...ctx.tag(),
1092
+ total: labels.length,
1093
+ labels: Object.fromEntries(labels.map((l) => [l.label, l.cardinality]))
1094
+ }, res);
1095
+ }));
1096
+ server.registerTool("loki_list_label_values", {
1097
+ title: "Loki: List Label Values",
1098
+ description: "List the values of one stream label, e.g. every namespace or every app, optionally only among streams matching a selector (the pods of one namespace). Use it to find the exact spelling to put in a selector rather than guessing with a regex.",
1099
+ inputSchema: z.object({
1100
+ label: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "A label name: letters, digits and underscores.").describe("The label name, e.g. `namespace`, `app`, `service_name`, `pod`."),
1101
+ selector: selectorArg(false),
1102
+ search: searchArg,
1103
+ start: timeArg("Range start", "6 hours before end"),
1104
+ end: timeArg("Range end", "now"),
1105
+ limit: z.number().int().min(1).max(5e3).default(200).describe("Maximum values to return (1-5000). Defaults to 200.")
1106
+ }),
1107
+ annotations: {
1108
+ readOnlyHint: true,
1109
+ openWorldHint: true
1110
+ }
1111
+ }, async ({ label, selector, search, start, end, limit }) => wrap(async () => {
1112
+ const range = resolveRange({
1113
+ start,
1114
+ end
1115
+ }, ctx.now(), DISCOVERY_SPAN);
1116
+ const res = await client.get(`/loki/api/v1/label/${encodeURIComponent(label)}/values`, {
1117
+ query: selector,
1118
+ start: range.start,
1119
+ end: range.end
1120
+ });
1121
+ const all = (res.data ?? []).filter((v) => matches(search, v));
1122
+ const values = all.slice(0, limit);
1123
+ return withNotes({
1124
+ ...ctx.tag(),
1125
+ label,
1126
+ total: all.length,
1127
+ ...all.length > values.length ? { truncated: true } : {},
1128
+ values
1129
+ }, res);
1130
+ }));
1131
+ server.registerTool("loki_list_series", {
1132
+ title: "Loki: List Series",
1133
+ description: "List the streams (distinct label sets) matching a selector, with the labels every one shares hoisted into `common_labels`. Use it to see what a selector actually covers — which pods, containers and stdout/stderr streams — before querying it. For how much each one logs, use loki_get_volume instead.",
1134
+ inputSchema: z.object({
1135
+ selector: selectorArg(true),
1136
+ start: timeArg("Range start", "6 hours before end"),
1137
+ end: timeArg("Range end", "now"),
1138
+ limit: z.number().int().min(1).max(1e3).default(50).describe("Maximum streams to return (1-1000). Defaults to 50."),
1139
+ hide_labels: z.array(z.string()).default(["filename"]).describe("Labels to leave out. Defaults to [\"filename\"] (a /var/log/pods/... path that repeats pod and container); [] keeps everything.")
1140
+ }),
1141
+ annotations: {
1142
+ readOnlyHint: true,
1143
+ openWorldHint: true
1144
+ }
1145
+ }, async ({ selector, start, end, limit, hide_labels }) => wrap(async () => {
1146
+ const range = resolveRange({
1147
+ start,
1148
+ end
1149
+ }, ctx.now(), DISCOVERY_SPAN);
1150
+ const res = await client.read("/loki/api/v1/series", {
1151
+ "match[]": [selector],
1152
+ start: range.start,
1153
+ end: range.end
1154
+ });
1155
+ const hide = /* @__PURE__ */ new Set([...hide_labels, "__stream_shard__"]);
1156
+ const all = (res.data ?? []).map((s) => Object.fromEntries(Object.entries(s).filter(([k]) => !hide.has(k))));
1157
+ const unique = [...new Map(all.map((s) => [JSON.stringify(Object.entries(s).toSorted()), s])).values()];
1158
+ const kept = unique.toSorted((a, b) => JSON.stringify(Object.entries(a).toSorted()).localeCompare(JSON.stringify(Object.entries(b).toSorted()))).slice(0, limit);
1159
+ const { common, rest } = splitCommonLabels(kept);
1160
+ return withNotes({
1161
+ ...ctx.tag(),
1162
+ total: unique.length,
1163
+ ...unique.length > kept.length ? { truncated: true } : {},
1164
+ ...Object.keys(common).length ? { common_labels: common } : {},
1165
+ series: rest
1166
+ }, res);
1167
+ }));
1168
+ server.registerTool("loki_list_detected_fields", {
1169
+ title: "Loki: List Detected Fields",
1170
+ description: "List the fields Loki can pull out of the log lines themselves — JSON keys, logfmt keys and structured metadata — with their type, cardinality and which parser finds them, for the streams a query selects. This is how to learn what to write after `| json` or `| logfmt` (e.g. `| json | status >= 500`) without reading lines first. Pass `field` to get that field's values instead.",
1171
+ inputSchema: z.object({
1172
+ query: z.string().min(1).describe("A stream selector, optionally with line filters, e.g. `{app=\"api\"}` or `{app=\"api\"} |= \"error\"`. Fields are sampled from the matching lines."),
1173
+ field: z.string().min(1).optional().describe("A field name from a previous call, e.g. `status`, to list its values."),
1174
+ search: searchArg,
1175
+ start: timeArg("Range start", "1 hour before end"),
1176
+ end: timeArg("Range end", "now"),
1177
+ limit: z.number().int().min(1).max(1e3).default(100).describe("Maximum fields or values to return (1-1000). Defaults to 100.")
1178
+ }),
1179
+ annotations: {
1180
+ readOnlyHint: true,
1181
+ openWorldHint: true
1182
+ }
1183
+ }, async ({ query, field, search, start, end, limit }) => wrap(async () => {
1184
+ const range = resolveRange({
1185
+ start,
1186
+ end
1187
+ }, ctx.now());
1188
+ if (field !== void 0) {
1189
+ const all = ((await client.get(`/loki/api/v1/detected_field/${encodeURIComponent(field)}/values`, {
1190
+ query,
1191
+ start: range.start,
1192
+ end: range.end,
1193
+ limit
1194
+ })).data.values ?? []).filter((v) => matches(search, v));
1195
+ return {
1196
+ ...ctx.tag(),
1197
+ field,
1198
+ total: all.length,
1199
+ ...all.length > limit ? { truncated: true } : {},
1200
+ values: all.slice(0, limit)
1201
+ };
1202
+ }
1203
+ const all = ((await client.get("/loki/api/v1/detected_fields", {
1204
+ query,
1205
+ start: range.start,
1206
+ end: range.end,
1207
+ limit
1208
+ })).data.fields ?? []).filter((f) => matches(search, f.label)).toSorted((a, b) => a.label.localeCompare(b.label));
1209
+ return {
1210
+ ...ctx.tag(),
1211
+ total: all.length,
1212
+ ...all.length > limit ? { truncated: true } : {},
1213
+ fields: all.slice(0, limit).map((f) => ({
1214
+ name: f.label,
1215
+ ...f.type ? { type: f.type } : {},
1216
+ ...f.cardinality !== void 0 ? { cardinality: f.cardinality } : {},
1217
+ parsers: f.parsers?.length ? f.parsers : ["structured_metadata"],
1218
+ ...f.jsonPath && f.jsonPath.length > 1 ? { json_path: f.jsonPath.join(".") } : {}
1219
+ }))
1220
+ };
1221
+ }));
1222
+ };
1223
+ // src/tools/logs.ts
1224
+ /**
1225
+ * Ask for structured metadata and parsed labels per line instead of merged
1226
+ * into the stream labels. Without it, `| json` makes every distinct set of
1227
+ * parsed fields its own stream: one stream of 12 labels became two on the
1228
+ * first line that failed to parse.
1229
+ */
1230
+ const CATEGORIZE = { "X-Loki-Response-Encoding-Flags": "categorize-labels" };
1231
+ const NS_PER_S = 1000000000n;
1232
+ /** How much the query read: the cost signal worth keeping out of the ~2 KB stats block. */
1233
+ const scanned = (stats) => {
1234
+ const s = stats?.summary;
1235
+ if (!s || s.totalBytesProcessed === void 0) return {};
1236
+ return { scanned: {
1237
+ size: humanBytes(s.totalBytesProcessed),
1238
+ ...s.totalLinesProcessed !== void 0 ? { lines: s.totalLinesProcessed } : {},
1239
+ ...s.execTime !== void 0 ? { seconds: round(s.execTime, 3) } : {}
1240
+ } };
1241
+ };
1242
+ const expectStreams = (res) => {
1243
+ if (res.data.resultType !== "streams") throw new TimeArgError(`That is a metric query (it returned a ${res.data.resultType}); use loki_query_metric or loki_query_metric_range for it.`);
1244
+ return res.data.result;
1245
+ };
1246
+ const maxLineArg = z.number().int().min(80).max(1e5).default(2e3).describe("Cut each line at this many characters (80-100000); a cut line ends with `… [+N chars]`. Defaults to 2000. Some services log single lines of hundreds of KB.");
1247
+ const parsedFieldsArg = z.boolean().default(false).describe("Include the labels extracted by | json, | logfmt, | regexp or | pattern on each line, under `fields`. Off by default because they restate the line itself and double its size; turn it on after line_format or keep has reshaped the line, or to read a nested JSON value flattened. Structured metadata (trace_id, detected_level) is always included.");
1248
+ const hideLabelsArg = z.array(z.string()).default(["filename"]).describe("Stream labels to leave out of the answer. Defaults to [\"filename\"], which on Kubernetes is a 120-character /var/log/pods/... path repeating namespace, pod and container. Pass [] to see every label.");
1249
+ const registerLogTools = (server, client, ctx) => {
1250
+ server.registerTool("loki_query_logs", {
1251
+ title: "Loki: Query Logs",
1252
+ description: "Run a LogQL log query over a time range and return the matching lines, newest first by default, merged across streams into one time-ordered list. Labels shared by every stream come once as `common_labels`; each line carries only the labels that tell its stream apart, plus `level` and structured-metadata `fields` (parsed ones with parsed_fields). When `more` is true, call again with `next` as start/end to page on. To count or rank rather than read (errors per namespace, top talkers), use loki_query_metric instead: it is far cheaper than reading lines. loki_get_stats tells you how much a selector would scan.",
1253
+ inputSchema: z.object({
1254
+ query: logQueryArg,
1255
+ start: timeArg("Range start", "1 hour before end"),
1256
+ end: timeArg("Range end (exclusive)", "now"),
1257
+ limit: z.number().int().min(1).max(5e3).default(100).describe("Maximum lines to return (1-5000; Loki's own max_entries_limit_per_query may be lower). Defaults to 100. Page with `next` rather than raising it."),
1258
+ direction: z.enum(["backward", "forward"]).default("backward").describe("backward: newest lines first, so the limit keeps the most recent. forward: oldest first, to read what happened from the start of the range."),
1259
+ max_line_chars: maxLineArg,
1260
+ parsed_fields: parsedFieldsArg,
1261
+ hide_labels: hideLabelsArg
1262
+ }),
1263
+ annotations: {
1264
+ readOnlyHint: true,
1265
+ openWorldHint: true
1266
+ }
1267
+ }, async ({ query, start, end, limit, direction, max_line_chars, parsed_fields, hide_labels }) => wrap(async () => {
1268
+ const range = resolveRange({
1269
+ start,
1270
+ end
1271
+ }, ctx.now());
1272
+ const res = await client.read("/loki/api/v1/query_range", {
1273
+ query,
1274
+ start: range.start,
1275
+ end: range.end,
1276
+ limit,
1277
+ direction
1278
+ }, CATEGORIZE);
1279
+ const { shaped, last } = shapeStreams(expectStreams(res), {
1280
+ direction,
1281
+ maxLineChars: max_line_chars,
1282
+ hideLabels: hide_labels,
1283
+ includeParsed: parsed_fields
1284
+ });
1285
+ const more = shaped.lines === limit && last !== void 0;
1286
+ return withNotes({
1287
+ ...ctx.tag(),
1288
+ start: nsToIso(range.start),
1289
+ end: nsToIso(range.end),
1290
+ direction,
1291
+ ...shaped,
1292
+ ...more ? {
1293
+ more: true,
1294
+ next: direction === "backward" ? {
1295
+ start: nsToIso(range.start),
1296
+ end: nsToIso(last)
1297
+ } : {
1298
+ start: nsToIso(last + 1n),
1299
+ end: nsToIso(range.end)
1300
+ }
1301
+ } : {},
1302
+ ...scanned(res.data.stats)
1303
+ }, res);
1304
+ }));
1305
+ server.registerTool("loki_get_log_context", {
1306
+ title: "Loki: Get Log Context",
1307
+ description: "Show the lines around one log line — what the same stream logged just before and after it, unfiltered — the way Grafana's 'show context' does. Pass the stream selector (built from the line's common_labels plus its own labels, e.g. `{namespace=\"payments\", pod=\"api-7d9f-x2k4\"}`) and the line's exact `ts`. Lines come back oldest first, with the anchor line marked `anchor: true`.",
1308
+ inputSchema: z.object({
1309
+ selector: z.string().min(1).describe("Stream selector for the line's stream, e.g. `{namespace=\"payments\", pod=\"api-7d9f-x2k4\", container=\"api\"}`. Matchers only; the narrower, the more the context is that one process's."),
1310
+ time: z.string().min(1).describe("The anchor line's `ts` exactly as returned (e.g. 2026-11-03T22:13:37.968458546Z), or its nanosecond timestamp. Any other time form is accepted too."),
1311
+ before: z.number().int().min(0).max(500).default(10).describe("Lines to show before the anchor (0-500). Defaults to 10."),
1312
+ after: z.number().int().min(0).max(500).default(10).describe("Lines to show after the anchor (0-500). Defaults to 10."),
1313
+ window: z.string().default("1h").describe("How far to look on each side for those lines, as a duration (5m, 1h). Defaults to 1h; a quiet stream may need more."),
1314
+ max_line_chars: maxLineArg,
1315
+ parsed_fields: parsedFieldsArg,
1316
+ hide_labels: hideLabelsArg
1317
+ }),
1318
+ annotations: {
1319
+ readOnlyHint: true,
1320
+ openWorldHint: true
1321
+ }
1322
+ }, async ({ selector, time, before, after, window, max_line_chars, parsed_fields, hide_labels }) => wrap(async () => {
1323
+ const at = parseTimeNs(time, ctx.now());
1324
+ const span = parseDuration(window);
1325
+ if (span === void 0 || span <= 0) throw new TimeArgError(`Cannot read window "${window}"; use a duration like 5m or 1h.`);
1326
+ const spanNs = BigInt(Math.round(span)) * NS_PER_S;
1327
+ const [older, newer] = await Promise.all([client.read("/loki/api/v1/query_range", {
1328
+ query: selector,
1329
+ start: at - spanNs,
1330
+ end: at + 1n,
1331
+ limit: before + 1,
1332
+ direction: "backward"
1333
+ }, CATEGORIZE), after > 0 ? client.read("/loki/api/v1/query_range", {
1334
+ query: selector,
1335
+ start: at + 1n,
1336
+ end: at + spanNs,
1337
+ limit: after,
1338
+ direction: "forward"
1339
+ }, CATEGORIZE) : void 0]);
1340
+ const streams = [...expectStreams(older), ...newer ? expectStreams(newer) : []];
1341
+ const { shaped } = shapeStreams(streams, {
1342
+ direction: "forward",
1343
+ maxLineChars: max_line_chars,
1344
+ hideLabels: hide_labels,
1345
+ includeParsed: parsed_fields
1346
+ });
1347
+ const anchorTs = nsToIso(at);
1348
+ const entries = shaped.entries.map((e) => e.ts === anchorTs ? {
1349
+ ...e,
1350
+ anchor: true
1351
+ } : e);
1352
+ const found = entries.some((e) => "anchor" in e);
1353
+ return withNotes({
1354
+ ...ctx.tag(),
1355
+ ...shaped,
1356
+ entries,
1357
+ ...found ? {} : { note: "No line at exactly that timestamp in this selector; showing the lines around the time instead. Pass the line's `ts` verbatim for an exact anchor." }
1358
+ }, older);
1359
+ }));
1360
+ };
1361
+ // src/tools/metrics.ts
1362
+ /** Loki, like Prometheus, refuses more than 11,000 points per series; far fewer is readable. */
1363
+ const MAX_POINTS = 1e3;
1364
+ const TARGET_POINTS = 60;
1365
+ const notLogs = (res, tool) => {
1366
+ if (res.data.resultType === "streams") throw new TimeArgError(`That is a log query; ${tool} needs a metric query such as \`sum by (app) (count_over_time({...} |= "error" [5m]))\`. Use loki_query_logs to read lines.`);
1367
+ };
1368
+ const registerMetricTools = (server, client, ctx) => {
1369
+ server.registerTool("loki_query_metric", {
1370
+ title: "Loki: Query Metric",
1371
+ description: "Evaluate a LogQL metric query at one instant: a count, rate or total over a window written into the query, e.g. errors per namespace over the last hour `sum by (namespace) (count_over_time({namespace=~\".+\"} |= \"error\" [1h]))`, or the top 10 `topk(10, ...)`. The right tool for 'how many / which ones' questions — far cheaper than reading lines with loki_query_logs. Labels shared by every series come once as `common_labels`. Series come back largest value first: LogQL's topk() picks the top N but does not order them.",
1372
+ inputSchema: z.object({
1373
+ query: metricQueryArg,
1374
+ time: timeArg("Evaluation time", "now"),
1375
+ limit: z.number().int().min(1).max(2e3).default(100).describe("Maximum series to return (1-2000). Defaults to 100. Use topk() in the query to keep the largest instead of raising this."),
1376
+ sort: z.enum(["value", "labels"]).default("value").describe("value: largest first, applied before `limit` cuts, so a cut keeps the biggest. labels: Loki's own order."),
1377
+ precision: precisionArg
1378
+ }),
1379
+ annotations: {
1380
+ readOnlyHint: true,
1381
+ openWorldHint: true
1382
+ }
1383
+ }, async ({ query, time, limit, sort, precision }) => wrap(async () => {
1384
+ const at = time !== void 0 ? parseTime(time, ctx.now()) : ctx.now();
1385
+ const res = await client.read("/loki/api/v1/query", {
1386
+ query,
1387
+ time: secondsToNs(at)
1388
+ });
1389
+ notLogs(res, "loki_query_metric");
1390
+ return withNotes({
1391
+ ...ctx.tag(),
1392
+ ...shapeInstant(res.data, {
1393
+ limit,
1394
+ precision,
1395
+ sort
1396
+ }),
1397
+ ...scanned(res.data.stats)
1398
+ }, res);
1399
+ }));
1400
+ server.registerTool("loki_query_metric_range", {
1401
+ title: "Loki: Query Metric Range",
1402
+ description: "Evaluate a LogQL metric query over a time range, for how log volume or error rates changed: a spike, when it started, which service it came from. Each series comes back with stats (min, max, avg, last) and, with view=values, one value per step on a shared grid given by start and step (null where there was nothing). The step is picked for about 60 points unless given; the range in the query (`[5m]`) is independent of it, and usually should match it. Aggregate with sum by — one series per stream is rarely what you want and quickly hits max_query_series.",
1403
+ inputSchema: z.object({
1404
+ query: metricQueryArg,
1405
+ start: timeArg("Range start", "now-1h"),
1406
+ end: timeArg("Range end", "now"),
1407
+ step: z.string().optional().describe("Resolution, as a duration (30s, 5m, 1h) or seconds. Defaults to a round step giving about 60 points."),
1408
+ view: z.enum(["values", "stats"]).default("values").describe("values: stats plus the value at every step. stats: min/max/avg/last only, about a tenth of the size — use it to survey many series before plotting a few."),
1409
+ sort: z.enum([
1410
+ "labels",
1411
+ "max",
1412
+ "avg",
1413
+ "last"
1414
+ ]).default("labels").describe("Order of series, applied before `limit` cuts: labels keeps Loki's order; max/avg/last rank highest first, so a cut keeps the biggest."),
1415
+ limit: z.number().int().min(1).max(500).default(20).describe("Maximum series to return (1-500). Defaults to 20."),
1416
+ precision: precisionArg
1417
+ }),
1418
+ annotations: {
1419
+ readOnlyHint: true,
1420
+ openWorldHint: true
1421
+ }
1422
+ }, async ({ query, start, end, step, view, sort, limit, precision }) => wrap(async () => {
1423
+ const now = ctx.now();
1424
+ const endAt = end !== void 0 ? parseTime(end, now) : now;
1425
+ let startAt = start !== void 0 ? parseTime(start, now) : endAt - 3600;
1426
+ if (startAt >= endAt) throw new TimeArgError("start must be before end.");
1427
+ let stepSeconds;
1428
+ if (step !== void 0) {
1429
+ const parsed = parseDuration(step);
1430
+ if (parsed === void 0 || parsed <= 0) throw new TimeArgError(`Cannot read step "${step}"; use a duration like 30s, 5m or 1h.`);
1431
+ stepSeconds = parsed;
1432
+ } else stepSeconds = autoStep(endAt - startAt, TARGET_POINTS);
1433
+ if (Number.isInteger(stepSeconds)) startAt = Math.floor(startAt / stepSeconds) * stepSeconds;
1434
+ const points = Math.floor((endAt - startAt) / stepSeconds) + 1;
1435
+ if (points > MAX_POINTS) throw new TimeArgError(`${points} points per series is too many (max ${MAX_POINTS}); raise step to at least ${Math.ceil((endAt - startAt) / 999)}s or shorten the range.`);
1436
+ const res = await client.read("/loki/api/v1/query_range", {
1437
+ query,
1438
+ start: secondsToNs(startAt),
1439
+ end: secondsToNs(endAt),
1440
+ step: stepSeconds
1441
+ });
1442
+ notLogs(res, "loki_query_metric_range");
1443
+ if (res.data.resultType !== "matrix") throw new Error(`Expected a matrix from query_range, got ${res.data.resultType}.`);
1444
+ return withNotes({
1445
+ ...ctx.tag(),
1446
+ ...shapeRange(res.data, {
1447
+ start: startAt,
1448
+ end: endAt,
1449
+ step: stepSeconds,
1450
+ limit,
1451
+ precision,
1452
+ sort,
1453
+ view
1454
+ }),
1455
+ ...scanned(res.data.stats)
1456
+ }, res);
1457
+ }));
1458
+ };
1459
+ // src/tools/request.ts
1460
+ /**
1461
+ * The read APIs and nothing else: /loki/api/v1/push and the compactor's
1462
+ * delete endpoints sit under the same prefix but take POST/PUT/DELETE, which
1463
+ * this tool cannot send.
1464
+ */
1465
+ const PATH_RE = /^\/(?:loki\/api\/v1|prometheus\/api\/v1)\/[A-Za-z0-9_\-./%]+$|^\/config$/;
1466
+ const registerRequestTools = (server, client) => {
1467
+ server.registerTool("loki_request", {
1468
+ title: "Loki: Request",
1469
+ description: "Escape hatch: GET any Loki read endpoint the other tools do not wrap — /loki/api/v1/index/volume_range, /loki/api/v1/format_query (pretty-prints and validates LogQL without running it), /prometheus/api/v1/alerts, or /config (this Loki's whole running configuration, as YAML). The answer is the raw `data` (or text), unshaped, cut at max_chars. Read-only: only GET is ever sent. Prefer the named tools, which shape their output.",
1470
+ inputSchema: z.object({
1471
+ path: z.string().regex(PATH_RE, "The path must start with /loki/api/v1/ or /prometheus/api/v1/ (or be /config), with no query string.").refine((p) => !p.includes(".."), "The path must not contain `..`.").describe("API path, e.g. \"/loki/api/v1/format_query\"."),
1472
+ params: z.record(z.string(), z.union([z.string(), z.array(z.string())])).default({}).describe("Query parameters, e.g. {\"query\": \"{app=\\\"api\\\"}\"}. Times are best sent as unix nanoseconds or RFC3339. A repeated parameter is an array."),
1473
+ max_chars: z.number().int().min(100).max(2e5).default(2e4).describe("Cut the answer at this many characters (100-200000). Defaults to 20000.")
1474
+ }),
1475
+ annotations: {
1476
+ readOnlyHint: true,
1477
+ openWorldHint: true
1478
+ }
1479
+ }, async ({ path, params, max_chars }) => wrap(async () => {
1480
+ const res = await client.get(path, params);
1481
+ const text = typeof res.data === "string" ? res.data : JSON.stringify(res.data);
1482
+ if (text === void 0 || text.length <= max_chars) return withNotes({ data: res.data }, res);
1483
+ return withNotes({
1484
+ truncated: true,
1485
+ chars: text.length,
1486
+ note: `Showing the first ${max_chars} of ${text.length} characters; narrow the request with params.`,
1487
+ data: text.slice(0, max_chars)
1488
+ }, res);
1489
+ }));
1490
+ };
1491
+ // src/tools/rules.ts
1492
+ const STATE_RANK = {
1493
+ firing: 0,
1494
+ pending: 1,
1495
+ inactive: 2
1496
+ };
1497
+ const registerRuleTools = (server, client, ctx) => {
1498
+ server.registerTool("loki_list_rules", {
1499
+ title: "Loki: List Rules",
1500
+ description: "List the Loki ruler's alerting and recording rules (LogQL rules evaluated by Loki itself, not Prometheus or Grafana-managed alerts), with health, last error and, for alerts, whether they are firing — firing first. Answers an empty list when no rule groups exist for this tenant, and 404 when the ruler is not deployed or not routed.",
1501
+ inputSchema: z.object({
1502
+ type: z.enum(["alert", "record"]).optional().describe("Only alerting rules (alert) or only recording rules (record)."),
1503
+ state: z.enum([
1504
+ "firing",
1505
+ "pending",
1506
+ "inactive"
1507
+ ]).optional().describe("Only alerting rules in this state."),
1508
+ search: searchArg,
1509
+ include_query: z.boolean().default(false).describe("Include each rule's LogQL expression. Off by default: it is most of the size.")
1510
+ }),
1511
+ annotations: {
1512
+ readOnlyHint: true,
1513
+ openWorldHint: true
1514
+ }
1515
+ }, async ({ type, state, search, include_query }) => wrap(async () => {
1516
+ const res = await client.get("/prometheus/api/v1/rules");
1517
+ const groups = res.data.groups ?? [];
1518
+ const rules = groups.flatMap((g) => g.rules.map((r) => ({
1519
+ g,
1520
+ r
1521
+ }))).filter(({ g, r }) => (type === void 0 || type === "alert" === (r.type === "alerting")) && (state === void 0 || r.state === state) && matches(search, r.name, g.name)).toSorted((a, b) => (STATE_RANK[a.r.state ?? ""] ?? 3) - (STATE_RANK[b.r.state ?? ""] ?? 3) || a.r.name.localeCompare(b.r.name));
1522
+ return withNotes({
1523
+ ...ctx.tag(),
1524
+ groups: groups.length,
1525
+ total: rules.length,
1526
+ rules: rules.map(({ g, r }) => shapeRule(g.name, r, include_query))
1527
+ }, res);
1528
+ }));
1529
+ };
1530
+ // src/tools/index.ts
1531
+ const createToolContext = (client, config, hasCredentials, now = () => Date.now() / 1e3) => {
1532
+ let identity;
1533
+ const tenant = resolveTenant(config);
1534
+ return {
1535
+ config,
1536
+ hasCredentials,
1537
+ tenant,
1538
+ tag: () => tenant ? { tenant: tenant.tenant } : {},
1539
+ now,
1540
+ identity: () => {
1541
+ identity ??= fetchIdentity(client).catch((err) => {
1542
+ identity = void 0;
1543
+ throw err;
1544
+ });
1545
+ return identity;
1546
+ }
1547
+ };
1548
+ };
1549
+ /**
1550
+ * Register the tools. Every tool is a read: Loki's mutating endpoints (push,
1551
+ * the compactor's delete requests, ruler writes) are deliberately not wrapped,
1552
+ * so there is no write flag. `loki_request` is GET-only.
1553
+ */
1554
+ const registerTools = (server, client, ctx) => {
1555
+ registerAuthTools(server, ctx);
1556
+ if (!ctx.hasCredentials) return;
1557
+ registerLogTools(server, client, ctx);
1558
+ registerMetricTools(server, client, ctx);
1559
+ registerLabelTools(server, client, ctx);
1560
+ registerIndexTools(server, client, ctx);
1561
+ registerRuleTools(server, client, ctx);
1562
+ registerRequestTools(server, client);
1563
+ };
1564
+ // src/server.ts
1565
+ const SERVER_NAME = BUILD_INFO.name;
1566
+ const SERVER_VERSION = BUILD_INFO.version;
1567
+ const USER_AGENT = `mcp-loki-js/${BUILD_INFO.version}`;
1568
+ const createServer = (opts) => {
1569
+ const { config } = opts;
1570
+ const server = new McpServer({
1571
+ name: SERVER_NAME,
1572
+ version: SERVER_VERSION
1573
+ });
1574
+ const client = new LokiClient({
1575
+ baseUrl: config.url ?? "http://unconfigured.invalid",
1576
+ headers: authHeaders(config),
1577
+ timeoutSeconds: config.timeoutSeconds,
1578
+ maxRetries: config.maxRetries,
1579
+ userAgent: USER_AGENT,
1580
+ ...opts.fetch ? { fetch: opts.fetch } : {},
1581
+ ...opts.logger ? { logger: opts.logger } : {}
1582
+ });
1583
+ const ctx = createToolContext(client, config, isConfigured(config), opts.now);
1584
+ registerTools(server, client, ctx);
1585
+ return {
1586
+ server,
1587
+ client,
1588
+ ctx
1589
+ };
1590
+ };
1591
+ export { BUILD_INFO as _, createToolContext as a, fetchIdentity as c, authHeaders as d, authMode as f, setupInstructions as g, resolveTenant as h, createServer as i, LokiClient as l, loadConfig as m, SERVER_VERSION as n, registerTools as o, isConfigured as p, USER_AGENT as r, describeError as s, SERVER_NAME as t, LokiApiError as u };
1592
+
1593
+ //# sourceMappingURL=server-B2TOeJ3P.js.map