@thotischner/observability-mcp 3.3.2 → 3.5.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,32 @@
1
+ export interface ScimFilter {
2
+ attr: string;
3
+ op: "eq";
4
+ /** Comparison value: a string, or a boolean for `active eq true`. */
5
+ value: string | boolean;
6
+ }
7
+ /** Parse a SCIM `filter` expression. Returns null for an empty/absent filter,
8
+ * or throws {unsupported:true} for a syntactically-valid filter we don't
9
+ * implement (a non-eq operator) so the caller can answer 400, not 200. */
10
+ export declare function parseScimFilter(raw: string | undefined): ScimFilter | null;
11
+ export interface ScimListResult<T> {
12
+ resources: T[];
13
+ totalResults: number;
14
+ startIndex: number;
15
+ itemsPerPage: number;
16
+ }
17
+ /**
18
+ * Apply a SCIM `eq` filter + startIndex/count pagination to a resource list.
19
+ * `filter`/`startIndex`/`count` are the raw query-string values.
20
+ *
21
+ * - filter: only `eq` (string, case-insensitive; or boolean for `active`).
22
+ * - startIndex: 1-based (SCIM); clamped to >= 1; default 1.
23
+ * - count: page size; clamped to [0, 1000]; absent → all remaining.
24
+ *
25
+ * Throws a {scimUnsupported:true} error for a non-eq filter so the route can
26
+ * return 400 rather than a misleading full list.
27
+ */
28
+ export declare function applyScimList<T extends Record<string, unknown>>(all: T[], q: {
29
+ filter?: string;
30
+ startIndex?: string;
31
+ count?: string;
32
+ }): ScimListResult<T>;
@@ -0,0 +1,73 @@
1
+ // SCIM 2.0 list query: filter + pagination (RFC 7644 §3.4.2).
2
+ //
3
+ // Identity providers (Entra, Okta) reconcile by issuing
4
+ // GET /Users?filter=userName eq "alice@example.com"
5
+ // and page large directories with startIndex/count. Without filter support a
6
+ // provider has to pull the whole list and match client-side — slow and, for
7
+ // Okta, a hard requirement. We support the `eq` operator on top-level string
8
+ // attributes (userName, displayName, externalId, id) plus `active eq true`,
9
+ // which covers what Entra/Okta actually send. Other operators are reported as
10
+ // unsupported (400) rather than silently returning everything — silence here
11
+ // would make a provider think "no match" when it means "not implemented".
12
+ /** Parse a SCIM `filter` expression. Returns null for an empty/absent filter,
13
+ * or throws {unsupported:true} for a syntactically-valid filter we don't
14
+ * implement (a non-eq operator) so the caller can answer 400, not 200. */
15
+ export function parseScimFilter(raw) {
16
+ if (!raw || !raw.trim())
17
+ return null;
18
+ const s = raw.trim();
19
+ // <attr> eq "value" | <attr> eq true|false
20
+ const m = /^([A-Za-z][\w.]*)\s+(eq)\s+(?:"([^"]*)"|(true|false))$/i.exec(s);
21
+ if (!m) {
22
+ // Either an unsupported operator (co, sw, gt, …) or malformed. Signal
23
+ // unsupported so the route returns 400 instead of an all-rows 200.
24
+ const err = new Error(`Unsupported or malformed SCIM filter: ${raw}`);
25
+ err.scimUnsupported = true;
26
+ throw err;
27
+ }
28
+ const attr = m[1];
29
+ const value = m[3] !== undefined ? m[3] : m[4].toLowerCase() === "true";
30
+ return { attr, op: "eq", value };
31
+ }
32
+ /** Read a top-level attribute off a SCIM resource for `eq` comparison.
33
+ * Only flat attributes are supported (userName/displayName/externalId/id/
34
+ * active) — nested paths (name.familyName) aren't part of the eq surface. */
35
+ function attrValue(resource, attr) {
36
+ // Case-insensitive attribute match per the SCIM spec (attribute names are
37
+ // case-insensitive); providers send `userName`, some send `username`.
38
+ const key = Object.keys(resource).find((k) => k.toLowerCase() === attr.toLowerCase());
39
+ return key ? resource[key] : undefined;
40
+ }
41
+ /**
42
+ * Apply a SCIM `eq` filter + startIndex/count pagination to a resource list.
43
+ * `filter`/`startIndex`/`count` are the raw query-string values.
44
+ *
45
+ * - filter: only `eq` (string, case-insensitive; or boolean for `active`).
46
+ * - startIndex: 1-based (SCIM); clamped to >= 1; default 1.
47
+ * - count: page size; clamped to [0, 1000]; absent → all remaining.
48
+ *
49
+ * Throws a {scimUnsupported:true} error for a non-eq filter so the route can
50
+ * return 400 rather than a misleading full list.
51
+ */
52
+ export function applyScimList(all, q) {
53
+ const filter = parseScimFilter(q.filter);
54
+ let filtered = all;
55
+ if (filter) {
56
+ filtered = all.filter((r) => {
57
+ const v = attrValue(r, filter.attr);
58
+ if (typeof filter.value === "boolean")
59
+ return Boolean(v) === filter.value;
60
+ // String eq is case-insensitive per the SCIM spec for these attrs.
61
+ return v !== undefined && String(v).toLowerCase() === filter.value.toLowerCase();
62
+ });
63
+ }
64
+ const total = filtered.length;
65
+ const startIndex = Math.max(1, Number.parseInt(q.startIndex ?? "1", 10) || 1);
66
+ const from = startIndex - 1;
67
+ const rawCount = q.count === undefined ? undefined : Number.parseInt(q.count, 10);
68
+ const count = rawCount === undefined || Number.isNaN(rawCount)
69
+ ? undefined
70
+ : Math.min(1000, Math.max(0, rawCount));
71
+ const page = count === undefined ? filtered.slice(from) : filtered.slice(from, from + count);
72
+ return { resources: page, totalResults: total, startIndex, itemsPerPage: page.length };
73
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseScimFilter, applyScimList } from "./query.js";
4
+ describe("parseScimFilter", () => {
5
+ it("returns null for absent/empty filter", () => {
6
+ assert.equal(parseScimFilter(undefined), null);
7
+ assert.equal(parseScimFilter(" "), null);
8
+ });
9
+ it("parses string eq (case-insensitive op)", () => {
10
+ assert.deepEqual(parseScimFilter('userName eq "alice@x.com"'), { attr: "userName", op: "eq", value: "alice@x.com" });
11
+ assert.deepEqual(parseScimFilter('displayName EQ "Admins"'), { attr: "displayName", op: "eq", value: "Admins" });
12
+ });
13
+ it("parses boolean eq for active", () => {
14
+ assert.deepEqual(parseScimFilter("active eq true"), { attr: "active", op: "eq", value: true });
15
+ assert.deepEqual(parseScimFilter("active eq false"), { attr: "active", op: "eq", value: false });
16
+ });
17
+ it("throws scimUnsupported for non-eq operators / malformed", () => {
18
+ for (const f of ['userName co "a"', 'userName sw "a"', 'userName pr', 'garbage']) {
19
+ assert.throws(() => parseScimFilter(f), (e) => e.scimUnsupported === true, `expected unsupported for: ${f}`);
20
+ }
21
+ });
22
+ });
23
+ describe("applyScimList", () => {
24
+ const users = [
25
+ { id: "1", userName: "alice@x.com", active: true },
26
+ { id: "2", userName: "bob@x.com", active: false },
27
+ { id: "3", userName: "carol@x.com", active: true },
28
+ ];
29
+ it("no filter, no pagination → all rows, correct envelope", () => {
30
+ const r = applyScimList(users, {});
31
+ assert.equal(r.totalResults, 3);
32
+ assert.equal(r.startIndex, 1);
33
+ assert.equal(r.itemsPerPage, 3);
34
+ assert.equal(r.resources.length, 3);
35
+ });
36
+ it("eq filter matches case-insensitively on the value", () => {
37
+ const r = applyScimList(users, { filter: 'userName eq "ALICE@X.COM"' });
38
+ assert.equal(r.totalResults, 1);
39
+ assert.equal(r.resources[0].id, "1");
40
+ });
41
+ it("eq filter with no match → empty, totalResults 0 (not all rows)", () => {
42
+ const r = applyScimList(users, { filter: 'userName eq "nobody@x.com"' });
43
+ assert.equal(r.totalResults, 0);
44
+ assert.equal(r.resources.length, 0);
45
+ });
46
+ it("boolean eq filters on active", () => {
47
+ assert.equal(applyScimList(users, { filter: "active eq true" }).totalResults, 2);
48
+ assert.equal(applyScimList(users, { filter: "active eq false" }).totalResults, 1);
49
+ });
50
+ it("pagination: startIndex + count slice; totalResults is the pre-page count", () => {
51
+ const r = applyScimList(users, { startIndex: "2", count: "1" });
52
+ assert.equal(r.totalResults, 3);
53
+ assert.equal(r.startIndex, 2);
54
+ assert.equal(r.itemsPerPage, 1);
55
+ assert.equal(r.resources[0].id, "2");
56
+ });
57
+ it("count=0 returns an empty page but the real totalResults", () => {
58
+ const r = applyScimList(users, { count: "0" });
59
+ assert.equal(r.totalResults, 3);
60
+ assert.equal(r.resources.length, 0);
61
+ });
62
+ it("filter + pagination compose", () => {
63
+ const r = applyScimList(users, { filter: "active eq true", startIndex: "2", count: "5" });
64
+ assert.equal(r.totalResults, 2); // alice + carol
65
+ assert.equal(r.resources.length, 1); // from index 2 of the 2 matches
66
+ assert.equal(r.resources[0].id, "3");
67
+ });
68
+ it("propagates the unsupported-filter error for the route to 400", () => {
69
+ assert.throws(() => applyScimList(users, { filter: 'userName co "x"' }), (e) => e.scimUnsupported === true);
70
+ });
71
+ });
@@ -4,7 +4,7 @@
4
4
  // GET /scim/v2/ServiceProviderConfig
