@thotischner/observability-mcp 3.3.1 → 3.4.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.
@@ -21,7 +21,7 @@ export function correlateSignals(anomalies, logResults, metricResults) {
21
21
  for (const metric of serviceMetrics) {
22
22
  if (metric.metric === anomaly.metric)
23
23
  continue;
24
- if (metric.summary.trend === "rising") {
24
+ if (metric.summary && metric.summary.trend === "rising") {
25
25
  correlations.push(`${anomaly.service}: ${anomaly.metric} anomaly coincides with rising ${metric.metric} ` +
26
26
  `(current: ${metric.summary.current.toFixed(2)})`);
27
27
  }
@@ -16,6 +16,11 @@
16
16
  * # via the bypass_redaction tool arg. Off by default
17
17
  * # for every key — pair with the redaction:bypass
18
18
  * # RBAC permission for the management-plane angle.
19
+ * OMCP_KEY_RAW_QUERY="agent,ci"
20
+ * # optional comma-separated list of key NAMES allowed to
21
+ * # run raw_query even when the global OMCP_RAW_QUERY
22
+ * # capability is off. Effective gate = global OR per-key,
23
+ * # so it only widens; off by default for every key.
19
24
  * OMCP_KEY_TENANTS="agent=acme;ci=bigco"
20
25
  * # optional per-key tenant assignment. Unlisted keys
21
26
  * # land in the "default" tenant — identical to the
@@ -42,6 +47,11 @@ export interface Credential {
42
47
  * to explicitly set `bypass_redaction: true` in the tool args —
43
48
  * this flag only authorises it; it never auto-disables redaction. */
44
49
  bypassRedaction?: boolean;
50
+ /** True when the operator opted this credential into running `raw_query`
51
+ * even with the global OMCP_RAW_QUERY capability off. Configured via
52
+ * OMCP_KEY_RAW_QUERY. The effective gate is `global OR per-credential` —
53
+ * it only widens access, never narrows a globally-enabled deployment. */
54
+ allowRawQuery?: boolean;
45
55
  /** Tenant this credential belongs to. Omitted → DEFAULT_TENANT. */
46
56
  tenant?: string;
47
57
  /** Product id this credential is bound to. When set, /mcp tools/list
@@ -16,6 +16,11 @@
16
16
  * # via the bypass_redaction tool arg. Off by default
17
17
  * # for every key — pair with the redaction:bypass
18
18
  * # RBAC permission for the management-plane angle.
19
+ * OMCP_KEY_RAW_QUERY="agent,ci"
20
+ * # optional comma-separated list of key NAMES allowed to
21
+ * # run raw_query even when the global OMCP_RAW_QUERY
22
+ * # capability is off. Effective gate = global OR per-key,
23
+ * # so it only widens; off by default for every key.
19
24
  * OMCP_KEY_TENANTS="agent=acme;ci=bigco"
20
25
  * # optional per-key tenant assignment. Unlisted keys
21
26
  * # land in the "default" tenant — identical to the
@@ -76,6 +81,7 @@ export function loadCredentials(env = process.env) {
76
81
  return [];
77
82
  const keySources = parseKeySources(env.OMCP_KEY_SOURCES);
78
83
  const bypassNames = parseBypassSet(env.OMCP_KEY_BYPASS_REDACTION);
84
+ const rawQueryNames = parseBypassSet(env.OMCP_KEY_RAW_QUERY);
79
85
  const keyTenants = parseKeyTenants(env.OMCP_KEY_TENANTS);
80
86
  const keyProducts = parseKeyProducts(env.OMCP_KEY_PRODUCTS);
81
87
  const creds = [];
@@ -90,6 +96,7 @@ export function loadCredentials(env = process.env) {
90
96
  token,
91
97
  allowedSources: keySources.get(name),
92
98
  bypassRedaction: bypassNames.has(name) || undefined,
99
+ allowRawQuery: rawQueryNames.has(name) || undefined,
93
100
  tenant: keyTenants.get(name) || undefined,
94
101
  productId: keyProducts.get(name) || undefined,
95
102
  });
@@ -11,7 +11,7 @@ describe("single-tenant auth primitive", () => {
11
11
  it("parses name:token and bare token", () => {
12
12
  const creds = loadCredentials({ OMCP_API_KEYS: "ci:tok_abc, tok_bare " });
13
13
  assert.equal(creds.length, 2);
14
- assert.deepEqual(creds[0], { name: "ci", token: "tok_abc", allowedSources: undefined, bypassRedaction: undefined, tenant: undefined, productId: undefined });
14
+ assert.deepEqual(creds[0], { name: "ci", token: "tok_abc", allowedSources: undefined, bypassRedaction: undefined, allowRawQuery: undefined, tenant: undefined, productId: undefined });
15
15
  assert.equal(creds[1].name, "key");
16
16
  assert.equal(creds[1].token, "tok_bare");
17
17
  });
@@ -39,6 +39,21 @@ describe("single-tenant auth primitive", () => {
39
39
  for (const c of creds)
40
40
  assert.equal(c.bypassRedaction, undefined);
41
41
  });
42
+ it("parses OMCP_KEY_RAW_QUERY → flags only the listed names", () => {
43
+ const creds = loadCredentials({
44
+ OMCP_API_KEYS: "agent:tok1,ci:tok2,unprivileged:tok3",
45
+ OMCP_KEY_RAW_QUERY: "agent, ci",
46
+ });
47
+ assert.equal(creds.find((c) => c.name === "agent")?.allowRawQuery, true);
48
+ assert.equal(creds.find((c) => c.name === "ci")?.allowRawQuery, true);
49
+ // Unlisted keys MUST be undefined (not false) so it only widens.
50
+ assert.equal(creds.find((c) => c.name === "unprivileged")?.allowRawQuery, undefined);
51
+ });
52
+ it("OMCP_KEY_RAW_QUERY absent → no key may raw_query per-credential (global flag still applies)", () => {
53
+ const creds = loadCredentials({ OMCP_API_KEYS: "agent:tok1,ci:tok2" });
54
+ for (const c of creds)
55
+ assert.equal(c.allowRawQuery, undefined);
56
+ });
42
57
  it("parses OMCP_KEY_TENANTS → assigns tenant to named keys; unlisted stays undefined (default)", () => {
43
58
  const creds = loadCredentials({
44
59
  OMCP_API_KEYS: "agent:tok1,ci:tok2,nobody:tok3",
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { readFileSync } from "node:fs";
3
+ import { readFileSync, existsSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { dirname, join } from "node:path";
6
6
  import { validatePassword, passwordPolicyFromEnv, passwordPolicyDisabledFromEnv, DEFAULT_PASSWORD_POLICY, COMMON_PASSWORD_DENYLIST, } from "./password-policy.js";
@@ -89,14 +89,23 @@ test("passwordPolicyDisabledFromEnv recognises truthy values", () => {
89
89
  assert.equal(passwordPolicyDisabledFromEnv({ OMCP_PASSWORD_POLICY_DISABLED: "false" }), false);
90
90
  assert.equal(passwordPolicyDisabledFromEnv({}), false);
91
91
  });
92
- test("CLI hash-password.mjs stays in sync with the canonical policy", () => {
92
+ // The CLI lives at the repo root (scripts/hash-password.mjs), reached via
93
+ // ../../../scripts from mcp-server/src/auth. Under a partial mount that only
94
+ // mounts mcp-server/ (e.g. the docker-first `docker run -v "$(pwd)/mcp-server:/app"`
95
+ // unit-test command) the repo-root scripts/ dir isn't present. Skip there
96
+ // rather than emit a spurious ENOENT failure — but in CI the full repo is
97
+ // checked out, so a miss is a real move/rename and must still fail loudly.
98
+ const _here = dirname(fileURLToPath(import.meta.url));
99
+ const _cliPath = join(_here, "..", "..", "..", "scripts", "hash-password.mjs");
100
+ const _cliSyncSkip = existsSync(_cliPath) || process.env.CI
101
+ ? {}
102
+ : { skip: "scripts/hash-password.mjs not reachable from this mount (run from the repo root or in CI)" };
103
+ test("CLI hash-password.mjs stays in sync with the canonical policy", _cliSyncSkip, () => {
93
104
  // The CLI deliberately duplicates the policy to stay dependency-free
94
105
  // (same precedent as the scrypt params). This guard fails loudly if the
95
106
  // two drift: every canonical denylist entry + the defaults must appear
96
107
  // verbatim in the script. (__dirname → mcp-server/src/auth)
97
- const here = dirname(fileURLToPath(import.meta.url));
98
- const cliPath = join(here, "..", "..", "..", "scripts", "hash-password.mjs");
99
- const cli = readFileSync(cliPath, "utf8");
108
+ const cli = readFileSync(_cliPath, "utf8");
100
109
  for (const entry of COMMON_PASSWORD_DENYLIST) {
101
110
  assert.ok(cli.includes(`"${entry}"`), `CLI denylist is missing "${entry}"`);
102
111
  }
@@ -146,6 +146,23 @@ describe("Q-LOG2: buildAggregateLogQL", () => {
146
146
  assert.equal(r.step, "900s");
147
147
  assert.equal(r.logql, `sum by (url) (count_over_time(${PIPE} [900s]))`);
148
148
  });
149
+ it("count_over_time with a label-filter pipeline + no by → valid sum-wrapped LogQL (#452 leftover #2)", () => {
150
+ // The reporter saw an intermittent 400 on a label-filtered count_over_time
151
+ // and wondered if the collapse path emits different LogQL when a filter is
152
+ // present. It does not: the label filter lives in the streamPipeline
153
+ // (identical to the sum/topk path, which works), and the count_over_time
154
+ // branch wraps it verbatim. Assert the emitted LogQL is well-formed —
155
+ // `sum (count_over_time({sel} | json | environment="prod" [step]))` — so
156
+ // any future regression in the generated query is caught here.
157
+ const filtered = '{service_name="app"} | json | environment="prod"';
158
+ const r = buildAggregateLogQL(filtered, { op: "count_over_time", step: "1h" }, "6h");
159
+ assert.equal(r.mode, "range");
160
+ assert.equal(r.step, "3600s");
161
+ assert.equal(r.logql, `sum (count_over_time(${filtered} [3600s]))`);
162
+ // Structural sanity: balanced parens, sum-wrapped, single range selector.
163
+ assert.equal((r.logql.match(/\(/g) || []).length, (r.logql.match(/\)/g) || []).length);
164
+ assert.match(r.logql, /^sum \(count_over_time\(.*\[\d+s\]\)\)$/);
165
+ });
149
166
  it("count_over_time without by → sum-wrapped (single series), default step (#452)", () => {
150
167
  // Regression for issue #452: a bare count_over_time over a `| json` stream
151
168
  // keeps every extracted label as its own series. With no `by` we must
@@ -240,6 +240,10 @@ export class PrometheusConnector {
240
240
  resolvedSeries: promql,
241
241
  resolvedLabel: label,
242
242
  };
243
+ if (!result.summary) {
244
+ result.note = `No data: no '${params.metric}' series matched "${params.service}" in this window. ` +
245
+ "The service may expose logs only, or the metric name/label didn't match. Absent ≠ zero — summary is null rather than all-zeros.";
246
+ }
243
247
  if (params.groupBy && groups.length > 1) {
244
248
  result.groupBy = params.groupBy;
245
249
  result.groups = groups;
@@ -295,6 +299,9 @@ export class PrometheusConnector {
295
299
  resolvedSeries: rawQuery,
296
300
  resolvedLabel: "",
297
301
  };
302
+ if (!result.summary) {
303
+ result.note = "No data: the query returned no series in this window. Absent ≠ zero — summary is null rather than all-zeros.";
304
+ }
298
305
  if (groups.length > 1)
299
306
  result.groups = groups;
300
307
  return result;
@@ -475,7 +482,10 @@ export class PrometheusConnector {
475
482
  }
476
483
  computeSummary(values) {
477
484
  if (values.length === 0) {
478
- return { current: 0, average: 0, min: 0, max: 0, trend: "stable" };
485
+ // No data points no-data, NOT a confident all-zeros reading. Coercing
486
+ // an empty series to {current:0,trend:"stable"} is indistinguishable
487
+ // from a service genuinely idling at 0 (issue #462).
488
+ return null;
479
489
  }
480
490
  const current = values[values.length - 1];
481
491
  const average = values.reduce((a, b) => a + b, 0) / values.length;
@@ -53,13 +53,9 @@ describe("PrometheusConnector", () => {
53
53
  });
54
54
  });
55
55
  describe("computeSummary", () => {
56
- it("returns zeros for empty array", () => {
56
+ it("returns null for empty array — no-data, not a false all-zeros reading (#462)", () => {
57
57
  const s = proto.computeSummary([]);
58
- assert.equal(s.current, 0);
59
- assert.equal(s.average, 0);
60
- assert.equal(s.min, 0);
61
- assert.equal(s.max, 0);
62
- assert.equal(s.trend, "stable");
58
+ assert.equal(s, null);
63
59
  });
64
60
  it("computes correct summary for values", () => {
65
61
  const s = proto.computeSummary([10, 20, 30, 40]);
@@ -199,4 +195,34 @@ describe("PrometheusConnector", () => {
199
195
  }
200
196
  });
201
197
  });
198
+ describe("queryMetrics no-data → null summary, not zero-fill (#462)", () => {
199
+ const fakeSource = { name: "test", type: "prometheus", url: "http://localhost:9090", enabled: true };
200
+ it("an empty result set yields values:[], summary:null, and a no-data note", async () => {
201
+ const connector = new PrometheusConnector();
202
+ await connector.connect({ ...fakeSource });
203
+ const orig = globalThis.fetch;
204
+ // raw_query bypasses the candidate-probe / label-resolve path and runs
205
+ // query_range directly — here it returns an empty result set (the
206
+ // no-data case: a logs-only service has no such metric series).
207
+ globalThis.fetch = (async () => ({
208
+ ok: true,
209
+ status: 200,
210
+ json: async () => ({ data: { result: [] } }),
211
+ }));
212
+ try {
213
+ const result = await connector.queryMetrics({
214
+ service: "",
215
+ metric: "",
216
+ duration: "1h",
217
+ rawQuery: "rate(process_cpu_seconds_total{job=\"logs-only-svc\"}[1m]) * 100",
218
+ });
219
+ assert.deepEqual(result.values, [], "no data points");
220
+ assert.equal(result.summary, null, "summary must be null, not {current:0,...}");
221
+ assert.match(result.note ?? "", /No data/i, "must carry a no-data note");
222
+ }
223
+ finally {
224
+ globalThis.fetch = orig;
225
+ }
226
+ });
227
+ });
202
228
  });
package/dist/context.d.ts CHANGED
@@ -23,6 +23,12 @@ export interface RequestContext {
23
23
  * tool call ALSO sets `bypass_redaction: true` in its args. Default
24
24
  * false. Configured via OMCP_KEY_BYPASS_REDACTION. */
25
25
  allowBypassRedaction?: boolean;
26
+ /** When true, this credential may run `raw_query` even if the global
27
+ * OMCP_RAW_QUERY capability is off. Per-credential gating (configured via
28
+ * OMCP_KEY_RAW_QUERY) — the effective gate is `global OR per-credential`,
29
+ * so a global enable still works and this only widens, never narrows.
30
+ * Default false. */
31
+ allowRawQuery?: boolean;
26
32
  /** Tenant the request operates in. ALWAYS set — defaults to
27
33
  * "default" for anonymous principals + missing-tenant credentials,
28
34
  * preserving the single-namespace behaviour of pre-E7 deployments. */
@@ -50,6 +56,7 @@ export declare function defaultContext(opts?: {
50
56
  /** Context for an authenticated API-key principal. */
51
57
  export declare function principalContext(principalId: string, allowedSources?: string[], opts?: {
52
58
  allowBypassRedaction?: boolean;
59
+ allowRawQuery?: boolean;
53
60
  tenant?: string;
54
61
  allowedTools?: string[];
55
62
  }): RequestContext;
package/dist/context.js CHANGED
@@ -24,6 +24,7 @@ export function principalContext(principalId, allowedSources, opts = {}) {
24
24
  auth: "apikey",
25
25
  allowedSources: allowedSources && allowedSources.length > 0 ? allowedSources : undefined,
26
26
  allowBypassRedaction: opts.allowBypassRedaction || undefined,
27
+ allowRawQuery: opts.allowRawQuery || undefined,
27
28
  tenant: normaliseTenant(opts.tenant),
28
29
  allowedTools: opts.allowedTools && opts.allowedTools.length > 0 ? opts.allowedTools : undefined,
29
30
  correlationId: randomUUID(),
@@ -29,6 +29,12 @@ test("principalContext — passes allowedTools through; empty array → undefine
29
29
  const ctx3 = principalContext("agent");
30
30
  assert.equal(ctx3.allowedTools, undefined);
31
31
  });
32
+ test("principalContext — allowRawQuery passes through, off by default (R4 per-credential raw_query)", () => {
33
+ assert.equal(principalContext("agent").allowRawQuery, undefined);
34
+ assert.equal(principalContext("agent", undefined, {}).allowRawQuery, undefined);
35
+ assert.equal(principalContext("agent", undefined, { allowRawQuery: false }).allowRawQuery, undefined);
36
+ assert.equal(principalContext("agent", undefined, { allowRawQuery: true }).allowRawQuery, true);
37
+ });
32
38
  test("defaultContext — no allowedTools (anonymous sees every tool, back-compat)", () => {
33
39
  const ctx = defaultContext();
34
40
  assert.equal(ctx.allowedTools, undefined);
@@ -13,13 +13,25 @@ export declare function parseCidr(cidr: string): {
13
13
  end: number;
14
14
  prefix: number;
15
15
  } | null;
16
+ /** Parse an IPv6 string to a 128-bit BigInt, or null if invalid. Handles
17
+ * `::` zero-compression and a trailing IPv4-mapped tail (`::ffff:1.2.3.4`). */
18
+ export declare function ipv6ToBigInt(ip: string): bigint | null;
19
+ /** Parse an IPv6 CIDR (or bare IPv6 = /128) to an inclusive BigInt range. */
20
+ export declare function parseCidr6(cidr: string): {
21
+ start: bigint;
22
+ end: bigint;
23
+ prefix: number;
24
+ } | null;
16
25
  export declare class IpEnrichmentDataset {
17
26
  private ranges;
18
- /** Rows that couldn't be parsed (bad CIDR, IPv6, malformed) — surfaced for diagnostics. */
27
+ private ranges6;
28
+ /** Rows that couldn't be parsed (bad CIDR, malformed) — surfaced for diagnostics. */
19
29
  readonly skipped: number;
20
30
  readonly size: number;
21
31
  private constructor();
22
32
  static fromCsv(text: string): IpEnrichmentDataset;
23
- /** Look up an IPv4 string. Returns the most specific matching row, or null. */
33
+ /** Look up an IPv4 or IPv6 string. Returns the most specific matching row, or null. */
24
34
  lookup(ip: string): IpEnrichment | null;
35
+ private lookup4;
36
+ private lookup6;
25
37
  }
@@ -9,9 +9,9 @@
9
9
  // 1.2.3.0/24,US,Ashburn,AS14618,Example Cloud,true
10
10
  // 203.0.113.5,DE,Berlin,AS3320,Example ISP,false
11
11
  //
12
- // - `network` is an IPv4 CIDR (or a bare IPv4, treated as /32). IPv6 rows are
13
- // skipped (logged by the caller) IPv4 covers the access-log case the
14
- // report was about; IPv6 can follow.
12
+ // - `network` is an IPv4 or IPv6 CIDR (or a bare address, treated as /32 or
13
+ // /128). Both families are supported; IPv4 uses fast 32-bit integer ranges,
14
+ // IPv6 uses 128-bit BigInt ranges. IPv4-mapped IPv6 (`::ffff:1.2.3.4`) parses.
15
15
  // - Remaining columns are optional; an empty cell is omitted from the result.
16
16
  // - `hosting` is the "is this a datacenter / hosting / proxy range" flag — the
17
17
  // signal that separates real humans from bots/scanners/VPN-exit-nodes. Parsed
@@ -50,20 +50,94 @@ export function parseCidr(cidr) {
50
50
  const end = (start + (hostBits === 32 ? 0xffffffff : (1 << hostBits) - 1)) >>> 0;
51
51
  return { start, end, prefix };
52
52
  }
53
+ /** Parse an IPv6 string to a 128-bit BigInt, or null if invalid. Handles
54
+ * `::` zero-compression and a trailing IPv4-mapped tail (`::ffff:1.2.3.4`). */
55
+ export function ipv6ToBigInt(ip) {
56
+ let s = ip.trim();
57
+ if (s === "" || s.includes(":::"))
58
+ return null;
59
+ // A trailing embedded IPv4 (e.g. ::ffff:1.2.3.4) → two hextets.
60
+ const lastColon = s.lastIndexOf(":");
61
+ if (s.slice(lastColon + 1).includes(".")) {
62
+ const v4 = ipv4ToInt(s.slice(lastColon + 1));
63
+ if (v4 === null)
64
+ return null;
65
+ const hi = (v4 >>> 16) & 0xffff;
66
+ const lo = v4 & 0xffff;
67
+ s = s.slice(0, lastColon + 1) + hi.toString(16) + ":" + lo.toString(16);
68
+ }
69
+ const halves = s.split("::");
70
+ if (halves.length > 2)
71
+ return null; // at most one "::"
72
+ const parseGroups = (part) => {
73
+ if (part === "")
74
+ return [];
75
+ const groups = part.split(":");
76
+ const out = [];
77
+ for (const g of groups) {
78
+ if (!/^[0-9a-fA-F]{1,4}$/.test(g))
79
+ return null;
80
+ out.push(parseInt(g, 16));
81
+ }
82
+ return out;
83
+ };
84
+ let hextets;
85
+ if (halves.length === 2) {
86
+ const left = parseGroups(halves[0]);
87
+ const right = parseGroups(halves[1]);
88
+ if (left === null || right === null)
89
+ return null;
90
+ const fill = 8 - left.length - right.length;
91
+ if (fill < 1)
92
+ return null; // "::" must stand for at least one zero group
93
+ hextets = [...left, ...Array(fill).fill(0), ...right];
94
+ }
95
+ else {
96
+ const all = parseGroups(s);
97
+ if (all === null)
98
+ return null;
99
+ hextets = all;
100
+ }
101
+ if (hextets.length !== 8)
102
+ return null;
103
+ let n = 0n;
104
+ for (const h of hextets)
105
+ n = (n << 16n) | BigInt(h);
106
+ return n;
107
+ }
108
+ /** Parse an IPv6 CIDR (or bare IPv6 = /128) to an inclusive BigInt range. */
109
+ export function parseCidr6(cidr) {
110
+ const [addr, prefixStr] = cidr.trim().split("/");
111
+ const base = ipv6ToBigInt(addr);
112
+ if (base === null)
113
+ return null;
114
+ const prefix = prefixStr === undefined ? 128 : Number(prefixStr);
115
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > 128)
116
+ return null;
117
+ const hostBits = BigInt(128 - prefix);
118
+ const full = (1n << 128n) - 1n;
119
+ const mask = prefix === 0 ? 0n : (full << hostBits) & full;
120
+ const start = base & mask;
121
+ const end = start | ((1n << hostBits) - 1n);
122
+ return { start, end, prefix };
123
+ }
53
124
  export class IpEnrichmentDataset {
54
125
  ranges = [];
55
- /** Rows that couldn't be parsed (bad CIDR, IPv6, malformed) — surfaced for diagnostics. */
126
+ ranges6 = [];
127
+ /** Rows that couldn't be parsed (bad CIDR, malformed) — surfaced for diagnostics. */
56
128
  skipped;
57
129
  size;
58
- constructor(ranges, skipped) {
130
+ constructor(ranges, ranges6, skipped) {
59
131
  // Sort by start asc; lookup picks the most specific (largest prefix)
60
132
  // containing range so nested/overlapping rows resolve deterministically.
61
133
  this.ranges = ranges.sort((a, b) => a.start - b.start || a.end - b.end);
134
+ this.ranges6 = ranges6.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : a.end < b.end ? -1 : a.end > b.end ? 1 : 0));
62
135
  this.skipped = skipped;
63
- this.size = ranges.length;
136
+ this.size = ranges.length + ranges6.length;
64
137
  }
65
138
  static fromCsv(text) {
66
139
  const ranges = [];
140
+ const ranges6 = [];
67
141
  let skipped = 0;
68
142
  for (const rawLine of text.split(/\r?\n/)) {
69
143
  const line = rawLine.trim();
@@ -72,11 +146,6 @@ export class IpEnrichmentDataset {
72
146
  const cells = line.split(",").map((c) => c.trim());
73
147
  if (cells[0].toLowerCase() === "network")
74
148
  continue; // header
75
- const r = parseCidr(cells[0]);
76
- if (!r) {
77
- skipped++;
78
- continue;
79
- }
80
149
  const data = {};
81
150
  if (cells[1])
82
151
  data.country = cells[1];
@@ -89,12 +158,31 @@ export class IpEnrichmentDataset {
89
158
  if (cells[5] !== undefined && cells[5] !== "") {
90
159
  data.hosting = ["true", "1", "yes"].includes(cells[5].toLowerCase());
91
160
  }
92
- ranges.push({ start: r.start, end: r.end, prefix: r.prefix, data });
161
+ // Route by family: a ':' in the network cell means IPv6.
162
+ if (cells[0].includes(":")) {
163
+ const r6 = parseCidr6(cells[0]);
164
+ if (!r6) {
165
+ skipped++;
166
+ continue;
167
+ }
168
+ ranges6.push({ start: r6.start, end: r6.end, prefix: r6.prefix, data });
169
+ }
170
+ else {
171
+ const r = parseCidr(cells[0]);
172
+ if (!r) {
173
+ skipped++;
174
+ continue;
175
+ }
176
+ ranges.push({ start: r.start, end: r.end, prefix: r.prefix, data });
177
+ }
93
178
  }
94
- return new IpEnrichmentDataset(ranges, skipped);
179
+ return new IpEnrichmentDataset(ranges, ranges6, skipped);
95
180
  }
96
- /** Look up an IPv4 string. Returns the most specific matching row, or null. */
181
+ /** Look up an IPv4 or IPv6 string. Returns the most specific matching row, or null. */
97
182
  lookup(ip) {
183
+ return ip.includes(":") ? this.lookup6(ip) : this.lookup4(ip);
184
+ }
185
+ lookup4(ip) {
98
186
  const n = ipv4ToInt(ip);
99
187
  if (n === null)
100
188
  return null;
@@ -110,4 +198,17 @@ export class IpEnrichmentDataset {
110
198
  }
111
199
  return best ? { ...best.data } : null;
112
200
  }
201
+ lookup6(ip) {
202
+ const n = ipv6ToBigInt(ip);
203
+ if (n === null)
204
+ return null;
205
+ let best = null;
206
+ for (const r of this.ranges6) {
207
+ if (r.start > n)
208
+ break; // sorted by start asc
209
+ if (n <= r.end && (best === null || r.prefix > best.prefix))
210
+ best = r;
211
+ }
212
+ return best ? { ...best.data } : null;
213
+ }
113
214
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { ipv4ToInt, parseCidr, IpEnrichmentDataset } from "./ip-dataset.js";
3
+ import { ipv4ToInt, parseCidr, ipv6ToBigInt, parseCidr6, IpEnrichmentDataset } from "./ip-dataset.js";
4
4
  describe("ipv4ToInt", () => {
5
5
  it("parses valid IPv4", () => {
6
6
  assert.equal(ipv4ToInt("0.0.0.0"), 0);
@@ -49,13 +49,13 @@ describe("IpEnrichmentDataset.fromCsv + lookup", () => {
49
49
  "10.0.0.0/8,US,,AS100,Example Cloud,true",
50
50
  "10.1.2.0/24,US,Ashburn,AS100,Example Cloud Edge,true",
51
51
  "203.0.113.5,DE,Berlin,AS3320,Example ISP,false",
52
- "2001:db8::/32,XX,,,,", // IPv6 skipped
52
+ "2001:db8::/32,XX,,,,", // IPv6 now parsed, not skipped
53
53
  "garbage-row",
54
54
  ].join("\n");
55
- it("parses rows, skips header/comments/blank, counts skipped", () => {
55
+ it("parses rows (v4 + v6), skips header/comments/blank, counts skipped", () => {
56
56
  const ds = IpEnrichmentDataset.fromCsv(csv);
57
- assert.equal(ds.size, 3); // 3 valid IPv4 rows
58
- assert.equal(ds.skipped, 2); // IPv6 + garbage
57
+ assert.equal(ds.size, 4); // 3 IPv4 + 1 IPv6 row
58
+ assert.equal(ds.skipped, 1); // only the garbage row
59
59
  });
60
60
  it("returns the most specific (longest-prefix) match", () => {
61
61
  const ds = IpEnrichmentDataset.fromCsv(csv);
@@ -83,3 +83,79 @@ describe("IpEnrichmentDataset.fromCsv + lookup", () => {
83
83
  assert.equal(ds.lookup("not-an-ip"), null);
84
84
  });
85
85
  });
86
+ describe("ipv6ToBigInt", () => {
87
+ it("parses a full address", () => {
88
+ assert.equal(ipv6ToBigInt("2001:0db8:0000:0000:0000:0000:0000:0001"), 0x20010db8000000000000000000000001n);
89
+ });
90
+ it("expands :: zero-compression", () => {
91
+ assert.equal(ipv6ToBigInt("2001:db8::1"), 0x20010db8000000000000000000000001n);
92
+ assert.equal(ipv6ToBigInt("::1"), 1n);
93
+ assert.equal(ipv6ToBigInt("::"), 0n);
94
+ assert.equal(ipv6ToBigInt("ff02::"), 0xff020000000000000000000000000000n);
95
+ });
96
+ it("parses an IPv4-mapped tail", () => {
97
+ // ::ffff:1.2.3.4 → the v4 lives in the low 32 bits with ffff above it
98
+ assert.equal(ipv6ToBigInt("::ffff:1.2.3.4"), 0x00000000000000000000ffff01020304n);
99
+ });
100
+ it("rejects malformed input", () => {
101
+ assert.equal(ipv6ToBigInt("2001:db8::1::2"), null); // two ::
102
+ assert.equal(ipv6ToBigInt("gggg::1"), null);
103
+ assert.equal(ipv6ToBigInt("1.2.3.4"), null); // v4 is not v6
104
+ assert.equal(ipv6ToBigInt("12345::"), null); // hextet too long
105
+ assert.equal(ipv6ToBigInt(""), null);
106
+ });
107
+ });
108
+ describe("parseCidr6", () => {
109
+ it("parses a /32 to its inclusive range", () => {
110
+ const r = parseCidr6("2001:db8::/32");
111
+ assert.equal(r?.prefix, 32);
112
+ assert.equal(r?.start, 0x20010db8000000000000000000000000n);
113
+ assert.equal(r?.end, 0x20010db8ffffffffffffffffffffffffn);
114
+ });
115
+ it("treats a bare address as /128", () => {
116
+ const r = parseCidr6("2001:db8::1");
117
+ assert.equal(r?.prefix, 128);
118
+ assert.equal(r?.start, r?.end);
119
+ });
120
+ it("handles /0 (whole space)", () => {
121
+ const r = parseCidr6("::/0");
122
+ assert.equal(r?.start, 0n);
123
+ assert.equal(r?.end, (1n << 128n) - 1n);
124
+ });
125
+ it("rejects bad prefixes / addresses", () => {
126
+ assert.equal(parseCidr6("2001:db8::/129"), null);
127
+ assert.equal(parseCidr6("nope::/32"), null);
128
+ });
129
+ });
130
+ describe("IpEnrichmentDataset IPv6 lookup", () => {
131
+ const csv = [
132
+ "network,country,city,asn,org,hosting",
133
+ "2001:db8::/32,US,,AS100,Example Cloud,true",
134
+ "2001:db8:1::/48,US,Ashburn,AS100,Example Cloud Edge,true",
135
+ "2606:4700::/32,US,,AS13335,Example CDN,true",
136
+ "10.0.0.0/8,DE,Berlin,AS3320,Example ISP,false", // v4 row alongside
137
+ ].join("\n");
138
+ it("returns the most specific v6 match", () => {
139
+ const ds = IpEnrichmentDataset.fromCsv(csv);
140
+ const hit = ds.lookup("2001:db8:1::abcd"); // inside both /32 and /48
141
+ assert.equal(hit?.city, "Ashburn");
142
+ assert.equal(hit?.org, "Example Cloud Edge");
143
+ });
144
+ it("falls back to the broader v6 range", () => {
145
+ const ds = IpEnrichmentDataset.fromCsv(csv);
146
+ const hit = ds.lookup("2001:db8:9::1"); // only in /32
147
+ assert.equal(hit?.asn, "AS100");
148
+ assert.equal(hit?.city, undefined);
149
+ });
150
+ it("keeps v4 and v6 lookups independent", () => {
151
+ const ds = IpEnrichmentDataset.fromCsv(csv);
152
+ assert.equal(ds.lookup("10.1.2.3")?.country, "DE"); // v4 path
153
+ assert.equal(ds.lookup("2606:4700::1")?.org, "Example CDN"); // v6 path
154
+ assert.equal(ds.lookup("2607:f8b0::1"), null); // unmatched v6
155
+ });
156
+ it("normalises an IPv4-mapped query against a v4 row? no — :: form hits v6 table only", () => {
157
+ const ds = IpEnrichmentDataset.fromCsv(csv);
158
+ // A ':'-bearing query goes to the v6 table; it won't match the 10.0.0.0/8 v4 row.
159
+ assert.equal(ds.lookup("::ffff:10.1.2.3"), null);
160
+ });
161
+ });
package/dist/index.js CHANGED
@@ -522,7 +522,7 @@ async function main() {
522
522
  "Fetch the raw time-series for ONE metric of ONE service over a look-back window, returned together with pre-computed summary statistics.",
523
523
  "When to use: when you need the actual numeric values or the trend of a known metric. For a 'is this service OK?' verdict use `get_service_health`; to find which services are misbehaving use `detect_anomalies`.",
524
524
  "Prerequisites: get the exact service name from `list_services` and choose a metric from the list at the end of this description.",
525
- "Behavior: read-only, no side effects. Returns an ordered array of {timestamp, value} points plus a summary {current, average, min, max, trend}. With `groupBy` set, returns one labelled series per distinct label value under `groups` instead of a single aggregated series. Units depend on the metric (e.g. CPU as %, latency as ms, rates as per-second). An unknown service/metric or an unreachable backend yields a structured explanatory error, never an exception.",
525
+ "Behavior: read-only, no side effects. Returns an ordered array of {timestamp, value} points plus a summary {current, average, min, max, trend}. When no series matched (e.g. a logs-only service has no such metric), `values` is empty and `summary` is `null` (not all-zeros) with a `note` — absent data is not a real zero reading. With `groupBy` set, returns one labelled series per distinct label value under `groups` instead of a single aggregated series. Units depend on the metric (e.g. CPU as %, latency as ms, rates as per-second). An unknown service/metric or an unreachable backend yields a structured explanatory error, never an exception.",
526
526
  `Available metrics: ${metricsList}`,
527
527
  ].join(" "), {
528
528
  service: z
@@ -555,7 +555,7 @@ async function main() {
555
555
  .describe("Optional escape hatch: a verbatim PromQL expression, run as-is over the range — for ad-hoc queries the curated `metric` catalog can't express (any series, any function, broken down by any label). When set, `metric`/`service`/`groupBy`/`labels` are ignored. DISABLED by default; the operator must enable the raw-query capability (OMCP_RAW_QUERY=on) or the call is refused. Still tenant-scoped and source-allow-listed."),
556
556
  }, { title: "Query Metrics", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async (args) => {
557
557
  await enforceEntitledAccess(ctx, { tool: "query_metrics", source: args?.source, service: args?.service });
558
- const result = await withToolMetrics("query_metrics", () => queryMetricsHandler(registry, args, ctx, { allowRawQuery: RAW_QUERY_ENABLED }));
558
+ const result = await withToolMetrics("query_metrics", () => queryMetricsHandler(registry, args, ctx, { allowRawQuery: RAW_QUERY_ENABLED || ctx.allowRawQuery === true }));
559
559
  return chargeTokenBudget(result, ctx, "query_metrics");
560
560
  });
561
561
  registerTool("query_logs", [
@@ -623,7 +623,7 @@ async function main() {
623
623
  .describe("Optional escape hatch: a verbatim LogQL log query, run as-is — for selectors/pipelines the curated params can't express. When set, `service`/`labels`/`level`/`query` are ignored and it is mutually exclusive with `aggregate` (express aggregation in the LogQL itself). DISABLED by default; the operator must enable the raw-query capability (OMCP_RAW_QUERY=on) or the call is refused. Redaction still applies to the returned log lines."),
624
624
  }, { title: "Query Logs", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async (args) => {
625
625
  await enforceEntitledAccess(ctx, { tool: "query_logs", source: args?.source, service: args?.service });
626
- const result = await withToolMetrics("query_logs", () => queryLogsHandler(registry, args, ctx, { allowRawQuery: RAW_QUERY_ENABLED }));
626
+ const result = await withToolMetrics("query_logs", () => queryLogsHandler(registry, args, ctx, { allowRawQuery: RAW_QUERY_ENABLED || ctx.allowRawQuery === true }));
627
627
  // Redact PII / secrets from the log payload before it crosses the
628
628
  // MCP boundary into the agent's context. Per-call bypass kicks in
629
629
  // only when BOTH (a) the credential is OMCP_KEY_BYPASS_REDACTION
@@ -3367,6 +3367,7 @@ async function main() {
3367
3367
  }
3368
3368
  return principalContext(cred.name, cred.allowedSources, {
3369
3369
  allowBypassRedaction: cred.bypassRedaction,
3370
+ allowRawQuery: cred.allowRawQuery,
3370
3371
  tenant: cred.tenant,
3371
3372
  allowedTools,
3372
3373
  });
@@ -3607,6 +3608,7 @@ async function main() {
3607
3608
  return {
3608
3609
  ctx: principalContext(cred.name, cred.allowedSources, {
3609
3610
  allowBypassRedaction: cred.bypassRedaction,
3611
+ allowRawQuery: cred.allowRawQuery,
3610
3612
  tenant: cred.tenant,
3611
3613
  allowedTools,
3612
3614
  }),
@@ -1,4 +1,4 @@
1
- import { ipv4ToInt } from "../enrich/ip-dataset.js";
1
+ import { ipv4ToInt, ipv6ToBigInt } from "../enrich/ip-dataset.js";
2
2
  import { defaultContext } from "../context.js";
3
3
  import { errorResponse } from "./validation.js";
4
4
  // enrich_ips (issue #415 Gap B): resolve a batch of IPs to geo / ASN / org /
@@ -7,9 +7,13 @@ import { errorResponse } from "./validation.js";
7
7
  // when no dataset is configured.
8
8
  export const enrichIpsDefinition = {
9
9
  name: "enrich_ips",
10
- description: "Resolve a batch of IPv4 addresses to geo (country/city), ASN/org, and a hosting/proxy flag from a local offline dataset. Use this to answer 'where are these visitors from / which are bots or datacenter IPs' without an out-of-band geo API call. Requires the operator to have configured an offline dataset (OMCP_IP_ENRICH_FILE); returns a clear notice otherwise.",
10
+ description: "Resolve a batch of IPv4 or IPv6 addresses to geo (country/city), ASN/org, and a hosting/proxy flag from a local offline dataset. Use this to answer 'where are these visitors from / which are bots or datacenter IPs' without an out-of-band geo API call. Requires the operator to have configured an offline dataset (OMCP_IP_ENRICH_FILE); returns a clear notice otherwise.",
11
11
  };
12
12
  const MAX_IPS = 1000;
13
+ /** A string is a valid IP if it parses as either IPv4 or IPv6. */
14
+ function isValidIp(ip) {
15
+ return ipv4ToInt(ip) !== null || ipv6ToBigInt(ip) !== null;
16
+ }
13
17
  export function enrichIpsHandler(dataset, args,
14
18
  // The RequestContext seam — enrich_ips doesn't scope by tenant today (the
15
19
  // dataset is a single process-wide table), but every tool handler threads
@@ -22,7 +26,7 @@ _ctx = defaultContext()) {
22
26
  }
23
27
  const ips = args.ips;
24
28
  if (!Array.isArray(ips) || ips.length === 0) {
25
- return errorResponse("`ips` must be a non-empty array of IPv4 address strings.");
29
+ return errorResponse("`ips` must be a non-empty array of IPv4 or IPv6 address strings.");
26
30
  }
27
31
  if (ips.length > MAX_IPS) {
28
32
  return errorResponse(`Too many IPs (${ips.length}); max ${MAX_IPS} per call.`);
@@ -31,7 +35,7 @@ _ctx = defaultContext()) {
31
35
  let invalid = 0;
32
36
  let matched = 0;
33
37
  for (const ip of ips) {
34
- if (typeof ip !== "string" || ipv4ToInt(ip) === null) {
38
+ if (typeof ip !== "string" || !isValidIp(ip)) {
35
39
  invalid++;
36
40
  results.push({ ip: String(ip), found: false });
37
41
  continue;
@@ -35,4 +35,15 @@ describe("enrichIpsHandler (R6, issue #415 Gap B)", () => {
35
35
  assert.deepEqual(out.summary, { total: 3, matched: 1, unmatched: 1, invalid: 1 });
36
36
  assert.equal(out.datasetSize, 2);
37
37
  });
38
+ it("accepts IPv6 inputs and enriches them (not counted invalid)", () => {
39
+ const ds6 = IpEnrichmentDataset.fromCsv(["2001:db8::/32,US,,AS14618,Example Cloud,true", "1.2.3.0/24,DE,Berlin,AS3320,Example ISP,false"].join("\n"));
40
+ const out = parse(enrichIpsHandler(ds6, { ips: ["2001:db8::1", "2606:4700::1", "1.2.3.9"] }));
41
+ const v6hit = out.results.find((r) => r.ip === "2001:db8::1");
42
+ assert.equal(v6hit.found, true);
43
+ assert.equal(v6hit.org, "Example Cloud");
44
+ const v6miss = out.results.find((r) => r.ip === "2606:4700::1");
45
+ assert.equal(v6miss.found, false);
46
+ // A valid-but-unmatched IPv6 is "unmatched", NOT "invalid".
47
+ assert.deepEqual(out.summary, { total: 3, matched: 2, unmatched: 1, invalid: 0 });
48
+ });
38
49
  });
@@ -60,16 +60,35 @@ export async function generatePostmortemHandler(registry, args, ctx = defaultCon
60
60
  traces,
61
61
  logHighlights,
62
62
  });
63
+ // Which primitives actually carried data. A post-mortem synthesised from
64
+ // ZERO signal still renders a full, authoritative-looking document — an
65
+ // on-call could paste it into a ticket as if it were a real finding. Make
66
+ // the coverage explicit so an empty report is self-labelling (the
67
+ // "absent ≠ zero" class, cf. #453/#462).
68
+ const coverage = {
69
+ anomalies: anomalies.length > 0,
70
+ traces: traces.length > 0,
71
+ topology: blastRadius.nodes.length > 0,
72
+ logs: logHighlights.length > 0,
73
+ };
74
+ const anySignal = Object.values(coverage).some(Boolean);
75
+ const reportWithCoverage = { ...report, coverage, builtFromSignal: anySignal };
63
76
  if ((args.format || "markdown").toLowerCase() === "json") {
64
77
  return {
65
- content: [{ type: "text", text: JSON.stringify(report) }],
78
+ content: [{ type: "text", text: JSON.stringify(reportWithCoverage) }],
66
79
  isError: false,
67
80
  };
68
81
  }
69
- // Default: return the markdown body. The structured sections live
70
- // in JSON if the caller asked for them.
82
+ // Default: return the markdown body. When there was no signal at all, lead
83
+ // with a banner so the document isn't mistaken for a real finding.
84
+ const banner = anySignal
85
+ ? ""
86
+ : "> ⚠️ **No signal in this window.** This report was built from zero anomalies, traces, " +
87
+ "topology, and log highlights. Either the window was genuinely clean, or the relevant " +
88
+ "backends aren't configured/writing (anomaly-history sink, traces connector, topology " +
89
+ "connector). Verify coverage before relying on this.\n\n";
71
90
  return {
72
- content: [{ type: "text", text: report.markdown }],
91
+ content: [{ type: "text", text: banner + report.markdown }],
73
92
  isError: false,
74
93
  };
75
94
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ConnectorRegistry } from "../connectors/registry.js";
4
+ import { generatePostmortemHandler } from "./generate-postmortem.js";
5
+ // R5 — a post-mortem built from ZERO signal (no anomaly/traces/topology/log
6
+ // backend) must label itself, not render as an authoritative finding.
7
+ describe("generatePostmortemHandler — no-signal honesty (R5)", () => {
8
+ it("markdown leads with a 'no signal' banner when nothing was found", async () => {
9
+ const reg = new ConnectorRegistry(); // no backends → every primitive empty
10
+ const out = await generatePostmortemHandler(reg, { service: "ghost-service", duration: "1h" });
11
+ const md = out.content[0].text;
12
+ assert.match(md, /No signal in this window/i);
13
+ assert.match(md, /backends aren't configured/i);
14
+ });
15
+ it("json form carries explicit coverage flags + builtFromSignal=false", async () => {
16
+ const reg = new ConnectorRegistry();
17
+ const out = await generatePostmortemHandler(reg, { service: "ghost-service", duration: "1h", format: "json" });
18
+ const report = JSON.parse(out.content[0].text);
19
+ assert.equal(report.builtFromSignal, false);
20
+ assert.deepEqual(report.coverage, { anomalies: false, traces: false, topology: false, logs: false });
21
+ });
22
+ });
@@ -35,24 +35,24 @@ export async function getServiceHealthHandler(registry, args, ctx = defaultConte
35
35
  continue;
36
36
  try {
37
37
  const cpuResult = await connector.queryMetrics({ service: args.service, metric: "cpu", duration: "5m" });
38
- if (cpuResult.values.length > 0) {
38
+ if (cpuResult.summary) {
39
39
  cpu = cpuResult.summary.current;
40
40
  metricsHadData = true;
41
41
  }
42
42
  checkAnomaly(cpuResult.values.map(v => v.value), "cpu", args.service, connector.name, anomalies);
43
43
  const memResult = await connector.queryMetrics({ service: args.service, metric: "memory", duration: "5m" });
44
- if (memResult.values.length > 0) {
44
+ if (memResult.summary) {
45
45
  memory = memResult.summary.current / 1_000_000;
46
46
  metricsHadData = true;
47
47
  } // MB for display
48
48
  const errResult = await connector.queryMetrics({ service: args.service, metric: "error_rate", duration: "5m" });
49
- if (errResult.values.length > 0) {
49
+ if (errResult.summary) {
50
50
  errorRate = errResult.summary.current;
51
51
  metricsHadData = true;
52
52
  }
53
53
  checkAnomaly(errResult.values.map(v => v.value), "error_rate", args.service, connector.name, anomalies);
54
54
  const latResult = await connector.queryMetrics({ service: args.service, metric: "latency_p99", duration: "5m" });
55
- if (latResult.values.length > 0) {
55
+ if (latResult.summary) {
56
56
  latencyP99 = latResult.summary.current;
57
57
  metricsHadData = true;
58
58
  }
@@ -152,6 +152,36 @@ describe("listServicesHandler", () => {
152
152
  const data = JSON.parse(result.content[0].text);
153
153
  assert.equal(data.total, 0);
154
154
  });
155
+ it("no backends configured → note distinguishes 'none configured' from 'zero services' (R5)", async () => {
156
+ const data = JSON.parse((await listServicesHandler(new ConnectorRegistry(), {})).content[0].text);
157
+ assert.equal(data.total, 0);
158
+ assert.match(data.note, /No observability backends are configured/i);
159
+ });
160
+ it("all sources fail discovery → note + not a silent 'zero services' (R5)", async () => {
161
+ const reg = createRegistryWithMocks([
162
+ createMockConnector({ name: "prom1", type: "prometheus", signalType: "metrics", listServices: async () => { throw new Error("down"); } }),
163
+ ]);
164
+ const data = JSON.parse((await listServicesHandler(reg, {})).content[0].text);
165
+ assert.equal(data.total, 0);
166
+ assert.match(data.note, /failed on all 1 configured source/i);
167
+ });
168
+ it("partial source failure → result is flagged partial with the failed source (R5)", async () => {
169
+ const reg = createRegistryWithMocks([
170
+ createMockConnector({ name: "prom1", type: "prometheus", signalType: "metrics", listServices: async () => { throw new Error("down"); } }),
171
+ createMockConnector({ name: "loki1", type: "loki", signalType: "logs", listServices: async () => [{ name: "order-service", source: "loki1", signalType: "logs" }] }),
172
+ ]);
173
+ const data = JSON.parse((await listServicesHandler(reg, {})).content[0].text);
174
+ assert.equal(data.total, 1);
175
+ assert.equal(data.partial, true);
176
+ assert.deepEqual(data.failedSources, ["prom1"]);
177
+ });
178
+ });
179
+ describe("listSourcesHandler — no-data honesty (R5)", () => {
180
+ it("no backends configured → explanatory note, not a bare empty list", async () => {
181
+ const data = JSON.parse((await listSourcesHandler(new ConnectorRegistry())).content[0].text);
182
+ assert.deepEqual(data.sources, []);
183
+ assert.match(data.note, /No observability backends are configured/i);
184
+ });
155
185
  });
156
186
  describe("detectAnomaliesHandler — fleet coverage (issue #453B)", () => {
157
187
  it("fleet scan includes log-only services and reports per-service coverage", async () => {
@@ -291,7 +321,9 @@ describe("getServiceHealthHandler — honest no-data / not-found (issue #453)",
291
321
  const emptySeries = () => ({
292
322
  source: "prom1", service: "x", metric: "x", unit: "",
293
323
  values: [],
294
- summary: { current: 0, average: 0, min: 0, max: 0, trend: "stable" },
324
+ // No data null summary (matches the real connector after #462), so the
325
+ // health handler treats it as no-coverage, not a real zero reading.
326
+ summary: null,
295
327
  });
296
328
  function metricsConnector(known) {
297
329
  return {
@@ -16,6 +16,10 @@ export async function listServicesHandler(registry, args, ctx = defaultContext()
16
16
  // Tenant-scoped: only consult sources the caller can see.
17
17
  const connectors = registry.getByTenant(ctx.tenant);
18
18
  const allServices = [];
19
+ // Track per-connector discovery failures so an empty result isn't silently
20
+ // partial — a down source must not make half the fleet vanish without a
21
+ // signal (the "absent ≠ zero" class, cf. #453).
22
+ const failedSources = [];
19
23
  for (const connector of connectors) {
20
24
  try {
21
25
  const services = await connector.listServices();
@@ -23,6 +27,7 @@ export async function listServicesHandler(registry, args, ctx = defaultContext()
23
27
  }
24
28
  catch (err) {
25
29
  console.error(`Failed to list services from ${connector.name}:`, err);
30
+ failedSources.push(connector.name);
26
31
  }
27
32
  }
28
33
  // Deduplicate by name, merge signal types. Carry per-service `labels`
@@ -54,11 +59,33 @@ export async function listServicesHandler(registry, args, ctx = defaultContext()
54
59
  const f = args.filter.toLowerCase();
55
60
  services = services.filter((s) => s.name.toLowerCase().includes(f));
56
61
  }
62
+ // An empty result is ambiguous — say *why* so an agent doesn't read it as
63
+ // "this environment has no services". Distinguish: no backends configured,
64
+ // discovery failed on some/all sources, or genuinely none discovered.
65
+ let note;
66
+ if (connectors.length === 0) {
67
+ note = "No observability backends are configured for this tenant — add one via the Sources tab or config/sources.yaml. This is not 'zero services'.";
68
+ }
69
+ else if (failedSources.length === connectors.length) {
70
+ note = `Service discovery failed on all ${connectors.length} configured source(s) (${failedSources.join(", ")}) — the empty result is an error, not 'zero services'. Check source health via list_sources.`;
71
+ }
72
+ else if (services.length === 0 && !args.filter) {
73
+ note = "No services discovered — the configured backend(s) returned none in this tenant.";
74
+ }
57
75
  return {
58
76
  content: [
59
77
  {
60
78
  type: "text",
61
- text: JSON.stringify({ services, total: services.length }, null, 2),
79
+ text: JSON.stringify({
80
+ services,
81
+ total: services.length,
82
+ // Only present when some (but not all) sources failed — a partial
83
+ // result the caller should know is incomplete.
84
+ ...(failedSources.length > 0 && failedSources.length < connectors.length
85
+ ? { partial: true, failedSources }
86
+ : {}),
87
+ ...(note ? { note } : {}),
88
+ }, null, 2),
62
89
  },
63
90
  ],
64
91
  };
@@ -21,11 +21,17 @@ export async function listSourcesHandler(registry, ctx = defaultContext()) {
21
21
  status: healthResults[c.name]?.status || "unknown",
22
22
  latencyMs: healthResults[c.name]?.latencyMs,
23
23
  }));
24
+ // An empty list is ambiguous on its own — name the cause so an agent
25
+ // doesn't read it as a transient/permission blip (the "absent ≠ zero"
26
+ // class). When nothing is configured, say so explicitly.
27
+ const note = sources.length === 0
28
+ ? "No observability backends are configured for this tenant. Add one via the Sources tab or config/sources.yaml — this is 'none configured', not a query error."
29
+ : undefined;
24
30
  return {
25
31
  content: [
26
32
  {
27
33
  type: "text",
28
- text: JSON.stringify({ sources }, null, 2),
34
+ text: JSON.stringify({ sources, ...(note ? { note } : {}) }, null, 2),
29
35
  },
30
36
  ],
31
37
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ConnectorRegistry } from "../connectors/registry.js";
4
+ import { queryLogsHandler } from "./query-logs.js";
5
+ // Inject a mock connector into the registry's internal maps.
6
+ function regWith(mock) {
7
+ const reg = new ConnectorRegistry();
8
+ reg.connectors.set(mock.name, mock);
9
+ reg.sourceConfigs.set(mock.name, { name: mock.name, type: mock.type, url: "http://mock", enabled: true });
10
+ return reg;
11
+ }
12
+ describe("queryLogsHandler error response shape (issue #452)", () => {
13
+ it("a failing query reports `window` (the look-back), not `duration` (read as wall-clock)", async () => {
14
+ // Mirrors the raw_query fail-fast case: the connector throws, the handler
15
+ // returns a structured error. The look-back window must be labelled
16
+ // `window`, never `duration` — an agent reading duration:"5m" on a <1s
17
+ // failure thinks it hung (the very symptom the fail-fast fix removed).
18
+ const mock = {
19
+ connect: async () => { }, disconnect: async () => { },
20
+ healthCheck: async () => ({ status: "up", latencyMs: 1 }),
21
+ getDefaultMetrics: () => [], getMetrics: () => [],
22
+ listServices: async () => [],
23
+ name: "loki1", type: "loki", signalType: "logs",
24
+ queryLogs: async () => { throw new Error("query_logs raw_query returned a 'matrix' result, but query_logs handles log lines (streams) only."); },
25
+ };
26
+ const result = await queryLogsHandler(regWith(mock), { raw_query: "sum(count_over_time({service_name=\"x\"} | json [1h]))", duration: "1h" }, undefined, { allowRawQuery: true });
27
+ const data = JSON.parse(result.content[0].text);
28
+ assert.ok(data.error, "must be an error response");
29
+ assert.equal(data.window, "1h", "look-back must be reported as `window`");
30
+ assert.equal("duration" in data, false, "must NOT carry a `duration` field (misread as elapsed time)");
31
+ });
32
+ });
@@ -59,7 +59,7 @@ export async function queryLogsHandler(registry, args, ctx = defaultContext(), o
59
59
  return errorResponse(rawErr);
60
60
  const isRaw = !!args.raw_query;
61
61
  if (isRaw && !opts.allowRawQuery) {
62
- return errorResponse("raw_query is disabled. The operator must enable the raw-query capability (OMCP_RAW_QUERY=on) to run verbatim LogQL — it bypasses the curated log surface, so it is off by default.");
62
+ return errorResponse("raw_query is disabled. The operator must enable the raw-query capability (OMCP_RAW_QUERY=on globally, or per-credential via OMCP_KEY_RAW_QUERY) to run verbatim LogQL — it bypasses the curated log surface, so it is off by default.");
63
63
  }
64
64
  if (isRaw && args.aggregate) {
65
65
  return errorResponse("raw_query and aggregate are mutually exclusive — a raw LogQL query expresses its own aggregation.");
@@ -119,7 +119,8 @@ export async function queryLogsHandler(registry, args, ctx = defaultContext(), o
119
119
  }
120
120
  if (aggResults.length === 0) {
121
121
  return {
122
- content: [{ type: "text", text: JSON.stringify({ error: aggErrors.length ? `Aggregate failed: ${aggErrors.join("; ")}` : "No data returned", service: args.service, duration }) }],
122
+ // `window` = the requested look-back, not elapsed time (issue #452).
123
+ content: [{ type: "text", text: JSON.stringify({ error: aggErrors.length ? `Aggregate failed: ${aggErrors.join("; ")}` : "No data returned", service: args.service, window: duration }) }],
123
124
  isError: aggErrors.length > 0,
124
125
  };
125
126
  }
@@ -160,7 +161,9 @@ export async function queryLogsHandler(registry, args, ctx = defaultContext(), o
160
161
  text: JSON.stringify({
161
162
  error: errors.length > 0 ? `Query failed: ${errors.join("; ")}` : "No logs returned",
162
163
  service: args.service,
163
- duration,
164
+ // The requested look-back window, NOT elapsed wall-clock time. Named
165
+ // `window` so a fast failure isn't misread as a 5-minute hang (#452).
166
+ window: duration,
164
167
  }),
165
168
  },
166
169
  ],
@@ -50,7 +50,7 @@ export async function queryMetricsHandler(registry, args, ctx = defaultContext()
50
50
  return errorResponse(rawErr);
51
51
  const isRaw = !!args.raw_query;
52
52
  if (isRaw && !opts.allowRawQuery) {
53
- return errorResponse("raw_query is disabled. The operator must enable the raw-query capability (OMCP_RAW_QUERY=on) to run verbatim PromQL — it bypasses the curated metric surface, so it is off by default.");
53
+ return errorResponse("raw_query is disabled. The operator must enable the raw-query capability (OMCP_RAW_QUERY=on globally, or per-credential via OMCP_KEY_RAW_QUERY) to run verbatim PromQL — it bypasses the curated metric surface, so it is off by default.");
54
54
  }
55
55
  if (!isRaw) {
56
56
  if (!args.service)
@@ -171,6 +171,25 @@ export const getBlastRadiusDefinition = {
171
171
  };
172
172
  export async function getBlastRadiusHandler(registry, args, ctx = defaultContext()) {
173
173
  const agg = await aggregateTopology(registry, ctx.tenant);
174
+ // No topology-capable connector → the resource can't be found because there
175
+ // IS no graph, not because the name is wrong. Say so explicitly instead of a
176
+ // generic "not found" that misattributes the cause (the "absent ≠ zero"
177
+ // class, consistent with get_topology's empty-graph note).
178
+ if (agg.sources.length === 0) {
179
+ return {
180
+ isError: true,
181
+ content: [{
182
+ type: "text",
183
+ text: JSON.stringify({
184
+ resource: args.resource,
185
+ hosts: [],
186
+ note: "No topology-capable connector is configured, so there is no blast radius to compute. " +
187
+ "Add a topology source (the built-in `kubernetes` connector, or aws/gcp/istio/linkerd/consul) " +
188
+ "— a metrics/logs-only deployment has no topology graph. This is not 'resource not found'.",
189
+ }, null, 2),
190
+ }],
191
+ };
192
+ }
174
193
  const found = resolveResource(args.resource, agg.resources);
175
194
  if ("error" in found) {
176
195
  return {
@@ -212,6 +212,16 @@ describe("get_blast_radius tool", () => {
212
212
  const out = parseTool(result);
213
213
  assert.match(out.error, /No resource found/);
214
214
  });
215
+ it("no topology connector → explicit note, not a misleading 'resource not found' (R5)", async () => {
216
+ // An empty registry has no topology source. The cause is "no graph", not
217
+ // "wrong name" — the response must say so rather than blame the resource.
218
+ const reg = new ConnectorRegistry(new PluginLoader());
219
+ const result = await getBlastRadiusHandler(reg, { resource: "anything" });
220
+ assert.equal(result.isError, true);
221
+ const out = parseTool(result);
222
+ assert.match(out.note, /No topology-capable connector is configured/i);
223
+ assert.ok(!out.error || !/No resource found/.test(out.error), "must not misattribute to a not-found name");
224
+ });
215
225
  it("uses ownership root, not direct owner, when grouping co-tenants", async () => {
216
226
  // api-aaa is OWNED_BY a ReplicaSet which is OWNED_BY a Deployment.
217
227
  // The blast-radius should bucket api-aaa under the Deployment, not the RS.
package/dist/types.d.ts CHANGED
@@ -182,7 +182,8 @@ export interface MetricSummary {
182
182
  export interface MetricGroup {
183
183
  key: string;
184
184
  values: DataPoint[];
185
- summary: MetricSummary;
185
+ /** null when this group has no data points — absent ≠ a real zero reading. */
186
+ summary: MetricSummary | null;
186
187
  }
187
188
  export interface MetricResult {
188
189
  source: string;
@@ -190,12 +191,15 @@ export interface MetricResult {
190
191
  metric: string;
191
192
  unit: string;
192
193
  values: DataPoint[];
193
- summary: MetricSummary;
194
+ /** null when `values` is empty (no series matched this service/metric) — a
195
+ * no-data signal, not a confident all-zeros reading (issue #462). */
196
+ summary: MetricSummary | null;
194
197
  resolvedSeries?: string;
195
198
  resolvedLabel?: string;
196
199
  groupBy?: string;
197
200
  groups?: MetricGroup[];
198
201
  hint?: string;
202
+ note?: string;
199
203
  }
200
204
  export interface LogEntry {
201
205
  timestamp: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thotischner/observability-mcp",
3
- "version": "3.3.1",
3
+ "version": "3.4.0",
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",