@thotischner/observability-mcp 3.4.0 → 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.
@@ -45,6 +45,13 @@ export interface PostmortemInput {
45
45
  traces: TraceSummary[];
46
46
  /** Optional log-error summary lines, e.g. ["payment-service: 412 5xx in window"]. */
47
47
  logHighlights?: string[];
48
+ /** Optional custom report template (issue: v3.3 candidate). When set, the
49
+ * markdown body is rendered by interpolating `{{placeholder}}` tokens
50
+ * instead of the built-in layout. Unset → the default report (unchanged).
51
+ * Available tokens: service, window, from, to, tenant, synopsis, timeline,
52
+ * blastRadius, signals, traces, logHighlights, followUps. An unknown token
53
+ * is left verbatim so a typo is visible rather than silently dropped. */
54
+ template?: string;
48
55
  }
49
56
  export interface PostmortemReport {
50
57
  service: string;
@@ -81,3 +88,20 @@ export interface PostmortemReport {
81
88
  /** Synthesise one report from already-fetched primitives. Pure
82
89
  * compute — no I/O. */
83
90
  export declare function synthesizePostmortem(input: PostmortemInput): PostmortemReport;
91
+ type RenderCtx = {
92
+ input: PostmortemInput;
93
+ timeline: PostmortemReport["sections"]["timeline"];
94
+ contributingSignals: PostmortemReport["sections"]["contributingSignals"];
95
+ peakNode: BlastRadiusNode | undefined;
96
+ peakScore: number;
97
+ errorTraces: number;
98
+ blastSize: number;
99
+ followUps: string[];
100
+ synopsis: string;
101
+ };
102
+ /** Build the `{{token}}` → markdown-block map for a custom template. */
103
+ export declare function buildTemplateVars(ctx: RenderCtx): Record<string, string>;
104
+ /** Interpolate `{{token}}` placeholders. An unknown token is left verbatim so
105
+ * a typo is visible in the output rather than silently producing a blank. */
106
+ export declare function renderTemplate(template: string, vars: Record<string, string>): string;
107
+ export {};
@@ -22,7 +22,7 @@ export function synthesizePostmortem(input) {
22
22
  const blastSize = input.blastRadius.nodes.length;
23
23
  const followUps = inferFollowUps(input, { peakScore, errorTraces, blastSize });
24
24
  const synopsis = synopsisFor(input, peakScore, errorTraces, blastSize);
25
- const markdown = renderMarkdown({
25
+ const renderCtx = {
26
26
  input,
27
27
  timeline,
28
28
  contributingSignals,
@@ -32,7 +32,12 @@ export function synthesizePostmortem(input) {
32
32
  blastSize,
33
33
  followUps,
34
34
  synopsis,
35
- });
35
+ };
36
+ // Default layout unless the operator supplied a custom template — the
37
+ // default path is byte-for-byte unchanged.
38
+ const markdown = input.template
39
+ ? renderTemplate(input.template, buildTemplateVars(renderCtx))
40
+ : renderMarkdown(renderCtx);
36
41
  return {
37
42
  service: input.service,
38
43
  window: input.window,
@@ -203,3 +208,51 @@ function renderMarkdown(ctx) {
203
208
  // could approach MB without the slice() caps above.
204
209
  return lines.join("\n");
205
210
  }
211
+ /** Build the `{{token}}` → markdown-block map for a custom template. */
212
+ export function buildTemplateVars(ctx) {
213
+ const { input, timeline, contributingSignals, peakNode, errorTraces, followUps, synopsis } = ctx;
214
+ const timelineBlock = timeline.length === 0
215
+ ? "_No anomaly samples in this window._"
216
+ : ["| ts | service | score | severity | method |", "|---|---|---|---|---|",
217
+ ...timeline.slice(0, 20).map((t) => `| \`${t.ts}\` | \`${t.service}\` | ${t.score} | ${t.severity} | ${t.method} |`),
218
+ ...(timeline.length > 20 ? [`| … | _${timeline.length - 20} more rows_ | | | |`] : [])].join("\n");
219
+ const brLines = [];
220
+ brLines.push(peakNode ? `Root node: **\`${peakNode.name}\`** (\`${peakNode.kind}\`).` : "_Topology snapshot empty._");
221
+ if (input.blastRadius.nodes.length > 0) {
222
+ brLines.push("", "| node | kind |", "|---|---|", ...input.blastRadius.nodes.slice(0, 30).map((n) => `| \`${n.name}\`${n.root ? " *(root)*" : ""} | \`${n.kind}\` |`));
223
+ }
224
+ brLines.push("", `Edges in radius: **${input.blastRadius.edges.length}**.`);
225
+ const blastRadiusBlock = brLines.join("\n");
226
+ const signalsBlock = contributingSignals.length === 0
227
+ ? "_No anomaly samples to rank._"
228
+ : ["| signal | samples | mean score |", "|---|---|---|",
229
+ ...contributingSignals.slice(0, 10).map((s) => `| \`${s.signal}\` | ${s.count} | ${s.meanScore} |`)].join("\n");
230
+ const tracesBlock = input.traces.length === 0
231
+ ? "_No traces returned for the window. Configure a Tempo / Jaeger source if traces are expected._"
232
+ : ["| trace | service | duration ms | error |", "|---|---|---|---|",
233
+ ...input.traces.slice(0, 10).map((t) => `| \`${t.traceId}\` | \`${t.rootService}\` | ${t.durationMs} | ${t.hasError ? "yes" : "no"} |`),
234
+ ...(errorTraces > 0 ? [`\n_${errorTraces} of the returned traces carried error spans._`] : [])].join("\n");
235
+ const logHighlightsBlock = (input.logHighlights ?? []).length === 0
236
+ ? "_No log highlights._"
237
+ : input.logHighlights.map((l) => `- ${l}`).join("\n");
238
+ const followUpsBlock = followUps.map((f) => `- ${f}`).join("\n");
239
+ return {
240
+ service: input.service,
241
+ window: input.window,
242
+ from: input.fromIso,
243
+ to: input.toIso,
244
+ tenant: input.tenant,
245
+ synopsis,
246
+ timeline: timelineBlock,
247
+ blastRadius: blastRadiusBlock,
248
+ signals: signalsBlock,
249
+ traces: tracesBlock,
250
+ logHighlights: logHighlightsBlock,
251
+ followUps: followUpsBlock,
252
+ };
253
+ }
254
+ /** Interpolate `{{token}}` placeholders. An unknown token is left verbatim so
255
+ * a typo is visible in the output rather than silently producing a blank. */
256
+ export function renderTemplate(template, vars) {
257
+ return template.replace(/\{\{\s*([a-zA-Z][\w]*)\s*\}\}/g, (whole, key) => Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : whole);
258
+ }
@@ -139,3 +139,30 @@ test("synthesizePostmortem: report carries the input window + iso bounds back in
139
139
  assert.equal(r.fromIso, "2026-06-06T00:00:00.000Z");
140
140
  assert.equal(r.toIso, "2026-06-06T01:00:00.000Z");
141
141
  });
142
+ test("custom template: tokens are interpolated; default path unchanged when no template", () => {
143
+ const data = input({
144
+ anomalies: [anomaly("2026-06-06T00:10:00Z", 0.7, "mad", "warn", "cpu")],
145
+ blastRadius: { nodes: [{ id: "n1", kind: "pod", name: "payment-1", root: true }], edges: [] },
146
+ logHighlights: ["payment: 5xx spike"],
147
+ });
148
+ // Default (no template) still produces the built-in report.
149
+ const def = synthesizePostmortem(data);
150
+ assert.match(def.markdown, /^# Post-mortem — payment/);
151
+ // Custom template interpolates the known tokens.
152
+ const tpl = "INCIDENT {{service}} ({{window}})\n\n{{synopsis}}\n\nTIMELINE:\n{{timeline}}\n\nFOLLOWUPS:\n{{followUps}}\n\nLOGS:\n{{logHighlights}}";
153
+ const out = synthesizePostmortem({ ...data, template: tpl }).markdown;
154
+ assert.match(out, /^INCIDENT payment \(1h\)/);
155
+ assert.ok(!out.includes("# Post-mortem"), "custom template replaces the default layout");
156
+ assert.match(out, /TIMELINE:\n\| ts \| service \| score/);
157
+ assert.match(out, /LOGS:\n- payment: 5xx spike/);
158
+ assert.ok(out.includes(def.synopsis), "synopsis token expands to the computed synopsis");
159
+ });
160
+ test("custom template: unknown token is left verbatim (visible typo, not silent blank)", () => {
161
+ const out = synthesizePostmortem({ ...input(), template: "{{service}} / {{nope}}" }).markdown;
162
+ assert.equal(out, "payment / {{nope}}");
163
+ });
164
+ test("custom template: empty sections render their placeholder text, not a crash", () => {
165
+ const out = synthesizePostmortem({ ...input(), template: "T:{{timeline}} L:{{logHighlights}}" }).markdown;
166
+ assert.match(out, /T:_No anomaly samples in this window\._/);
167
+ assert.match(out, /L:_No log highlights\._/);
168
+ });
@@ -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,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,6 +87,7 @@ export async function generatePostmortemHandler(registry, args, ctx = defaultCon
59
87
  blastRadius,
60
88
  traces,
61
89
  logHighlights,
90
+ template: loadPostmortemTemplate(),
62
91
  });
63
92
  // Which primitives actually carried data. A post-mortem synthesised from
64
93
  // ZERO signal still renders a full, authoritative-looking document — an
@@ -1,7 +1,10 @@
1
1
  import { describe, it } from "node:test";
2
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";
3
6
  import { ConnectorRegistry } from "../connectors/registry.js";
4
- import { generatePostmortemHandler } from "./generate-postmortem.js";
7
+ import { generatePostmortemHandler, _resetPostmortemTemplateCache } from "./generate-postmortem.js";
5
8
  // R5 — a post-mortem built from ZERO signal (no anomaly/traces/topology/log
6
9
  // backend) must label itself, not render as an authoritative finding.
7
10
  describe("generatePostmortemHandler — no-signal honesty (R5)", () => {
@@ -20,3 +23,50 @@ describe("generatePostmortemHandler — no-signal honesty (R5)", () => {
20
23
  assert.deepEqual(report.coverage, { anomalies: false, traces: false, topology: false, logs: false });
21
24
  });
22
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thotischner/observability-mcp",
3
- "version": "3.4.0",
3
+ "version": "3.5.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",