5
5
  // GET /scim/v2/ResourceTypes
6
6
  // GET /scim/v2/Schemas
7
- // GET /scim/v2/Users list (no filter support yet)
7
+ // GET /scim/v2/Users list (filter: <attr> eq "x"; startIndex/count)
8
8
  // GET /scim/v2/Users/:id
9
9
  // POST /scim/v2/Users
10
10
  // PATCH /scim/v2/Users/:id minimal: replace top-level attrs
@@ -20,6 +20,7 @@
20
20
  import { timingSafeEqual } from "node:crypto";
21
21
  import { SCIM_SCHEMA_LIST_RESPONSE, SCIM_SCHEMA_USER, SCIM_SCHEMA_GROUP, scimError, } from "./types.js";
22
22
  import { ScimNotFoundError, ScimValidationError } from "./store.js";
23
+ import { applyScimList } from "./query.js";
23
24
  const constantTimeBearerMatch = (raw, expected) => {
24
25
  if (!raw)
25
26
  return false;
@@ -57,7 +58,7 @@ export function registerScimRoutes(app, deps) {
57
58
  documentationUri: "https://thotischner.github.io/observability-mcp/scim-provisioning/",
58
59
  patch: { supported: true },
59
60
  bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
60
- filter: { supported: false, maxResults: 200 },
61
+ filter: { supported: true, maxResults: 200 },
61
62
  changePassword: { supported: false },
62
63
  sort: { supported: false },
63
64
  etag: { supported: false },
@@ -108,14 +109,25 @@ export function registerScimRoutes(app, deps) {
108
109
  });
109
110
  });
