@thotischner/observability-mcp 1.8.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 (169) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/types.d.ts +18 -0
  12. package/dist/audit/sinks/types.js +1 -0
  13. package/dist/audit/sinks/webhook.d.ts +45 -0
  14. package/dist/audit/sinks/webhook.js +111 -0
  15. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  16. package/dist/audit/sinks/webhook.test.js +162 -0
  17. package/dist/auth/credentials.d.ts +11 -0
  18. package/dist/auth/credentials.js +27 -0
  19. package/dist/auth/credentials.test.js +21 -1
  20. package/dist/auth/csrf.d.ts +26 -0
  21. package/dist/auth/csrf.js +128 -0
  22. package/dist/auth/csrf.test.d.ts +1 -0
  23. package/dist/auth/csrf.test.js +143 -0
  24. package/dist/auth/local-users.d.ts +6 -0
  25. package/dist/auth/local-users.js +11 -0
  26. package/dist/auth/local-users.test.js +41 -0
  27. package/dist/auth/middleware.d.ts +7 -6
  28. package/dist/auth/oidc/dcr.d.ts +70 -0
  29. package/dist/auth/oidc/dcr.js +160 -0
  30. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  31. package/dist/auth/oidc/dcr.test.js +109 -0
  32. package/dist/auth/oidc/endpoints.js +44 -0
  33. package/dist/auth/oidc/profiles.d.ts +22 -0
  34. package/dist/auth/oidc/profiles.js +95 -0
  35. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  36. package/dist/auth/oidc/profiles.test.js +51 -0
  37. package/dist/auth/oidc/runtime.d.ts +3 -0
  38. package/dist/auth/oidc/runtime.js +16 -3
  39. package/dist/auth/oidc/runtime.test.js +1 -0
  40. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  41. package/dist/auth/policy/batch-dry-run.js +129 -0
  42. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  43. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  44. package/dist/auth/policy/engine.d.ts +20 -4
  45. package/dist/auth/policy/engine.js +16 -2
  46. package/dist/auth/policy/loader.d.ts +11 -1
  47. package/dist/auth/policy/loader.js +37 -0
  48. package/dist/auth/policy/loader.test.d.ts +1 -0
  49. package/dist/auth/policy/loader.test.js +86 -0
  50. package/dist/auth/policy/opa.d.ts +5 -5
  51. package/dist/auth/policy/opa.js +25 -14
  52. package/dist/auth/policy/opa.test.js +48 -0
  53. package/dist/auth/rbac.d.ts +23 -1
  54. package/dist/auth/rbac.js +43 -1
  55. package/dist/auth/rbac.test.js +62 -0
  56. package/dist/cli/index.js +3 -0
  57. package/dist/cli/inspector-config.d.ts +9 -0
  58. package/dist/cli/inspector-config.js +28 -0
  59. package/dist/cli/inspector-config.test.d.ts +1 -0
  60. package/dist/cli/inspector-config.test.js +33 -0
  61. package/dist/cli/lib.d.ts +1 -1
  62. package/dist/cli/lib.js +1 -0
  63. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  64. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  65. package/dist/connectors/interface.d.ts +5 -1
  66. package/dist/connectors/loader.js +6 -4
  67. package/dist/connectors/loader.test.d.ts +1 -0
  68. package/dist/connectors/loader.test.js +78 -0
  69. package/dist/connectors/prometheus.test.js +31 -13
  70. package/dist/connectors/registry.d.ts +13 -0
  71. package/dist/connectors/registry.js +30 -0
  72. package/dist/connectors/registry.test.js +56 -2
  73. package/dist/context.d.ts +32 -0
  74. package/dist/context.js +35 -0
  75. package/dist/context.test.d.ts +1 -0
  76. package/dist/context.test.js +58 -0
  77. package/dist/federation/registry.d.ts +32 -0
  78. package/dist/federation/registry.js +77 -0
  79. package/dist/federation/registry.test.d.ts +1 -0
  80. package/dist/federation/registry.test.js +130 -0
  81. package/dist/federation/upstream.d.ts +60 -0
  82. package/dist/federation/upstream.js +114 -0
  83. package/dist/index.js +1188 -120
  84. package/dist/middleware/ssrfGuard.d.ts +15 -0
  85. package/dist/middleware/ssrfGuard.js +103 -0
  86. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  87. package/dist/middleware/ssrfGuard.test.js +81 -0
  88. package/dist/observability/otel.d.ts +20 -0
  89. package/dist/observability/otel.js +118 -0
  90. package/dist/observability/otel.test.d.ts +1 -0
  91. package/dist/observability/otel.test.js +56 -0
  92. package/dist/openapi.js +215 -7
  93. package/dist/openapi.test.js +34 -0
  94. package/dist/postmortem/synthesizer.d.ts +83 -0
  95. package/dist/postmortem/synthesizer.js +205 -0
  96. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  97. package/dist/postmortem/synthesizer.test.js +141 -0
  98. package/dist/products/loader.d.ts +31 -3
  99. package/dist/products/loader.js +77 -4
  100. package/dist/products/loader.test.js +90 -1
  101. package/dist/quota/charge.d.ts +28 -0
  102. package/dist/quota/charge.js +30 -0
  103. package/dist/quota/charge.test.d.ts +1 -0
  104. package/dist/quota/charge.test.js +83 -0
  105. package/dist/quota/limiter.d.ts +29 -4
  106. package/dist/quota/limiter.js +64 -8
  107. package/dist/quota/limiter.test.js +86 -0
  108. package/dist/scim/group-role-map.d.ts +4 -0
  109. package/dist/scim/group-role-map.js +33 -0
  110. package/dist/scim/group-role-map.test.d.ts +1 -0
  111. package/dist/scim/group-role-map.test.js +33 -0
  112. package/dist/scim/routes.d.ts +15 -0
  113. package/dist/scim/routes.js +249 -0
  114. package/dist/scim/store.d.ts +37 -0
  115. package/dist/scim/store.js +178 -0
  116. package/dist/scim/store.test.d.ts +1 -0
  117. package/dist/scim/store.test.js +121 -0
  118. package/dist/scim/types.d.ts +73 -0
  119. package/dist/scim/types.js +29 -0
  120. package/dist/sdk/hooks.d.ts +77 -0
  121. package/dist/sdk/hooks.js +72 -0
  122. package/dist/sdk/hooks.test.d.ts +1 -0
  123. package/dist/sdk/hooks.test.js +159 -0
  124. package/dist/sdk/index.d.ts +2 -0
  125. package/dist/sdk/index.js +1 -0
  126. package/dist/sdk/manifest-schema.d.ts +17 -0
  127. package/dist/sdk/manifest-schema.js +21 -0
  128. package/dist/tools/context-seam.test.js +6 -1
  129. package/dist/tools/detect-anomalies.d.ts +1 -1
  130. package/dist/tools/detect-anomalies.js +5 -4
  131. package/dist/tools/generate-postmortem.d.ts +35 -0
  132. package/dist/tools/generate-postmortem.js +191 -0
  133. package/dist/tools/get-anomaly-history.d.ts +35 -0
  134. package/dist/tools/get-anomaly-history.js +126 -0
  135. package/dist/tools/get-service-health.d.ts +1 -1
  136. package/dist/tools/get-service-health.js +4 -3
  137. package/dist/tools/list-services.d.ts +1 -1
  138. package/dist/tools/list-services.js +3 -2
  139. package/dist/tools/list-sources.d.ts +1 -1
  140. package/dist/tools/list-sources.js +6 -2
  141. package/dist/tools/query-logs.d.ts +1 -1
  142. package/dist/tools/query-logs.js +2 -2
  143. package/dist/tools/query-metrics.d.ts +1 -1
  144. package/dist/tools/query-metrics.js +19 -6
  145. package/dist/tools/query-traces.d.ts +47 -0
  146. package/dist/tools/query-traces.js +145 -0
  147. package/dist/tools/query-traces.test.d.ts +1 -0
  148. package/dist/tools/query-traces.test.js +110 -0
  149. package/dist/tools/registry-names.d.ts +35 -0
  150. package/dist/tools/registry-names.js +54 -0
  151. package/dist/tools/registry-names.test.d.ts +1 -0
  152. package/dist/tools/registry-names.test.js +61 -0
  153. package/dist/tools/topology.d.ts +3 -3
  154. package/dist/tools/topology.js +10 -6
  155. package/dist/topology/merge.d.ts +22 -0
  156. package/dist/topology/merge.js +178 -0
  157. package/dist/topology/merge.test.d.ts +1 -0
  158. package/dist/topology/merge.test.js +110 -0
  159. package/dist/transport/sessionStore.d.ts +66 -0
  160. package/dist/transport/sessionStore.js +138 -0
  161. package/dist/transport/sessionStore.test.d.ts +1 -0
  162. package/dist/transport/sessionStore.test.js +118 -0
  163. package/dist/transport/websocket.d.ts +35 -0
  164. package/dist/transport/websocket.js +133 -0
  165. package/dist/transport/websocket.test.d.ts +1 -0
  166. package/dist/transport/websocket.test.js +124 -0
  167. package/dist/types.d.ts +51 -0
  168. package/dist/ui/index.html +1729 -100
  169. package/package.json +13 -3
