@thotischner/observability-mcp 1.7.1 → 3.0.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.
Files changed (238) hide show
  1. package/config/products.yaml.example +48 -0
  2. package/dist/analysis/history.d.ts +70 -0
  3. package/dist/analysis/history.js +170 -0
  4. package/dist/analysis/history.test.d.ts +1 -0
  5. package/dist/analysis/history.test.js +141 -0
  6. package/dist/audit/log.d.ts +108 -0
  7. package/dist/audit/log.js +200 -0
  8. package/dist/audit/log.test.d.ts +1 -0
  9. package/dist/audit/log.test.js +147 -0
  10. package/dist/audit/middleware.d.ts +20 -0
  11. package/dist/audit/middleware.js +50 -0
  12. package/dist/audit/redaction-bypass.d.ts +67 -0
  13. package/dist/audit/redaction-bypass.js +64 -0
  14. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  15. package/dist/audit/redaction-bypass.test.js +72 -0
  16. package/dist/audit/sinks/types.d.ts +18 -0
  17. package/dist/audit/sinks/types.js +1 -0
  18. package/dist/audit/sinks/webhook.d.ts +45 -0
  19. package/dist/audit/sinks/webhook.js +111 -0
  20. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  21. package/dist/audit/sinks/webhook.test.js +162 -0
  22. package/dist/auth/credentials.d.ts +29 -0
  23. package/dist/auth/credentials.js +53 -1
  24. package/dist/auth/credentials.test.js +46 -1
  25. package/dist/auth/csrf.d.ts +26 -0
  26. package/dist/auth/csrf.js +128 -0
  27. package/dist/auth/csrf.test.d.ts +1 -0
  28. package/dist/auth/csrf.test.js +143 -0
  29. package/dist/auth/local-users.d.ts +68 -0
  30. package/dist/auth/local-users.js +154 -0
  31. package/dist/auth/local-users.test.d.ts +1 -0
  32. package/dist/auth/local-users.test.js +121 -0
  33. package/dist/auth/middleware.d.ts +49 -0
  34. package/dist/auth/middleware.js +65 -0
  35. package/dist/auth/middleware.test.d.ts +1 -0
  36. package/dist/auth/middleware.test.js +90 -0
  37. package/dist/auth/oidc/client.d.ts +73 -0
  38. package/dist/auth/oidc/client.js +104 -0
  39. package/dist/auth/oidc/client.test.d.ts +1 -0
  40. package/dist/auth/oidc/client.test.js +121 -0
  41. package/dist/auth/oidc/dcr.d.ts +70 -0
  42. package/dist/auth/oidc/dcr.js +160 -0
  43. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  44. package/dist/auth/oidc/dcr.test.js +109 -0
  45. package/dist/auth/oidc/discovery.d.ts +38 -0
  46. package/dist/auth/oidc/discovery.js +48 -0
  47. package/dist/auth/oidc/discovery.test.d.ts +1 -0
  48. package/dist/auth/oidc/discovery.test.js +68 -0
  49. package/dist/auth/oidc/endpoints.d.ts +20 -0
  50. package/dist/auth/oidc/endpoints.js +168 -0
  51. package/dist/auth/oidc/endpoints.test.d.ts +7 -0
  52. package/dist/auth/oidc/endpoints.test.js +304 -0
  53. package/dist/auth/oidc/flow-cookie.d.ts +57 -0
  54. package/dist/auth/oidc/flow-cookie.js +142 -0
  55. package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
  56. package/dist/auth/oidc/flow-cookie.test.js +0 -0
  57. package/dist/auth/oidc/index.d.ts +7 -0
  58. package/dist/auth/oidc/index.js +6 -0
  59. package/dist/auth/oidc/jwks.d.ts +36 -0
  60. package/dist/auth/oidc/jwks.js +69 -0
  61. package/dist/auth/oidc/jwks.test.d.ts +1 -0
  62. package/dist/auth/oidc/jwks.test.js +65 -0
  63. package/dist/auth/oidc/jwt.d.ts +62 -0
  64. package/dist/auth/oidc/jwt.js +113 -0
  65. package/dist/auth/oidc/jwt.test.d.ts +1 -0
  66. package/dist/auth/oidc/jwt.test.js +141 -0
  67. package/dist/auth/oidc/pkce.d.ts +19 -0
  68. package/dist/auth/oidc/pkce.js +43 -0
  69. package/dist/auth/oidc/pkce.test.d.ts +1 -0
  70. package/dist/auth/oidc/pkce.test.js +55 -0
  71. package/dist/auth/oidc/profiles.d.ts +22 -0
  72. package/dist/auth/oidc/profiles.js +95 -0
  73. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  74. package/dist/auth/oidc/profiles.test.js +51 -0
  75. package/dist/auth/oidc/runtime.d.ts +66 -0
  76. package/dist/auth/oidc/runtime.js +142 -0
  77. package/dist/auth/oidc/runtime.test.d.ts +1 -0
  78. package/dist/auth/oidc/runtime.test.js +181 -0
  79. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  80. package/dist/auth/policy/batch-dry-run.js +129 -0
  81. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  82. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  83. package/dist/auth/policy/engine.d.ts +64 -0
  84. package/dist/auth/policy/engine.js +87 -0
  85. package/dist/auth/policy/engine.test.d.ts +1 -0
  86. package/dist/auth/policy/engine.test.js +98 -0
  87. package/dist/auth/policy/loader.d.ts +45 -0
  88. package/dist/auth/policy/loader.js +137 -0
  89. package/dist/auth/policy/loader.test.d.ts +1 -0
  90. package/dist/auth/policy/loader.test.js +86 -0
  91. package/dist/auth/policy/opa.d.ts +69 -0
  92. package/dist/auth/policy/opa.js +173 -0
  93. package/dist/auth/policy/opa.test.d.ts +1 -0
  94. package/dist/auth/policy/opa.test.js +206 -0
  95. package/dist/auth/rbac.d.ts +62 -0
  96. package/dist/auth/rbac.js +162 -0
  97. package/dist/auth/rbac.test.d.ts +1 -0
  98. package/dist/auth/rbac.test.js +183 -0
  99. package/dist/auth/session.d.ts +66 -0
  100. package/dist/auth/session.js +146 -0
  101. package/dist/auth/session.test.d.ts +1 -0
  102. package/dist/auth/session.test.js +90 -0
  103. package/dist/catalog/loader.d.ts +67 -0
  104. package/dist/catalog/loader.js +122 -0
  105. package/dist/catalog/loader.test.d.ts +1 -0
  106. package/dist/catalog/loader.test.js +108 -0
  107. package/dist/cli/index.js +3 -0
  108. package/dist/cli/inspector-config.d.ts +9 -0
  109. package/dist/cli/inspector-config.js +28 -0
  110. package/dist/cli/inspector-config.test.d.ts +1 -0
  111. package/dist/cli/inspector-config.test.js +33 -0
  112. package/dist/cli/lib.d.ts +1 -1
  113. package/dist/cli/lib.js +1 -0
  114. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  115. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  116. package/dist/connectors/interface.d.ts +5 -1
  117. package/dist/connectors/loader.js +6 -4
  118. package/dist/connectors/loader.test.d.ts +1 -0
  119. package/dist/connectors/loader.test.js +78 -0
  120. package/dist/connectors/prometheus.test.js +31 -13
  121. package/dist/connectors/registry.d.ts +13 -0
  122. package/dist/connectors/registry.js +30 -0
  123. package/dist/connectors/registry.test.js +56 -2
  124. package/dist/context.d.ts +45 -1
  125. package/dist/context.js +40 -1
  126. package/dist/context.test.d.ts +1 -0
  127. package/dist/context.test.js +58 -0
  128. package/dist/federation/registry.d.ts +32 -0
  129. package/dist/federation/registry.js +77 -0
  130. package/dist/federation/registry.test.d.ts +1 -0
  131. package/dist/federation/registry.test.js +130 -0
  132. package/dist/federation/upstream.d.ts +60 -0
  133. package/dist/federation/upstream.js +114 -0
  134. package/dist/index.js +2124 -73
  135. package/dist/middleware/ssrfGuard.d.ts +15 -0
  136. package/dist/middleware/ssrfGuard.js +103 -0
  137. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  138. package/dist/middleware/ssrfGuard.test.js +81 -0
  139. package/dist/net/egress-policy.js +2 -0
  140. package/dist/observability/otel.d.ts +20 -0
  141. package/dist/observability/otel.js +118 -0
  142. package/dist/observability/otel.test.d.ts +1 -0
  143. package/dist/observability/otel.test.js +56 -0
  144. package/dist/openapi.js +654 -6
  145. package/dist/openapi.test.d.ts +1 -0
  146. package/dist/openapi.test.js +98 -0
  147. package/dist/policy/redact.d.ts +44 -0
  148. package/dist/policy/redact.js +144 -0
  149. package/dist/policy/redact.test.d.ts +1 -0
  150. package/dist/policy/redact.test.js +172 -0
  151. package/dist/postmortem/synthesizer.d.ts +83 -0
  152. package/dist/postmortem/synthesizer.js +205 -0
  153. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  154. package/dist/postmortem/synthesizer.test.js +141 -0
  155. package/dist/products/loader.d.ts +112 -0
  156. package/dist/products/loader.js +289 -0
  157. package/dist/products/loader.test.d.ts +1 -0
  158. package/dist/products/loader.test.js +257 -0
  159. package/dist/quota/charge.d.ts +28 -0
  160. package/dist/quota/charge.js +30 -0
  161. package/dist/quota/charge.test.d.ts +1 -0
  162. package/dist/quota/charge.test.js +83 -0
  163. package/dist/quota/limiter.d.ts +97 -0
  164. package/dist/quota/limiter.js +161 -0
  165. package/dist/quota/limiter.test.d.ts +1 -0
  166. package/dist/quota/limiter.test.js +205 -0
  167. package/dist/quota/token-budget.d.ts +119 -0
  168. package/dist/quota/token-budget.js +297 -0
  169. package/dist/quota/token-budget.test.d.ts +1 -0
  170. package/dist/quota/token-budget.test.js +215 -0
  171. package/dist/scim/group-role-map.d.ts +4 -0
  172. package/dist/scim/group-role-map.js +33 -0
  173. package/dist/scim/group-role-map.test.d.ts +1 -0
  174. package/dist/scim/group-role-map.test.js +33 -0
  175. package/dist/scim/routes.d.ts +15 -0
  176. package/dist/scim/routes.js +249 -0
  177. package/dist/scim/store.d.ts +37 -0
  178. package/dist/scim/store.js +178 -0
  179. package/dist/scim/store.test.d.ts +1 -0
  180. package/dist/scim/store.test.js +121 -0
  181. package/dist/scim/types.d.ts +73 -0
  182. package/dist/scim/types.js +29 -0
  183. package/dist/sdk/hooks.d.ts +77 -0
  184. package/dist/sdk/hooks.js +72 -0
  185. package/dist/sdk/hooks.test.d.ts +1 -0
  186. package/dist/sdk/hooks.test.js +159 -0
  187. package/dist/sdk/index.d.ts +2 -0
  188. package/dist/sdk/index.js +1 -0
  189. package/dist/sdk/manifest-schema.d.ts +17 -0
  190. package/dist/sdk/manifest-schema.js +21 -0
  191. package/dist/tenancy/context.d.ts +45 -0
  192. package/dist/tenancy/context.js +97 -0
  193. package/dist/tenancy/context.test.d.ts +1 -0
  194. package/dist/tenancy/context.test.js +72 -0
  195. package/dist/tenancy/migration.test.d.ts +7 -0
  196. package/dist/tenancy/migration.test.js +75 -0
  197. package/dist/tools/context-seam.test.js +6 -1
  198. package/dist/tools/detect-anomalies.d.ts +1 -1
  199. package/dist/tools/detect-anomalies.js +5 -4
  200. package/dist/tools/generate-postmortem.d.ts +35 -0
  201. package/dist/tools/generate-postmortem.js +191 -0
  202. package/dist/tools/get-anomaly-history.d.ts +35 -0
  203. package/dist/tools/get-anomaly-history.js +126 -0
  204. package/dist/tools/get-service-health.d.ts +1 -1
  205. package/dist/tools/get-service-health.js +4 -3
  206. package/dist/tools/list-services.d.ts +1 -1
  207. package/dist/tools/list-services.js +3 -2
  208. package/dist/tools/list-sources.d.ts +1 -1
  209. package/dist/tools/list-sources.js +6 -2
  210. package/dist/tools/query-logs.d.ts +1 -1
  211. package/dist/tools/query-logs.js +2 -2
  212. package/dist/tools/query-metrics.d.ts +1 -1
  213. package/dist/tools/query-metrics.js +19 -6
  214. package/dist/tools/query-traces.d.ts +47 -0
  215. package/dist/tools/query-traces.js +145 -0
  216. package/dist/tools/query-traces.test.d.ts +1 -0
  217. package/dist/tools/query-traces.test.js +110 -0
  218. package/dist/tools/registry-names.d.ts +35 -0
  219. package/dist/tools/registry-names.js +54 -0
  220. package/dist/tools/registry-names.test.d.ts +1 -0
  221. package/dist/tools/registry-names.test.js +61 -0
  222. package/dist/tools/topology.d.ts +3 -3
  223. package/dist/tools/topology.js +10 -6
  224. package/dist/topology/merge.d.ts +22 -0
  225. package/dist/topology/merge.js +178 -0
  226. package/dist/topology/merge.test.d.ts +1 -0
  227. package/dist/topology/merge.test.js +110 -0
  228. package/dist/transport/sessionStore.d.ts +66 -0
  229. package/dist/transport/sessionStore.js +138 -0
  230. package/dist/transport/sessionStore.test.d.ts +1 -0
  231. package/dist/transport/sessionStore.test.js +118 -0
  232. package/dist/transport/websocket.d.ts +35 -0
  233. package/dist/transport/websocket.js +133 -0
  234. package/dist/transport/websocket.test.d.ts +1 -0
  235. package/dist/transport/websocket.test.js +124 -0
  236. package/dist/types.d.ts +51 -0
  237. package/dist/ui/index.html +3083 -88
  238. package/package.json +32 -5
