@thotischner/observability-mcp 3.1.0 → 3.1.1

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.
@@ -113,6 +113,20 @@ test("MCP 2025-11-25: tools/list returns a Tool[] each with name + inputSchema",
113
113
  assert.ok(t.inputSchema && typeof t.inputSchema === "object", `tool ${t.name} missing inputSchema`);
114
114
  }
115
115
  });
116
+ test("MCP 2025-11-25: query_logs advertises labels + aggregate params (issue #415)", opts, async () => {
117
+ // Regression guard for the v3.1.0 ship gap: the labels/aggregate handler
118
+ // code existed but the inline MCP schema in createMcpServer never declared
119
+ // them, so a live tools/list omitted them and the SDK stripped them from
120
+ // calls — a silent no-op. Assert the live server advertises both.
121
+ const session = await newSession();
122
+ const { response } = await jsonRpc("tools/list", {}, { id: 2, session });
123
+ const r = response.result;
124
+ const queryLogs = r.tools?.find((t) => t.name === "query_logs");
125
+ assert.ok(queryLogs, "query_logs tool must be advertised");
126
+ const props = queryLogs.inputSchema?.properties ?? {};
127
+ assert.ok("labels" in props, "query_logs must advertise a `labels` param (issue #415 #1)");
128
+ assert.ok("aggregate" in props, "query_logs must advertise an `aggregate` param (issue #415 #2)");
129
+ });
116
130
  test("MCP 2025-11-25: tools/call dispatches and returns CallToolResult", opts, async () => {
117
131
  const session = await newSession();
118
132
  const { response } = await jsonRpc("tools/call", { name: "list_sources", arguments: {} }, { id: 3, session });
package/dist/index.js CHANGED
@@ -437,6 +437,10 @@ async function main() {
437
437
  .string()
438
438
  .optional()
439
439
  .describe("Optional. Filter expression matched against the log message; regular expressions are supported. Omit to return all entries in the window."),
440
+ labels: z
441
+ .record(z.string(), z.string())
442
+ .optional()
443
+ .describe("Optional. Exact-match filters on backend-extracted log fields (e.g. {\"method\":\"GET\",\"status\":\"200\",\"url\":\"/\",\"environment\":\"prod\"}). All AND'd together and compiled to LogQL label filters applied after `| json`, so structured JSON fields become first-class selectors — far more reliable than regex on the raw message. Combine with `aggregate` to filter then group. Backends without label extraction ignore it."),
440
444
  duration: z
441
445
  .string()
442
446
  .optional()
@@ -445,12 +449,34 @@ async function main() {
445
449
  .enum(["error", "warn", "info", "debug"])
446
450
  .optional()
447
451
  .describe("Optional. Return only entries at this severity. Default: all levels."),
452
+ aggregate: z
453
+ .object({
454
+ op: z
455
+ .enum(["count_over_time", "sum", "topk"])
456
+ .describe("count_over_time = time series of counts per `step` bucket; sum = single total per group over the window; topk = the top `k` groups by total."),
457
+ by: z
458
+ .array(z.string())
459
+ .optional()
460
+ .describe("Label names to group by, e.g. [\"url\"] or [\"status\"]. Required for topk."),
461
+ k: z
462
+ .number()
463
+ .int()
464
+ .positive()
465
+ .optional()
466
+ .describe("For topk: how many top groups to return (1-1000)."),
467
+ step: z
468
+ .string()
469
+ .optional()
470
+ .describe("For count_over_time: bucket width as <number><unit> m|h|d (e.g. '15m'). Default auto-derived from duration."),
471
+ })
472
+ .optional()
473
+ .describe("Optional. Server-side aggregation pushed down to LogQL metric queries — returns grouped counts, not raw rows, so you get a number instead of a haystack (and never hit `limit`). Honours `labels`/`query` filters. Example: {\"op\":\"topk\",\"by\":[\"url\"],\"k\":10} for the busiest paths; {\"op\":\"count_over_time\",\"step\":\"15m\"} for a request-count time series."),
448
474
  limit: z
449
475
  .number()
450
476
  .int()
451
477
  .positive()
452
478
  .optional()
453
- .describe("Optional. Maximum number of log entries to return (most recent first). Default: 100."),
479
+ .describe("Optional. Maximum number of log entries to return (most recent first). Default: 100. Ignored when `aggregate` is set."),
454
480
  bypass_redaction: z
455
481
  .boolean()
456
482
  .optional()
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ // Regression guard for issue #415: the query_logs handler (query-logs.ts)
7
+ // reads `labels` and `aggregate` from its args, and validateLogLabels /
8
+ // validateLogAggregate enforce them — but the MCP-facing input schema is
9
+ // declared INLINE in createMcpServer's registerTool("query_logs", …) block
10
+ // in index.ts. In v3.1.0 that inline schema was never updated to advertise
11
+ // `labels`/`aggregate`, so the MCP SDK stripped those keys before they
12
+ // reached the handler: the params were unreachable over MCP and passing
13
+ // them was a silent no-op. The handler unit tests passed because they call
14
+ // the handler directly, bypassing the SDK schema layer.
15
+ //
16
+ // This test parses index.ts and asserts the query_logs registration block
17
+ // declares both fields as schema entries, so the SDK validates and forwards
18
+ // them. The live equivalent (real tools/list handshake) lives in the
19
+ // conformance suite; this is the fast, server-less guard.
20
+ const here = dirname(fileURLToPath(import.meta.url));
21
+ const INDEX_TS = join(here, "..", "index.ts");
22
+ function registerToolBlock(src, tool) {
23
+ const start = src.indexOf(`registerTool(\n "${tool}"`);
24
+ assert.notEqual(start, -1, `registerTool("${tool}", …) not found in index.ts`);
25
+ // The block ends at the next registerTool( call (or EOF).
26
+ const next = src.indexOf("registerTool(", start + 1);
27
+ return src.slice(start, next === -1 ? undefined : next);
28
+ }
29
+ test("query_logs MCP schema advertises `labels` (issue #415 #1)", () => {
30
+ const block = registerToolBlock(readFileSync(INDEX_TS, "utf8"), "query_logs");
31
+ assert.match(block, /\blabels:\s*z\b/, "query_logs registration in index.ts must declare a `labels` schema field " +
32
+ "so the MCP SDK forwards it to the handler (else it is silently stripped).");
33
+ });
34
+ test("query_logs MCP schema advertises `aggregate` (issue #415 #2)", () => {
35
+ const block = registerToolBlock(readFileSync(INDEX_TS, "utf8"), "query_logs");
36
+ assert.match(block, /\baggregate:\s*z\b/, "query_logs registration in index.ts must declare an `aggregate` schema field " +
37
+ "so the MCP SDK forwards it to the handler (else it is silently stripped).");
38
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thotischner/observability-mcp",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Unified observability gateway for AI agents — one MCP server for Prometheus, Loki, and any backend",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",