@@ -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
+ });
@@ -12,8 +12,13 @@
12
12
  * (YAML or JSON). Missing/empty file → empty catalog.
13
13
  * - Strict validation: unknown action / unknown resource /
14
14
  * unexpected keys reject loudly.
15
- * - Hot-reload on next /api/products call (slice 2 wires the
16
- * reload trigger; for now the file is read once at boot).
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).
17
22
  */
18
23
  export interface Product {
19
24
  /** Stable identifier — used in URLs, audit entries, /api/products/{id}. */
@@ -47,7 +52,30 @@ export declare function parseProductsText(text: string, origin: string): Product
47
52
  /** In-memory store with tenant- and status-aware queries. */
48
53
  export declare class ProductsStore {
49
54
  private file;
50
- constructor(file?: ProductsFile);
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>;
51
79
  /** Return the product list. When `tenant` is set, filters to that
52
80
  * tenant (entries without a tenant field treated as "default").
53
81
  * When `includeStaging` is false (default), staging products are
@@ -12,10 +12,15 @@
12
12
  * (YAML or JSON). Missing/empty file → empty catalog.
13
13
  * - Strict validation: unknown action / unknown resource /
14
14
  * unexpected keys reject loudly.
15
- * - Hot-reload on next /api/products call (slice 2 wires the
16
- * reload trigger; for now the file is read once at boot).
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).
17
22
  */
18
- import { readFile, writeFile, rename } from "node:fs/promises";
23
+ import { readFile, writeFile, rename, stat } from "node:fs/promises";
19
24
  import yaml from "js-yaml";
20
25
  const EMPTY = { products: [] };
21
26
  const VALID_STATUS = new Set(["published", "staging"]);
@@ -134,8 +139,76 @@ export function parseProductsText(text, origin) {
134
139
  /** In-memory store with tenant- and status-aware queries. */
135
140
  export class ProductsStore {
136
141
  file;
137
- constructor(file = EMPTY) {
142
+ /** Optional source file path. When set, `maybeReload()` polls its
143
+ * mtime and re-parses on change. Mutations via upsert/delete update
144
+ * `lastMtimeMs` after the caller persists, so the store does not
145
+ * reload its own writes. */
146
+ path;
147
+ lastMtimeMs = 0;
148
+ constructor(file = EMPTY, opts = {}) {
138
149
  this.file = file;
150
+ this.path = opts.path;
151
+ this.lastMtimeMs = opts.initialMtimeMs ?? 0;
152
+ }
153
+ /** Re-read the source file if its mtime has advanced since the last
154
+ * load. No-op when no path was supplied at construction. Parse or
155
+ * IO errors are logged and the previous good state is kept — the
156
+ * invariant is "the store always reflects a valid catalogue", so a
157
+ * broken edit on disk never takes the running server down. */
158
+ async maybeReload() {
159
+ if (!this.path)
160
+ return { reloaded: false };
161
+ let mtimeMs;
162
+ try {
163
+ const s = await stat(this.path);
164
+ mtimeMs = s.mtimeMs;
165
+ }
166
+ catch (e) {
167
+ const code = e.code;
168
+ // File gone (ENOENT) — keep last good state. Re-creating the
169
+ // file will land in this branch's else on the next call when
170
+ // stat succeeds again with a fresh mtime.
171
+ if (code !== "ENOENT") {
172
+ console.warn(`[products] hot-reload stat(${this.path}) failed: ${e.message} — keeping previous catalogue`);
173
+ }
174
+ return { reloaded: false };
175
+ }
176
+ if (mtimeMs <= this.lastMtimeMs)
177
+ return { reloaded: false };
178
+ let next;
179
+ try {
180
+ next = await readProductsFile(this.path);
181
+ }
182
+ catch (e) {
183
+ // readProductsFile downgrades IO errors to EMPTY but lets
184
+ // parse errors (ProductsLoadError) propagate — so a broken
185
+ // YAML edit lands here, and we explicitly do NOT swap state.
186
+ console.warn(`[products] hot-reload of ${this.path} failed: ${e.message} — keeping previous catalogue`);
187
+ // Bump the mtime cursor anyway so we don't re-log the same
188
+ // failure on every subsequent request until the operator fixes
189
+ // the file (next save advances mtime past this value).
190
+ this.lastMtimeMs = mtimeMs;
191
+ return { reloaded: false };
192
+ }
193
+ this.file = next;
194
+ this.lastMtimeMs = mtimeMs;
195
+ return { reloaded: true };
196
+ }
197
+ /** Re-stat the source file and pin the mtime cursor to its current
198
+ * value. Call this after a successful write so the store does not
199
+ * treat its own change as an external reload trigger. Best-effort:
200
+ * if the stat fails, the next maybeReload() will simply reload the
201
+ * file once and find it identical. */
202
+ async pinMtimeAfterWrite() {
203
+ if (!this.path)
204
+ return;
205
+ try {
206
+ const s = await stat(this.path);
207
+ this.lastMtimeMs = s.mtimeMs;
208
+ }
209
+ catch {
210
+ // Silent — see method JSDoc.
211
+ }
139
212
  }
140
213
  /** Return the product list. When `tenant` is set, filters to that
141
214
  * tenant (entries without a tenant field treated as "default").
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { parseProductsText, ProductsStore, ProductsLoadError } from "./loader.js";
3
+ import { parseProductsText, ProductsStore, ProductsLoadError, readProductsFile } from "./loader.js";
4
4
  test("parseProductsText — empty/minimal products array", () => {
5
5
  const f = parseProductsText("products: []", "test");
6
6
  assert.deepEqual(f.products, []);
@@ -166,3 +166,92 @@ test("ProductsLoadError is the throw class", () => {
166
166
  }
167
167
  assert.fail("expected throw");
168
168
  });
169
+ test("ProductsStore.maybeReload — picks up out-of-band edits on next call", async () => {
170
+ const { mkdtemp, rm, writeFile, utimes } = await import("node:fs/promises");
171
+ const { tmpdir } = await import("node:os");
172
+ const { join } = await import("node:path");
173
+ const dir = await mkdtemp(join(tmpdir(), "omcp-products-reload-"));
174
+ try {
175
+ const file = join(dir, "products.yaml");
176
+ await writeFile(file, "products:\n - id: a\n name: A\n", "utf8");
177
+ const initial = await readProductsFile(file);
178
+ const store = new ProductsStore(initial, { path: file });
179
+ await store.pinMtimeAfterWrite();
180
+ assert.equal(store.list().length, 1);
181
+ assert.equal(store.list()[0].id, "a");
182
+ // Simulate an out-of-band edit. Bump mtime explicitly because
183
+ // some filesystems (WSL → 9P) round mtime to the second, so a
184
+ // back-to-back write can land in the same second and look
185
+ // unchanged to stat().
186
+ await writeFile(file, "products:\n - id: a\n name: A\n - id: b\n name: B\n", "utf8");
187
+ const future = new Date(Date.now() + 5_000);
188
+ await utimes(file, future, future);
189
+ const { reloaded } = await store.maybeReload();
190
+ assert.equal(reloaded, true);
191
+ assert.equal(store.list().length, 2);
192
+ // A second call with no further edit is a no-op.
193
+ const r2 = await store.maybeReload();
194
+ assert.equal(r2.reloaded, false);
195
+ }
196
+ finally {
197
+ await rm(dir, { recursive: true, force: true });
198
+ }
199
+ });
200
+ test("ProductsStore.maybeReload — broken YAML on disk keeps previous good state", async () => {
201
+ const { mkdtemp, rm, writeFile, utimes } = await import("node:fs/promises");
202
+ const { tmpdir } = await import("node:os");
203
+ const { join } = await import("node:path");
204
+ const dir = await mkdtemp(join(tmpdir(), "omcp-products-broken-"));
205
+ try {
206
+ const file = join(dir, "products.yaml");
207
+ await writeFile(file, "products:\n - id: a\n name: A\n", "utf8");
208
+ const store = new ProductsStore(await readProductsFile(file), { path: file });
209
+ await store.pinMtimeAfterWrite();
210
+ // Corrupt the file with an unknown top-level key — fails the
211
+ // strict typo guard inside parseProductsText.
212
+ await writeFile(file, "products:\n - id: a\n name: A\n junk: true\n", "utf8");
213
+ const future = new Date(Date.now() + 5_000);
214
+ await utimes(file, future, future);
215
+ const { reloaded } = await store.maybeReload();
216
+ // We did NOT swap state — caller sees the previous good catalogue.
217
+ assert.equal(reloaded, false);
218
+ assert.equal(store.list().length, 1);
219
+ assert.equal(store.list()[0].name, "A");
220
+ }
221
+ finally {
222
+ await rm(dir, { recursive: true, force: true });
223
+ }
224
+ });
225
+ test("ProductsStore.maybeReload — no path = no-op", async () => {
226
+ const store = new ProductsStore({ products: [{ id: "a", name: "A" }] });
227
+ const r = await store.maybeReload();
228
+ assert.equal(r.reloaded, false);
229
+ assert.equal(store.list().length, 1);
230
+ });
231
+ test("ProductsStore.pinMtimeAfterWrite — own writes do not trigger a redundant reload", async () => {
232
+ const { mkdtemp, rm, writeFile, utimes } = await import("node:fs/promises");
233
+ const { tmpdir } = await import("node:os");
234
+ const { join } = await import("node:path");
235
+ const { writeProductsFile } = await import("./loader.js");
236
+ const dir = await mkdtemp(join(tmpdir(), "omcp-products-pin-"));
237
+ try {
238
+ const file = join(dir, "products.yaml");
239
+ await writeFile(file, "products:\n - id: a\n name: A\n", "utf8");
240
+ const store = new ProductsStore(await readProductsFile(file), { path: file });
241
+ await store.pinMtimeAfterWrite();
242
+ // Simulate the server-side mutate-then-persist path.
243
+ store.upsert({ id: "b", name: "B" });
244
+ // Move mtime forward so writeProductsFile genuinely advances it
245
+ // past our cursor (1-second-resolution FS guard).
246
+ const future = new Date(Date.now() + 5_000);
247
+ await writeProductsFile(file, store.snapshot());
248
+ await utimes(file, future, future);
249
+ await store.pinMtimeAfterWrite();
250
+ const { reloaded } = await store.maybeReload();
251
+ assert.equal(reloaded, false, "own write must not re-trigger maybeReload");
252
+ assert.equal(store.list().length, 2);
253
+ }
254
+ finally {
255
+ await rm(dir, { recursive: true, force: true });
256
+ }
257
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Pure helper that turns a TokenBudget decision into either the
3
+ * original tool result (when allowed / uncapped) or a structured
4
+ * error payload distinguishing the two budget-denial cases:
5
+ *
6
+ * - OMCP_TOKEN_BUDGET_EXCEEDED — cumulative trailing-24h
7
+ * usage would push the principal past its cap. Waiting helps;
8
+ * `retryAfterSeconds` says how long until enough buckets drop
9
+ * off to fit the request.
10
+ *
11
+ * - OMCP_TOKEN_REQUEST_EXCEEDS_BUDGET — this single response is
12
+ * larger than the entire daily cap. Waiting does NOT help — the
13
+ * agent must narrow the query or the operator must raise the
14
+ * cap. `retryAfterSeconds` is 0 here so retry-with-backoff loops
15
+ * terminate instead of churning.
16
+ *
17
+ * Extracted from the createMcpServer closure in index.ts purely for
18
+ * unit-testability. Behaviour is identical to the previous inline
19
+ * version.
20
+ */
21
+ import type { CheckResult } from "./token-budget.js";
22
+ export interface ToolResult {
23
+ content: Array<{
24
+ text: string;
25
+ [k: string]: unknown;
26
+ }>;
27
+ }
28
+ export declare function applyBudgetDecision<T extends ToolResult>(result: T, decision: CheckResult, tokens: number, toolName: string): T;
@@ -0,0 +1,30 @@
1
+ export function applyBudgetDecision(result, decision, tokens, toolName) {
2
+ if (decision.allowed || decision.limit === 0)
3
+ return result;
4
+ // A request larger than the entire daily cap can never succeed by
5
+ // waiting — distinct error code so the agent doesn't spin.
6
+ const requestExceedsCap = tokens > decision.limit;
7
+ const errBody = {
8
+ error: requestExceedsCap ? "OMCP_TOKEN_REQUEST_EXCEEDS_BUDGET" : "OMCP_TOKEN_BUDGET_EXCEEDED",
9
+ tool: toolName,
10
+ used: decision.used,
11
+ limit: decision.limit,
12
+ requested: tokens,
13
+ retryAfterSeconds: requestExceedsCap ? 0 : decision.retryAfterSeconds,
14
+ freedAtRetry: decision.freedAtRetry,
15
+ message: requestExceedsCap
16
+ ? `This single response (~${tokens} tokens) is larger than the entire daily budget (${decision.limit}). Retrying won't help — narrow the query (smaller window / lower limit / more selective filter) or raise OMCP_TOOL_DAILY_TOKENS.`
17
+ : `Daily token budget exceeded (${decision.used}/${decision.limit} tokens used in the trailing 24h; this call would have added ~${tokens}). Try again in ~${Math.ceil(decision.retryAfterSeconds / 3600)}h or raise OMCP_TOOL_DAILY_TOKENS.`,
18
+ };
19
+ // Preserve any additional content entries (e.g. a future tool
20
+ // returning [text, image]) — only the text payload of the first
21
+ // entry is replaced with the error JSON; everything after passes
22
+ // through unchanged.
23
+ return {
24
+ ...result,
25
+ content: [
26
+ { ...result.content[0], text: JSON.stringify(errBody) },
27
+ ...result.content.slice(1),
28
+ ],
29
+ };
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,83 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { applyBudgetDecision } from "./charge.js";
4
+ function decision(over) {
5
+ return {
6
+ allowed: false,
7
+ used: 0,
8
+ limit: 1000,
9
+ retryAfterSeconds: 3600,
10
+ freedAtRetry: 100,
11
+ ...over,
12
+ };
13
+ }
14
+ const sampleResult = () => ({ content: [{ text: "original tool output" }] });
15
+ test("applyBudgetDecision — passes the result through when allowed", () => {
16
+ const r = sampleResult();
17
+ const out = applyBudgetDecision(r, decision({ allowed: true }), 50, "query_logs");
18
+ assert.equal(out, r, "exact passthrough when allowed");
19
+ });
20
+ test("applyBudgetDecision — passes through when uncapped (limit === 0)", () => {
21
+ const r = sampleResult();
22
+ const out = applyBudgetDecision(r, decision({ allowed: false, limit: 0 }), 50_000, "query_logs");
23
+ assert.equal(out.content[0].text, "original tool output");
24
+ });
25
+ test("applyBudgetDecision — cumulative exceed emits OMCP_TOKEN_BUDGET_EXCEEDED", () => {
26
+ // Tokens fit a single request (<= limit) but cumulative pushes over.
27
+ const r = sampleResult();
28
+ const out = applyBudgetDecision(r, decision({ used: 950, limit: 1000, retryAfterSeconds: 7200, freedAtRetry: 200 }), 100, "query_logs");
29
+ const body = JSON.parse(out.content[0].text);
30
+ assert.equal(body.error, "OMCP_TOKEN_BUDGET_EXCEEDED");
31
+ assert.equal(body.tool, "query_logs");
32
+ assert.equal(body.used, 950);
33
+ assert.equal(body.limit, 1000);
34
+ assert.equal(body.requested, 100);
35
+ assert.equal(body.retryAfterSeconds, 7200);
36
+ assert.equal(body.freedAtRetry, 200);
37
+ assert.match(body.message, /Daily token budget exceeded/);
38
+ assert.match(body.message, /Try again in ~2h/);
39
+ });
40
+ test("applyBudgetDecision — single request > limit emits the DISTINCT OMCP_TOKEN_REQUEST_EXCEEDS_BUDGET", () => {
41
+ // The whole point of the distinct code: an agent that sees this
42
+ // must NOT retry — waiting can never fit a request larger than the
43
+ // entire daily cap. retryAfterSeconds is forced to 0 so naive
44
+ // backoff loops terminate.
45
+ const r = sampleResult();
46
+ const out = applyBudgetDecision(r, decision({ used: 0, limit: 1000, retryAfterSeconds: 3600, freedAtRetry: 0 }), 5000, // request > limit
47
+ "query_metrics");
48
+ const body = JSON.parse(out.content[0].text);
49
+ assert.equal(body.error, "OMCP_TOKEN_REQUEST_EXCEEDS_BUDGET");
50
+ assert.equal(body.tool, "query_metrics");
51
+ assert.equal(body.requested, 5000);
52
+ assert.equal(body.limit, 1000);
53
+ assert.equal(body.retryAfterSeconds, 0, "retry-loop killer: 0 instead of inherited 3600");
54
+ assert.match(body.message, /larger than the entire daily budget/);
55
+ assert.match(body.message, /Retrying won't help/);
56
+ });
57
+ test("applyBudgetDecision — boundary: request == limit is NOT the request-exceeds-cap code", () => {
58
+ // A request exactly equal to the cap can theoretically succeed on
59
+ // an empty bucket — it's the cumulative-exceeded path, not the
60
+ // unconditional-deny path.
61
+ const r = sampleResult();
62
+ const out = applyBudgetDecision(r, decision({ used: 100, limit: 1000 }), 1000, "query_logs");
63
+ const body = JSON.parse(out.content[0].text);
64
+ assert.equal(body.error, "OMCP_TOKEN_BUDGET_EXCEEDED");
65
+ });
66
+ test("applyBudgetDecision — preserves additional content entries past the first", () => {
67
+ const r = {
68
+ content: [
69
+ { text: "first", extraField: 42 },
70
+ { text: "second" },
71
+ { text: "third" },
72
+ ],
73
+ };
74
+ const out = applyBudgetDecision(r, decision({}), 10, "t");
75
+ assert.equal(out.content.length, 3);
76
+ // First entry's text replaced; its other fields (extraField) preserved.
77
+ const body = JSON.parse(out.content[0].text);
78
+ assert.equal(body.error, "OMCP_TOKEN_BUDGET_EXCEEDED");
79
+ assert.equal(out.content[0].extraField, 42);
80
+ // Trailing entries pass through verbatim.
81
+ assert.equal(out.content[1].text, "second");
82
+ assert.equal(out.content[2].text, "third");
83
+ });
@@ -25,19 +25,40 @@
25
25
  * - unset / empty / non-numeric → DEFAULT_LIMIT_PER_MIN (60)
26
26
  * - `"0"` → DEFAULT_LIMIT_PER_MIN (limit=0 would deny every request,
27
27
  * which is almost never what an operator setting "0" wants — they
28
- * either mean "default" or "disable"; we treat it as "default" and
29
- * leave the explicit disable path on the roadmap)
28
+ * either mean "default" or "disable"; this function maps it to the
29
+ * default so they aren't accidentally locked out, and the explicit
30
+ * disable path is one of the UNLIMITED_TOKENS instead)
31
+ * - `"off"` / `"none"` / `"unlimited"` / `"disabled"` / `"false"`
32
+ * (case-insensitive) → Number.POSITIVE_INFINITY, which the
33
+ * `count >= limit` comparison in check() always allows. JSON
34
+ * serialisation renders Infinity as `null`; consumers can treat
35
+ * a null limit as "uncapped".
30
36
  * - negative → DEFAULT_LIMIT_PER_MIN (limit=-1 with the current
31
37
  * `count >= limit` check would also deny every request)
32
38
  * - any positive integer ≥ 1 → that value
33
39
  */
34
40
  export declare function resolveToolRatePerMin(raw: string | undefined): number;
35
41
  export interface LimiterConfig {
36
- /** Cap per identity per window. Defaults to 60. */
42
+ /** Default cap per identity per window. Defaults to 60. */
37
43
  limit?: number;
38
44
  /** Window length in milliseconds. Defaults to 60_000. */
39
45
  windowMs?: number;
46
+ /** Optional per-identity override. Returns the cap for the named
47
+ * identity, or undefined to fall back to the default `limit`.
48
+ * Useful for the OMCP_KEY_RATE_PER_MIN credential-level override
49
+ * (`agent=600;ci=240`) — admin gives a noisy automation a higher
50
+ * quota without affecting every other caller. Returning Infinity
51
+ * disables the cap for that identity (matches the global
52
+ * unlimited-token contract). */
53
+ limitFor?: (identity: string) => number | undefined;
40
54
  }
55
+ /** Parse OMCP_KEY_RATE_PER_MIN — `name=count;name2=count2`. Same
56
+ * shape as parseKeyTenants / parseKeyProducts so operators have one
57
+ * syntactic model across all per-credential overrides. Unknown
58
+ * counts (non-numeric / ≤ 0) silently skip. Magic disable tokens
59
+ * (off/none/unlimited/disabled/false) map to Infinity, same as the
60
+ * global OMCP_TOOL_RATE_PER_MIN. */
61
+ export declare function parseKeyRateLimits(raw: string | undefined): Map<string, number>;
41
62
  export interface CheckResult {
42
63
  /** True when the call is allowed (and the timestamp recorded). */
43
64
  allowed: boolean;
@@ -52,10 +73,14 @@ export interface CheckResult {
52
73
  retryAfterSeconds: number;
53
74
  }
54
75
  export declare class IdentityRateLimiter {
55
- private readonly limit;
76
+ private readonly defaultLimit;
56
77
  private readonly windowMs;
78
+ private readonly limitFor?;
57
79
  private readonly buckets;
58
80
  constructor(cfg?: LimiterConfig);
81
+ /** Resolved cap for one identity: the per-identity override wins
82
+ * when defined; otherwise the process-wide default applies. */
83
+ private resolveLimit;
59
84
  /** Record-and-test a call for the given identity. Returns the
60
85
  * decision plus enough context to render a 429 with Retry-After. */
61
86
  check(identity: string, now?: number): CheckResult;