@@ -0,0 +1,205 @@
1
+ // Auto-post-mortem synthesizer — Phase F19.
2
+ //
3
+ // Stitches together the existing observability primitives — anomaly
4
+ // history (F15), blast-radius (F13/topology), trace summaries (F13),
5
+ // log-derived error patterns (existing query_logs) — into a single
6
+ // markdown report a human (or LLM) can read in one shot.
7
+ //
8
+ // The synthesizer is pure-ish: it accepts the upstream queries as
9
+ // injected functions so the tool layer can compose them without the
10
+ // synthesizer depending on the entire ConnectorRegistry API. Tests
11
+ // inject fake data and don't need a live demo stack.
12
+ /** Synthesise one report from already-fetched primitives. Pure
13
+ * compute — no I/O. */
14
+ export function synthesizePostmortem(input) {
15
+ const timeline = [...input.anomalies]
16
+ .sort((a, b) => a.ts.localeCompare(b.ts))
17
+ .map((a) => ({ ts: a.ts, service: a.service, score: a.score, severity: a.severity, method: a.method }));
18
+ const contributingSignals = aggregateBySignal(input.anomalies);
19
+ const peakScore = input.anomalies.reduce((m, a) => Math.max(m, a.score), 0);
20
+ const errorTraces = input.traces.filter((t) => t.hasError).length;
21
+ const peakNode = input.blastRadius.nodes.find((n) => n.root) ?? input.blastRadius.nodes[0];
22
+ const blastSize = input.blastRadius.nodes.length;
23
+ const followUps = inferFollowUps(input, { peakScore, errorTraces, blastSize });
24
+ const synopsis = synopsisFor(input, peakScore, errorTraces, blastSize);
25
+ const markdown = renderMarkdown({
26
+ input,
27
+ timeline,
28
+ contributingSignals,
29
+ peakNode,
30
+ peakScore,
31
+ errorTraces,
32
+ blastSize,
33
+ followUps,
34
+ synopsis,
35
+ });
36
+ return {
37
+ service: input.service,
38
+ window: input.window,
39
+ fromIso: input.fromIso,
40
+ toIso: input.toIso,
41
+ synopsis,
42
+ markdown,
43
+ sections: {
44
+ timeline,
45
+ blastRadius: { nodes: input.blastRadius.nodes, edgeCount: input.blastRadius.edges.length },
46
+ topTraces: input.traces.slice(0, 10),
47
+ contributingSignals,
48
+ followUps,
49
+ logHighlights: input.logHighlights ?? [],
50
+ },
51
+ };
52
+ }
53
+ function aggregateBySignal(anomalies) {
54
+ const groups = new Map();
55
+ for (const a of anomalies) {
56
+ const sig = a.signal ?? a.method;
57
+ const prev = groups.get(sig);
58
+ if (prev)
59
+ prev.push(a.score);
60
+ else
61
+ groups.set(sig, [a.score]);
62
+ }
63
+ return [...groups.entries()]
64
+ .map(([signal, scores]) => ({
65
+ signal,
66
+ count: scores.length,
67
+ meanScore: Math.round((scores.reduce((s, x) => s + x, 0) / scores.length) * 100) / 100,
68
+ }))
69
+ .sort((a, b) => b.meanScore - a.meanScore);
70
+ }
71
+ function inferFollowUps(input, ctx) {
72
+ const out = [];
73
+ if (input.anomalies.length === 0) {
74
+ out.push("No anomaly history found for this service in the window — confirm OMCP_ANOMALY_HISTORY_REMOTE_WRITE is wired and Prometheus is scraping the same TSDB.");
75
+ return out;
76
+ }
77
+ if (ctx.peakScore >= 0.9) {
78
+ out.push(`Peak anomaly score ${ctx.peakScore} is critical — review the detector's threshold for service '${input.service}' and consider whether the chosen method (${dominantMethod(input.anomalies)}) suits this signal's distribution.`);
79
+ }
80
+ if (ctx.errorTraces > 0) {
81
+ out.push(`${ctx.errorTraces} trace(s) carried error spans during the window — drill into the slowest via \`query_traces(service="${input.service}", errorsOnly=true)\`.`);
82
+ }
83
+ if (ctx.blastSize > 5) {
84
+ out.push(`Blast radius spans ${ctx.blastSize} nodes — verify that the dependency edges are still accurate (a stale topology snapshot can blow up the radius and miss the real cause).`);
85
+ }
86
+ if ((input.logHighlights ?? []).length > 0) {
87
+ out.push("Log highlights above point at concrete error patterns — promote the recurring ones to an alert or SLO so the next regression catches itself.");
88
+ }
89
+ if (out.length === 0) {
90
+ out.push("All signals look stable for this window — consider closing the incident as a transient anomaly or expanding the time window.");
91
+ }
92
+ return out;
93
+ }
94
+ function dominantMethod(anomalies) {
95
+ const c = new Map();
96
+ for (const a of anomalies)
97
+ c.set(a.method, (c.get(a.method) ?? 0) + 1);
98
+ return [...c.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown";
99
+ }
100
+ function synopsisFor(input, peakScore, errorTraces, blastSize) {
101
+ const anomalyCount = input.anomalies.length;
102
+ if (anomalyCount === 0) {
103
+ return `No anomalies recorded for service '${input.service}' between ${input.fromIso} and ${input.toIso}. Either the window was clean, or the history sink wasn't writing at the time.`;
104
+ }
105
+ return [
106
+ `Service '${input.service}' produced ${anomalyCount} anomaly sample(s) between ${input.fromIso} and ${input.toIso}, peaking at ${peakScore}.`,
107
+ `Blast radius at peak covered ${blastSize} node(s); ${errorTraces} trace(s) carried error spans.`,
108
+ ].join(" ");
109
+ }
110
+ function renderMarkdown(ctx) {
111
+ const { input, timeline, contributingSignals, peakNode, peakScore, errorTraces, followUps, synopsis } = ctx;
112
+ const lines = [];
113
+ lines.push(`# Post-mortem — ${input.service}`);
114
+ lines.push("");
115
+ lines.push(`> **Window:** \`${input.fromIso}\` → \`${input.toIso}\` (\`${input.window}\`) `);
116
+ lines.push(`> **Tenant:** \`${input.tenant}\` `);
117
+ lines.push(`> **Generated by:** observability-mcp \`generate_postmortem\``);
118
+ lines.push("");
119
+ lines.push("## Synopsis");
120
+ lines.push("");
121
+ lines.push(synopsis);
122
+ lines.push("");
123
+ lines.push("## Anomaly timeline");
124
+ lines.push("");
125
+ if (timeline.length === 0) {
126
+ lines.push("_No anomaly samples in this window._");
127
+ }
128
+ else {
129
+ lines.push("| ts | service | score | severity | method |");
130
+ lines.push("|---|---|---|---|---|");
131
+ for (const t of timeline.slice(0, 20)) {
132
+ lines.push(`| \`${t.ts}\` | \`${t.service}\` | ${t.score} | ${t.severity} | ${t.method} |`);
133
+ }
134
+ if (timeline.length > 20)
135
+ lines.push(`| … | _${timeline.length - 20} more rows_ | | | |`);
136
+ }
137
+ lines.push("");
138
+ lines.push("## Blast radius at peak");
139
+ lines.push("");
140
+ if (peakNode) {
141
+ lines.push(`Root node: **\`${peakNode.name}\`** (\`${peakNode.kind}\`).`);
142
+ }
143
+ else {
144
+ lines.push("_Topology snapshot empty._");
145
+ }
146
+ lines.push("");
147
+ if (input.blastRadius.nodes.length > 0) {
148
+ lines.push("| node | kind |");
149
+ lines.push("|---|---|");
150
+ for (const n of input.blastRadius.nodes.slice(0, 30)) {
151
+ lines.push(`| \`${n.name}\`${n.root ? " *(root)*" : ""} | \`${n.kind}\` |`);
152
+ }
153
+ }
154
+ lines.push("");
155
+ lines.push(`Edges in radius: **${input.blastRadius.edges.length}**.`);
156
+ lines.push("");
157
+ lines.push("## Contributing signals (ranked)");
158
+ lines.push("");
159
+ if (contributingSignals.length === 0) {
160
+ lines.push("_No anomaly samples to rank._");
161
+ }
162
+ else {
163
+ lines.push("| signal | samples | mean score |");
164
+ lines.push("|---|---|---|");
165
+ for (const s of contributingSignals.slice(0, 10)) {
166
+ lines.push(`| \`${s.signal}\` | ${s.count} | ${s.meanScore} |`);
167
+ }
168
+ }
169
+ lines.push("");
170
+ lines.push("## Related traces");
171
+ lines.push("");
172
+ if (input.traces.length === 0) {
173
+ lines.push("_No traces returned for the window. Configure a Tempo / Jaeger source if traces are expected._");
174
+ }
175
+ else {
176
+ lines.push("| trace | service | duration ms | error |");
177
+ lines.push("|---|---|---|---|");
178
+ for (const t of input.traces.slice(0, 10)) {
179
+ lines.push(`| \`${t.traceId}\` | \`${t.rootService}\` | ${t.durationMs} | ${t.hasError ? "yes" : "no"} |`);
180
+ }
181
+ if (errorTraces > 0)
182
+ lines.push(`\n_${errorTraces} of the returned traces carried error spans._`);
183
+ }
184
+ lines.push("");
185
+ if ((input.logHighlights ?? []).length > 0) {
186
+ lines.push("## Log highlights");
187
+ lines.push("");
188
+ for (const l of input.logHighlights)
189
+ lines.push(`- ${l}`);
190
+ lines.push("");
191
+ }
192
+ lines.push("## Suggested follow-ups");
193
+ lines.push("");
194
+ for (const f of followUps)
195
+ lines.push(`- ${f}`);
196
+ lines.push("");
197
+ lines.push("---");
198
+ lines.push("");
199
+ lines.push(`*Generated by observability-mcp \`generate_postmortem\` — see \`docs/postmortems.md\` for the prompt sources.*`);
200
+ lines.push("");
201
+ // Bound the chunk to keep memory predictable; the rendered report
202
+ // is normally a few KB but a pathological 10k-sample timeline
203
+ // could approach MB without the slice() caps above.
204
+ return lines.join("\n");
205
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,141 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { synthesizePostmortem, } from "./synthesizer.js";
4
+ function input(overrides = {}) {
5
+ return {
6
+ service: "payment",
7
+ window: "1h",
8
+ tenant: "default",
9
+ fromIso: "2026-06-06T00:00:00.000Z",
10
+ toIso: "2026-06-06T01:00:00.000Z",
11
+ anomalies: [],
12
+ blastRadius: { nodes: [], edges: [] },
13
+ traces: [],
14
+ ...overrides,
15
+ };
16
+ }
17
+ function anomaly(ts, score, method = "mad", severity = "warn", signal) {
18
+ return { ts, service: "payment", score, method, severity, signal };
19
+ }
20
+ test("synthesizePostmortem: empty input returns synopsis + 'no anomalies' follow-up", () => {
21
+ const r = synthesizePostmortem(input());
22
+ assert.match(r.synopsis, /No anomalies recorded/);
23
+ assert.equal(r.sections.timeline.length, 0);
24
+ assert.equal(r.sections.followUps.length, 1);
25
+ assert.match(r.sections.followUps[0], /OMCP_ANOMALY_HISTORY_REMOTE_WRITE/);
26
+ });
27
+ test("synthesizePostmortem: timeline is sorted by ts ascending", () => {
28
+ const r = synthesizePostmortem(input({
29
+ anomalies: [
30
+ anomaly("2026-06-06T00:30:00Z", 0.5),
31
+ anomaly("2026-06-06T00:10:00Z", 0.4),
32
+ anomaly("2026-06-06T00:50:00Z", 0.9),
33
+ ],
34
+ }));
35
+ assert.deepEqual(r.sections.timeline.map((t) => t.ts), ["2026-06-06T00:10:00Z", "2026-06-06T00:30:00Z", "2026-06-06T00:50:00Z"]);
36
+ });
37
+ test("synthesizePostmortem: contributing signals aggregated by signal label + ranked by mean score desc", () => {
38
+ const r = synthesizePostmortem(input({
39
+ anomalies: [
40
+ anomaly("2026-06-06T00:10Z", 0.5, "mad", "warn", "request_latency"),
41
+ anomaly("2026-06-06T00:20Z", 0.4, "mad", "warn", "request_latency"),
42
+ anomaly("2026-06-06T00:30Z", 0.95, "seasonality", "critical", "error_rate"),
43
+ ],
44
+ }));
45
+ const sigs = r.sections.contributingSignals;
46
+ assert.equal(sigs.length, 2);
47
+ // error_rate (0.95 mean) ranks above request_latency (0.45 mean)
48
+ assert.equal(sigs[0].signal, "error_rate");
49
+ assert.equal(sigs[0].count, 1);
50
+ assert.equal(sigs[0].meanScore, 0.95);
51
+ assert.equal(sigs[1].signal, "request_latency");
52
+ assert.equal(sigs[1].count, 2);
53
+ assert.equal(sigs[1].meanScore, 0.45);
54
+ });
55
+ test("synthesizePostmortem: missing signal label falls back to method", () => {
56
+ const r = synthesizePostmortem(input({ anomalies: [anomaly("2026-06-06T00:10Z", 0.6, "correlator")] }));
57
+ assert.equal(r.sections.contributingSignals[0].signal, "correlator");
58
+ });
59
+ test("synthesizePostmortem: critical peak triggers a follow-up mentioning the threshold", () => {
60
+ const r = synthesizePostmortem(input({ anomalies: [anomaly("2026-06-06T00:30Z", 0.95)] }));
61
+ assert.ok(r.sections.followUps.some((f) => /Peak anomaly score 0\.95/.test(f)));
62
+ });
63
+ test("synthesizePostmortem: errors-in-traces triggers errorsOnly drill-in suggestion", () => {
64
+ const r = synthesizePostmortem(input({
65
+ anomalies: [anomaly("2026-06-06T00:10Z", 0.6)],
66
+ traces: [
67
+ { traceId: "aaa", rootName: "GET /pay", rootService: "payment", durationMs: 800, hasError: true },
68
+ ],
69
+ }));
70
+ assert.ok(r.sections.followUps.some((f) => /errorsOnly=true/.test(f)));
71
+ });
72
+ test("synthesizePostmortem: large blast radius triggers stale-topology hint", () => {
73
+ const nodes = Array.from({ length: 7 }, (_, i) => ({ id: `n${i}`, kind: "pod", name: `n${i}`, root: i === 0 }));
74
+ const r = synthesizePostmortem(input({
75
+ anomalies: [anomaly("2026-06-06T00:10Z", 0.6)],
76
+ blastRadius: { nodes, edges: [{ from: "n0", to: "n1", relation: "CALLS" }] },
77
+ }));
78
+ assert.ok(r.sections.followUps.some((f) => /7 nodes/.test(f) && /stale topology/i.test(f)));
79
+ });
80
+ test("synthesizePostmortem: clean window returns a 'stable, consider closing' follow-up", () => {
81
+ // The "all signals stable" branch fires only when:
82
+ // anomalies present (not zero)
83
+ // peak < 0.9
84
+ // no error traces
85
+ // blast radius <= 5
86
+ // no log highlights
87
+ const r = synthesizePostmortem(input({
88
+ anomalies: [anomaly("2026-06-06T00:10Z", 0.3)],
89
+ blastRadius: { nodes: [{ id: "n0", kind: "pod", name: "n0", root: true }], edges: [] },
90
+ }));
91
+ assert.ok(r.sections.followUps.some((f) => /stable for this window/.test(f)));
92
+ });
93
+ test("synthesizePostmortem: markdown contains every section header in order", () => {
94
+ const r = synthesizePostmortem(input({
95
+ anomalies: [anomaly("2026-06-06T00:10Z", 0.7)],
96
+ blastRadius: {
97
+ nodes: [{ id: "p", kind: "deployment", name: "payment", root: true }],
98
+ edges: [{ from: "p", to: "rds", relation: "READS_FROM" }],
99
+ },
100
+ traces: [{ traceId: "t", rootName: "GET /pay", rootService: "payment", durationMs: 200, hasError: false }],
101
+ logHighlights: ["payment-service: 12 5xx in window"],
102
+ }));
103
+ for (const heading of [
104
+ "# Post-mortem — payment",
105
+ "## Synopsis",
106
+ "## Anomaly timeline",
107
+ "## Blast radius at peak",
108
+ "## Contributing signals (ranked)",
109
+ "## Related traces",
110
+ "## Log highlights",
111
+ "## Suggested follow-ups",
112
+ ]) {
113
+ assert.ok(r.markdown.includes(heading), `markdown missing section: ${heading}`);
114
+ }
115
+ // The order check — anomaly timeline should appear before blast radius
116
+ assert.ok(r.markdown.indexOf("## Anomaly timeline") < r.markdown.indexOf("## Blast radius at peak"));
117
+ });
118
+ test("synthesizePostmortem: timeline > 20 rows is truncated with an ellipsis row", () => {
119
+ const anomalies = Array.from({ length: 25 }, (_, i) => anomaly(`2026-06-06T00:${String(i).padStart(2, "0")}:00Z`, 0.5 + i * 0.01));
120
+ const r = synthesizePostmortem(input({ anomalies }));
121
+ // The structured section has all 25
122
+ assert.equal(r.sections.timeline.length, 25);
123
+ // The markdown table is capped at 20 data rows + an ellipsis row
124
+ // — count rows specifically inside the Anomaly timeline section
125
+ // (other sections also use | ` ... | tables and would inflate a
126
+ // global grep).
127
+ const md = r.markdown;
128
+ const timelineStart = md.indexOf("## Anomaly timeline");
129
+ const blastStart = md.indexOf("## Blast radius at peak");
130
+ const timelineSection = md.slice(timelineStart, blastStart);
131
+ const tableRows = timelineSection.split("\n").filter((l) => l.startsWith("| `")).length;
132
+ assert.equal(tableRows, 20);
133
+ assert.match(timelineSection, /_5 more rows_/);
134
+ });
135
+ test("synthesizePostmortem: report carries the input window + iso bounds back into the structured shape", () => {
136
+ const r = synthesizePostmortem(input({ window: "6h" }));
137
+ assert.equal(r.service, "payment");
138
+ assert.equal(r.window, "6h");
139
+ assert.equal(r.fromIso, "2026-06-06T00:00:00.000Z");
140
+ assert.equal(r.toIso, "2026-06-06T01:00:00.000Z");
141
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * MCP Products — curated, agent-facing collections of tools.
3
+ *
4
+ * A Product is a named bundle that ships with branding metadata
5
+ * (icon, description, version) plus a list of allowed MCP tools.
6
+ * The agent calling /mcp can be told which Product it belongs to
7
+ * (via a future header / arg, slice 2+), and the server can filter
8
+ * tools/list and tools/call responses accordingly.
9
+ *
10
+ * Today's surface (slice 1):
11
+ * - In-memory ProductsStore loaded from OMCP_PRODUCTS_FILE
12
+ * (YAML or JSON). Missing/empty file → empty catalog.
13
+ * - Strict validation: unknown action / unknown resource /
14
+ * unexpected keys reject loudly.
15
+ * - Mtime-poll hot-reload: callers (e.g. each /api/products
16
+ * handler) `await store.maybeReload()` before reading. If the
17
+ * file mtime advanced since the last load, the store re-parses
18
+ * and atomically swaps the in-memory file; parse errors keep
19
+ * the previous good state and log loudly. One `stat()` call per
20
+ * reload-aware request — too cheap to matter vs. the network
21
+ * round-trip, no FSWatcher platform fragility (WSL / NFS).
22
+ */
23
+ export interface Product {
24
+ /** Stable identifier — used in URLs, audit entries, /api/products/{id}. */
25
+ id: string;
26
+ /** Display name shown in the UI / agent dropdown. */
27
+ name: string;
28
+ /** One-sentence description. */
29
+ description?: string;
30
+ /** Allowed MCP tool names. Empty / undefined → all tools allowed. */
31
+ tools?: string[];
32
+ /** Operator-defined version label, e.g. "1.0.0" or "preview". */
33
+ version?: string;
34
+ /** Free-form branding metadata for the UI — icon URL, theme colour, etc. */
35
+ branding?: {
36
+ iconUrl?: string;
37
+ color?: string;
38
+ };
39
+ /** Lifecycle stage: published = visible to agents; staging = admin-only. */
40
+ status?: "published" | "staging";
41
+ /** Tenant this product belongs to. Omitted → "default". */
42
+ tenant?: string;
43
+ }
44
+ export interface ProductsFile {
45
+ products: Product[];
46
+ }
47
+ export declare class ProductsLoadError extends Error {
48
+ constructor(msg: string);
49
+ }
50
+ export declare function readProductsFile(path: string | undefined): Promise<ProductsFile>;
51
+ export declare function parseProductsText(text: string, origin: string): ProductsFile;
52
+ /** In-memory store with tenant- and status-aware queries. */
53
+ export declare class ProductsStore {
54
+ private file;
55
+ /** Optional source file path. When set, `maybeReload()` polls its
56
+ * mtime and re-parses on change. Mutations via upsert/delete update
57
+ * `lastMtimeMs` after the caller persists, so the store does not
58
+ * reload its own writes. */
59
+ private path?;
60
+ private lastMtimeMs;
61
+ constructor(file?: ProductsFile, opts?: {
62
+ path?: string;
63
+ initialMtimeMs?: number;
64
+ });
65
+ /** Re-read the source file if its mtime has advanced since the last
66
+ * load. No-op when no path was supplied at construction. Parse or
67
+ * IO errors are logged and the previous good state is kept — the
68
+ * invariant is "the store always reflects a valid catalogue", so a
69
+ * broken edit on disk never takes the running server down. */
70
+ maybeReload(): Promise<{
71
+ reloaded: boolean;
72
+ }>;
73
+ /** Re-stat the source file and pin the mtime cursor to its current
74
+ * value. Call this after a successful write so the store does not
75
+ * treat its own change as an external reload trigger. Best-effort:
76
+ * if the stat fails, the next maybeReload() will simply reload the
77
+ * file once and find it identical. */
78
+ pinMtimeAfterWrite(): Promise<void>;
79
+ /** Return the product list. When `tenant` is set, filters to that
80
+ * tenant (entries without a tenant field treated as "default").
81
+ * When `includeStaging` is false (default), staging products are
82
+ * hidden from the result — admins should pass true. */
83
+ list(opts?: {
84
+ tenant?: string;
85
+ includeStaging?: boolean;
86
+ }): Product[];
87
+ /** Lookup by id. Cross-tenant gets return undefined when `tenant` set. */
88
+ get(id: string, tenant?: string): Product | undefined;
89
+ count(tenant?: string): number;
90
+ replace(file: ProductsFile): void;
91
+ /** Upsert (replace if id exists, else append). Returns the new
92
+ * ProductsFile so the caller can persist it. */
93
+ upsert(product: Product): ProductsFile;
94
+ /** Remove by id. Returns true when the product existed, false
95
+ * otherwise. Caller persists the resulting file. */
96
+ delete(id: string): {
97
+ removed: boolean;
98
+ file: ProductsFile;
99
+ };
100
+ /** Snapshot of the current file (for tests / persistence). */
101
+ snapshot(): ProductsFile;
102
+ }
103
+ /** Validate a single product entry by routing it through the same
104
+ * parser as the file format. Throws ProductsLoadError on any
105
+ * shape problem. Used by PUT /api/products/:id so a typo / wrong
106
+ * type / unknown key gets the same loud rejection a malformed
107
+ * file would. */
108
+ export declare function validateProduct(input: unknown, origin?: string): Product;
109
+ /** Atomic write of the products file. Same tmp+rename pattern as
110
+ * the audit-chain + token-budget snapshot, so a crash mid-write
111
+ * leaves the previous file intact. */
112
+ export declare function writeProductsFile(path: string, file: ProductsFile): Promise<void>;