110
111
  // ---- Users ----
111
- app.get("/scim/v2/Users", (_req, res) => {
112
+ app.get("/scim/v2/Users", (req, res) => {
112
113
  const users = store.listUsers().map((u) => withGroups(u, store));
114
+ let page;
115
+ try {
116
+ page = applyScimList(users, req.query);
117
+ }
118
+ catch (e) {
119
+ if (e.scimUnsupported) {
120
+ res.status(400).json(scimError(400, e.message));
121
+ return;
122
+ }
123
+ throw e;
124
+ }
113
125
  res.json({
114
126
  schemas: [SCIM_SCHEMA_LIST_RESPONSE],
115
- totalResults: users.length,
116
- itemsPerPage: users.length,
117
- startIndex: 1,
118
- Resources: users,
127
+ totalResults: page.totalResults,
128
+ itemsPerPage: page.itemsPerPage,
129
+ startIndex: page.startIndex,
130
+ Resources: page.resources,
119
131
  });
120
132
  });
121
133
  app.get("/scim/v2/Users/:id", (req, res) => {
@@ -157,14 +169,25 @@ export function registerScimRoutes(app, deps) {
157
169
  res.status(204).end();
158
170
  });
159
171
  // ---- Groups ----
160
- app.get("/scim/v2/Groups", (_req, res) => {
172
+ app.get("/scim/v2/Groups", (req, res) => {
161
173
  const groups = store.listGroups();
174
+ let page;
175
+ try {
176
+ page = applyScimList(groups, req.query);
177
+ }
178
+ catch (e) {
179
+ if (e.scimUnsupported) {
180
+ res.status(400).json(scimError(400, e.message));
181
+ return;
182
+ }
183
+ throw e;
184
+ }
162
185
  res.json({
163
186
  schemas: [SCIM_SCHEMA_LIST_RESPONSE],
164
- totalResults: groups.length,
165
- itemsPerPage: groups.length,
166
- startIndex: 1,
167
- Resources: groups,
187
+ totalResults: page.totalResults,
188
+ itemsPerPage: page.itemsPerPage,
189
+ startIndex: page.startIndex,
190
+ Resources: page.resources,
168
191
  });
169
192
  });
170
193
  app.get("/scim/v2/Groups/:id", (req, res) => {
@@ -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
  });
@@ -1,5 +1,7 @@
1
1
  import type { ConnectorRegistry } from "../connectors/registry.js";
2
2
  import { type RequestContext } from "../context.js";
3
+ /** Test seam: reset the cached template (so tests can vary the env). */
4
+ export declare function _resetPostmortemTemplateCache(): void;
3
5
  export declare const generatePostmortemDefinition: {
4
6
  name: "generate_postmortem";
5
7
  description: string;
@@ -7,9 +7,37 @@
7
7
  // The synthesizer is pure compute (see ./../postmortem/synthesizer);
8
8
  // this handler is just the orchestration: pull each upstream
9
9
  // primitive in parallel, hand the result to the synthesizer.
10
+ import { readFileSync } from "node:fs";
10
11
  import { defaultContext } from "../context.js";
11
12
  import { validateDuration, validateServiceName, errorResponse } from "./validation.js";
12
13
  import { synthesizePostmortem, } from "../postmortem/synthesizer.js";
14
+ // Optional operator-supplied report template (v3.3 candidate). Set
15
+ // OMCP_POSTMORTEM_TEMPLATE to a file of `{{token}}` placeholders to override
16
+ // the built-in layout; unset → the default report. Read once and cached; a
17
+ // read error falls back to the default (logged once) rather than failing the
18
+ // tool — a broken template must not break incident reporting.
19
+ let _templateCache;
20
+ function loadPostmortemTemplate(env = process.env) {
21
+ if (_templateCache !== undefined)
22
+ return _templateCache ?? undefined;
23
+ const path = env.OMCP_POSTMORTEM_TEMPLATE?.trim();
24
+ if (!path) {
25
+ _templateCache = null;
26
+ return undefined;
27
+ }
28
+ try {
29
+ _templateCache = readFileSync(path, "utf8");
30
+ }
31
+ catch (err) {
32
+ console.error("OMCP_POSTMORTEM_TEMPLATE unreadable, using the built-in layout:", err instanceof Error ? err.message : err);
33
+ _templateCache = null;
34
+ }
35
+ return _templateCache ?? undefined;
36
+ }
37
+ /** Test seam: reset the cached template (so tests can vary the env). */
38
+ export function _resetPostmortemTemplateCache() {
39
+ _templateCache = undefined;
40
+ }
13
41
  export const generatePostmortemDefinition = {
14
42
  name: "generate_postmortem",
15
43
  description: [
@@ -59,17 +87,37 @@ export async function generatePostmortemHandler(registry, args, ctx = defaultCon
59
87
  blastRadius,
60
88
  traces,
61
89
  logHighlights,
90
+ template: loadPostmortemTemplate(),
62
91
  });
92
+ // Which primitives actually carried data. A post-mortem synthesised from
93
+ // ZERO signal still renders a full, authoritative-looking document — an
94
+ // on-call could paste it into a ticket as if it were a real finding. Make
95
+ // the coverage explicit so an empty report is self-labelling (the
96
+ // "absent ≠ zero" class, cf. #453/#462).
97
+ const coverage = {
98
+ anomalies: anomalies.length > 0,
99
+ traces: traces.length > 0,
100
+ topology: blastRadius.nodes.length > 0,
101
+ logs: logHighlights.length > 0,
102
+ };
103
+ const anySignal = Object.values(coverage).some(Boolean);
104
+ const reportWithCoverage = { ...report, coverage, builtFromSignal: anySignal };
63
105
  if ((args.format || "markdown").toLowerCase() === "json") {
64
106
  return {
65
- content: [{ type: "text", text: JSON.stringify(report) }],
107
+ content: [{ type: "text", text: JSON.stringify(reportWithCoverage) }],
66
108
  isError: false,
67
109
  };
68
110
  }
69
- // Default: return the markdown body. The structured sections live
70
- // in JSON if the caller asked for them.
111
+ // Default: return the markdown body. When there was no signal at all, lead
112
+ // with a banner so the document isn't mistaken for a real finding.
113
+ const banner = anySignal
114
+ ? ""
115
+ : "> ⚠️ **No signal in this window.** This report was built from zero anomalies, traces, " +
116
+ "topology, and log highlights. Either the window was genuinely clean, or the relevant " +
117
+ "backends aren't configured/writing (anomaly-history sink, traces connector, topology " +
118
+ "connector). Verify coverage before relying on this.\n\n";
71
119
  return {
72
- content: [{ type: "text", text: report.markdown }],
120
+ content: [{ type: "text", text: banner + report.markdown }],
73
121
  isError: false,
74
122
  };
75
123
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { ConnectorRegistry } from "../connectors/registry.js";
7
+ import { generatePostmortemHandler, _resetPostmortemTemplateCache } from "./generate-postmortem.js";
8
+ // R5 — a post-mortem built from ZERO signal (no anomaly/traces/topology/log
9
+ // backend) must label itself, not render as an authoritative finding.
10
+ describe("generatePostmortemHandler — no-signal honesty (R5)", () => {
11
+ it("markdown leads with a 'no signal' banner when nothing was found", async () => {
12
+ const reg = new ConnectorRegistry(); // no backends → every primitive empty
13
+ const out = await generatePostmortemHandler(reg, { service: "ghost-service", duration: "1h" });
14
+ const md = out.content[0].text;
15
+ assert.match(md, /No signal in this window/i);
16
+ assert.match(md, /backends aren't configured/i);
17
+ });
18
+ it("json form carries explicit coverage flags + builtFromSignal=false", async () => {
19
+ const reg = new ConnectorRegistry();
20
+ const out = await generatePostmortemHandler(reg, { service: "ghost-service", duration: "1h", format: "json" });
21
+ const report = JSON.parse(out.content[0].text);
22
+ assert.equal(report.builtFromSignal, false);
23
+ assert.deepEqual(report.coverage, { anomalies: false, traces: false, topology: false, logs: false });
24
+ });
25
+ });
26
+ describe("generatePostmortemHandler — custom template via OMCP_POSTMORTEM_TEMPLATE (C3)", () => {
27
+ it("renders the operator's template when the env file is set; falls back when unset", async () => {
28
+ const dir = mkdtempSync(join(tmpdir(), "pm-tpl-"));
29
+ const file = join(dir, "tpl.md");
30
+ writeFileSync(file, "## Incident: {{service}} over {{window}}\n\n{{synopsis}}");
31
+ const prev = process.env.OMCP_POSTMORTEM_TEMPLATE;
32
+ try {
33
+ process.env.OMCP_POSTMORTEM_TEMPLATE = file;
34
+ _resetPostmortemTemplateCache();
35
+ const reg = new ConnectorRegistry();
36
+ const md = (await generatePostmortemHandler(reg, { service: "payment", duration: "2h" })).content[0].text;
37
+ // The custom template body is used (the no-signal honesty banner may
38
+ // still prepend it — that's deliberate and template-independent).
39
+ assert.match(md, /## Incident: payment over 2h/);
40
+ assert.ok(!md.includes("# Post-mortem —"), "custom template replaces the default layout");
41
+ // Unset → back to the built-in layout.
42
+ delete process.env.OMCP_POSTMORTEM_TEMPLATE;
43
+ _resetPostmortemTemplateCache();
44
+ const md2 = (await generatePostmortemHandler(reg, { service: "payment", duration: "2h" })).content[0].text;
45
+ assert.match(md2, /# Post-mortem — payment/);
46
+ }
47
+ finally {
48
+ if (prev === undefined)
49
+ delete process.env.OMCP_POSTMORTEM_TEMPLATE;
50
+ else
51
+ process.env.OMCP_POSTMORTEM_TEMPLATE = prev;
52
+ _resetPostmortemTemplateCache();
53
+ rmSync(dir, { recursive: true, force: true });
54
+ }
55
+ });
56
+ it("an unreadable template path falls back to the default layout, doesn't throw", async () => {
57
+ const prev = process.env.OMCP_POSTMORTEM_TEMPLATE;
58
+ try {
59
+ process.env.OMCP_POSTMORTEM_TEMPLATE = "/nonexistent/nope.md";
60
+ _resetPostmortemTemplateCache();
61
+ const md = (await generatePostmortemHandler(new ConnectorRegistry(), { service: "x", duration: "1h" })).content[0].text;
62
+ assert.match(md, /# Post-mortem — x/);
63
+ }
64
+ finally {
65
+ if (prev === undefined)
66
+ delete process.env.OMCP_POSTMORTEM_TEMPLATE;
67
+ else
68
+ process.env.OMCP_POSTMORTEM_TEMPLATE = prev;
69
+ _resetPostmortemTemplateCache();
70
+ }
71
+ });
72
+ });
@@ -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 () => {
@@ -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
  };
@@ -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.");
@@ -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 {