@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,289 @@
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
+ import { readFile, writeFile, rename, stat } from "node:fs/promises";
24
+ import yaml from "js-yaml";
25
+ const EMPTY = { products: [] };
26
+ const VALID_STATUS = new Set(["published", "staging"]);
27
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
28
+ export class ProductsLoadError extends Error {
29
+ constructor(msg) {
30
+ super(msg);
31
+ this.name = "ProductsLoadError";
32
+ }
33
+ }
34
+ export async function readProductsFile(path) {
35
+ if (!path)
36
+ return EMPTY;
37
+ let text;
38
+ try {
39
+ text = await readFile(path, "utf8");
40
+ }
41
+ catch (e) {
42
+ const code = e.code;
43
+ if (code === "ENOENT")
44
+ return EMPTY;
45
+ console.warn(`[products] could not read ${path}: ${e.message} — starting with empty catalog`);
46
+ return EMPTY;
47
+ }
48
+ return parseProductsText(text, path);
49
+ }
50
+ export function parseProductsText(text, origin) {
51
+ let parsed;
52
+ try {
53
+ parsed = yaml.load(text);
54
+ }
55
+ catch (e) {
56
+ throw new ProductsLoadError(`${origin}: not valid YAML/JSON: ${e.message}`);
57
+ }
58
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
59
+ throw new ProductsLoadError(`${origin}: root must be an object with a 'products' array`);
60
+ }
61
+ const root = parsed;
62
+ const rawProducts = root.products;
63
+ if (!Array.isArray(rawProducts)) {
64
+ throw new ProductsLoadError(`${origin}: 'products' must be an array`);
65
+ }
66
+ const out = [];
67
+ const seenIds = new Set();
68
+ for (let i = 0; i < rawProducts.length; i++) {
69
+ const e = rawProducts[i];
70
+ if (!e || typeof e !== "object" || Array.isArray(e)) {
71
+ throw new ProductsLoadError(`${origin}: products[${i}] must be an object`);
72
+ }
73
+ const r = e;
74
+ if (typeof r.id !== "string" || !ID_RE.test(r.id)) {
75
+ throw new ProductsLoadError(`${origin}: products[${i}].id must be a string matching ${ID_RE}`);
76
+ }
77
+ if (seenIds.has(r.id)) {
78
+ throw new ProductsLoadError(`${origin}: duplicate product id '${r.id}'`);
79
+ }
80
+ seenIds.add(r.id);
81
+ if (typeof r.name !== "string" || !r.name) {
82
+ throw new ProductsLoadError(`${origin}: products[${i}].name must be a non-empty string`);
83
+ }
84
+ const p = { id: r.id, name: r.name };
85
+ if (r.description !== undefined) {
86
+ if (typeof r.description !== "string")
87
+ throw new ProductsLoadError(`${origin}: products[${i}].description must be a string`);
88
+ p.description = r.description;
89
+ }
90
+ if (r.tools !== undefined) {
91
+ if (!Array.isArray(r.tools) || !r.tools.every((t) => typeof t === "string")) {
92
+ throw new ProductsLoadError(`${origin}: products[${i}].tools must be an array of strings`);
93
+ }
94
+ p.tools = r.tools;
95
+ }
96
+ if (r.version !== undefined) {
97
+ if (typeof r.version !== "string")
98
+ throw new ProductsLoadError(`${origin}: products[${i}].version must be a string`);
99
+ p.version = r.version;
100
+ }
101
+ if (r.status !== undefined) {
102
+ if (typeof r.status !== "string" || !VALID_STATUS.has(r.status)) {
103
+ throw new ProductsLoadError(`${origin}: products[${i}].status must be one of ${[...VALID_STATUS].join(", ")}`);
104
+ }
105
+ p.status = r.status;
106
+ }
107
+ if (r.tenant !== undefined) {
108
+ if (typeof r.tenant !== "string")
109
+ throw new ProductsLoadError(`${origin}: products[${i}].tenant must be a string`);
110
+ p.tenant = r.tenant;
111
+ }
112
+ if (r.branding !== undefined) {
113
+ if (!r.branding || typeof r.branding !== "object" || Array.isArray(r.branding)) {
114
+ throw new ProductsLoadError(`${origin}: products[${i}].branding must be an object`);
115
+ }
116
+ const b = r.branding;
117
+ p.branding = {};
118
+ if (b.iconUrl !== undefined) {
119
+ if (typeof b.iconUrl !== "string")
120
+ throw new ProductsLoadError(`${origin}: products[${i}].branding.iconUrl must be a string`);
121
+ p.branding.iconUrl = b.iconUrl;
122
+ }
123
+ if (b.color !== undefined) {
124
+ if (typeof b.color !== "string")
125
+ throw new ProductsLoadError(`${origin}: products[${i}].branding.color must be a string`);
126
+ p.branding.color = b.color;
127
+ }
128
+ }
129
+ // Reject unexpected top-level keys — operator typo guard
130
+ for (const k of Object.keys(r)) {
131
+ if (!["id", "name", "description", "tools", "version", "branding", "status", "tenant"].includes(k)) {
132
+ throw new ProductsLoadError(`${origin}: products[${i}] has unexpected key '${k}'`);
133
+ }
134
+ }
135
+ out.push(p);
136
+ }
137
+ return { products: out };
138
+ }
139
+ /** In-memory store with tenant- and status-aware queries. */
140
+ export class ProductsStore {
141
+ file;
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 = {}) {
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
+ }
212
+ }
213
+ /** Return the product list. When `tenant` is set, filters to that
214
+ * tenant (entries without a tenant field treated as "default").
215
+ * When `includeStaging` is false (default), staging products are
216
+ * hidden from the result — admins should pass true. */
217
+ list(opts = {}) {
218
+ return this.file.products.filter((p) => {
219
+ if (opts.tenant) {
220
+ const pt = p.tenant || "default";
221
+ if (pt !== opts.tenant)
222
+ return false;
223
+ }
224
+ if (!opts.includeStaging && p.status === "staging")
225
+ return false;
226
+ return true;
227
+ });
228
+ }
229
+ /** Lookup by id. Cross-tenant gets return undefined when `tenant` set. */
230
+ get(id, tenant) {
231
+ const p = this.file.products.find((x) => x.id === id);
232
+ if (!p)
233
+ return undefined;
234
+ if (tenant && (p.tenant || "default") !== tenant)
235
+ return undefined;
236
+ return p;
237
+ }
238
+ count(tenant) {
239
+ return this.list({ tenant, includeStaging: true }).length;
240
+ }
241
+ replace(file) {
242
+ this.file = file;
243
+ }
244
+ /** Upsert (replace if id exists, else append). Returns the new
245
+ * ProductsFile so the caller can persist it. */
246
+ upsert(product) {
247
+ const i = this.file.products.findIndex((p) => p.id === product.id);
248
+ const next = this.file.products.slice();
249
+ if (i >= 0)
250
+ next[i] = product;
251
+ else
252
+ next.push(product);
253
+ this.file = { products: next };
254
+ return this.file;
255
+ }
256
+ /** Remove by id. Returns true when the product existed, false
257
+ * otherwise. Caller persists the resulting file. */
258
+ delete(id) {
259
+ const i = this.file.products.findIndex((p) => p.id === id);
260
+ if (i < 0)
261
+ return { removed: false, file: this.file };
262
+ const next = this.file.products.slice();
263
+ next.splice(i, 1);
264
+ this.file = { products: next };
265
+ return { removed: true, file: this.file };
266
+ }
267
+ /** Snapshot of the current file (for tests / persistence). */
268
+ snapshot() {
269
+ return { products: this.file.products.slice() };
270
+ }
271
+ }
272
+ /** Validate a single product entry by routing it through the same
273
+ * parser as the file format. Throws ProductsLoadError on any
274
+ * shape problem. Used by PUT /api/products/:id so a typo / wrong
275
+ * type / unknown key gets the same loud rejection a malformed
276
+ * file would. */
277
+ export function validateProduct(input, origin = "input") {
278
+ const wrapped = parseProductsText(yaml.dump({ products: [input] }), origin);
279
+ return wrapped.products[0];
280
+ }
281
+ /** Atomic write of the products file. Same tmp+rename pattern as
282
+ * the audit-chain + token-budget snapshot, so a crash mid-write
283
+ * leaves the previous file intact. */
284
+ export async function writeProductsFile(path, file) {
285
+ const text = yaml.dump(file, { sortKeys: false, lineWidth: 100 });
286
+ const tmp = path + ".tmp";
287
+ await writeFile(tmp, text, "utf8");
288
+ await rename(tmp, path);
289
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,257 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseProductsText, ProductsStore, ProductsLoadError, readProductsFile } from "./loader.js";
4
+ test("parseProductsText — empty/minimal products array", () => {
5
+ const f = parseProductsText("products: []", "test");
6
+ assert.deepEqual(f.products, []);
7
+ });
8
+ test("parseProductsText — happy path with full shape", () => {
9
+ const yaml = `
10
+ products:
11
+ - id: ops-bundle
12
+ name: Operations Bundle
13
+ description: Tools for incident response.
14
+ tools: [query_logs, query_metrics, get_service_health]
15
+ version: 1.2.0
16
+ status: published
17
+ branding:
18
+ iconUrl: https://example.test/icon.png
19
+ color: "#3178c6"
20
+ - id: dev-bundle
21
+ name: Developer Bundle
22
+ tools: [query_logs]
23
+ status: staging
24
+ `;
25
+ const f = parseProductsText(yaml, "test");
26
+ assert.equal(f.products.length, 2);
27
+ assert.equal(f.products[0].id, "ops-bundle");
28
+ assert.deepEqual(f.products[0].tools, ["query_logs", "query_metrics", "get_service_health"]);
29
+ assert.equal(f.products[0].status, "published");
30
+ assert.equal(f.products[0].branding?.color, "#3178c6");
31
+ assert.equal(f.products[1].status, "staging");
32
+ });
33
+ test("parseProductsText — rejects malformed root / non-array products", () => {
34
+ assert.throws(() => parseProductsText("[]", "t"), /root must be an object/);
35
+ assert.throws(() => parseProductsText("products: notalist", "t"), /'products' must be an array/);
36
+ });
37
+ test("parseProductsText — rejects bad id / missing name / duplicate id", () => {
38
+ assert.throws(() => parseProductsText("products:\n - id: '..bad'\n name: x", "t"), /id must be a string matching/);
39
+ assert.throws(() => parseProductsText("products:\n - id: ok\n name: ''", "t"), /name must be a non-empty string/);
40
+ assert.throws(() => parseProductsText("products:\n - id: dup\n name: A\n - id: dup\n name: B", "t"), /duplicate product id 'dup'/);
41
+ });
42
+ test("parseProductsText — rejects unknown status / wrong types", () => {
43
+ assert.throws(() => parseProductsText("products:\n - id: x\n name: X\n status: archived", "t"), /status must be one of/);
44
+ assert.throws(() => parseProductsText("products:\n - id: x\n name: X\n tools: 'string-not-array'", "t"), /tools must be an array/);
45
+ assert.throws(() => parseProductsText("products:\n - id: x\n name: X\n version: 42", "t"), /version must be a string/);
46
+ });
47
+ test("parseProductsText — rejects unexpected top-level keys (typo guard)", () => {
48
+ assert.throws(() => parseProductsText("products:\n - id: x\n name: X\n toolss: []", "t"), /unexpected key 'toolss'/);
49
+ });
50
+ test("parseProductsText — rejects malformed branding shape", () => {
51
+ assert.throws(() => parseProductsText("products:\n - id: x\n name: X\n branding: notobject", "t"), /branding must be an object/);
52
+ assert.throws(() => parseProductsText("products:\n - id: x\n name: X\n branding:\n iconUrl: 42", "t"), /branding.iconUrl must be a string/);
53
+ });
54
+ test("ProductsStore — list / get / count happy paths", () => {
55
+ const store = new ProductsStore({
56
+ products: [
57
+ { id: "a", name: "A", status: "published" },
58
+ { id: "b", name: "B", status: "staging" },
59
+ { id: "c", name: "C" }, // no explicit status → not "staging" → visible by default
60
+ ],
61
+ });
62
+ // Default: staging hidden
63
+ assert.equal(store.list().length, 2);
64
+ // Include staging
65
+ assert.equal(store.list({ includeStaging: true }).length, 3);
66
+ // get unfiltered
67
+ assert.equal(store.get("a")?.name, "A");
68
+ assert.equal(store.get("missing"), undefined);
69
+ // count includes everything
70
+ assert.equal(store.count(), 3);
71
+ });
72
+ test("ProductsStore — tenant filter scopes list / get / count", () => {
73
+ const store = new ProductsStore({
74
+ products: [
75
+ { id: "acme-ops", name: "Acme Ops", tenant: "acme" },
76
+ { id: "bigco-ops", name: "BigCo Ops", tenant: "bigco" },
77
+ { id: "shared", name: "Shared" }, // no tenant → "default"
78
+ ],
79
+ });
80
+ // Tenant-scoped
81
+ assert.equal(store.list({ tenant: "acme" }).length, 1);
82
+ assert.equal(store.get("acme-ops", "acme")?.name, "Acme Ops");
83
+ assert.equal(store.get("bigco-ops", "acme"), undefined, "cross-tenant get returns undefined");
84
+ assert.equal(store.count("default"), 1, "no-tenant entry counts under 'default'");
85
+ });
86
+ test("ProductsStore — staging hidden by default within a tenant filter", () => {
87
+ const store = new ProductsStore({
88
+ products: [
89
+ { id: "p1", name: "P1", tenant: "acme", status: "published" },
90
+ { id: "p2", name: "P2", tenant: "acme", status: "staging" },
91
+ ],
92
+ });
93
+ assert.equal(store.list({ tenant: "acme" }).length, 1, "staging is hidden");
94
+ assert.equal(store.list({ tenant: "acme", includeStaging: true }).length, 2);
95
+ });
96
+ test("ProductsStore.upsert — replaces existing, appends new", () => {
97
+ const store = new ProductsStore({
98
+ products: [
99
+ { id: "a", name: "Original" },
100
+ { id: "b", name: "Second" },
101
+ ],
102
+ });
103
+ // Replace existing
104
+ store.upsert({ id: "a", name: "Replaced" });
105
+ assert.equal(store.get("a")?.name, "Replaced");
106
+ assert.equal(store.count(), 2);
107
+ // Append new
108
+ store.upsert({ id: "c", name: "New" });
109
+ assert.equal(store.count(), 3);
110
+ assert.equal(store.get("c")?.name, "New");
111
+ });
112
+ test("ProductsStore.delete — returns removed flag + survivors", () => {
113
+ const store = new ProductsStore({
114
+ products: [{ id: "a", name: "A" }, { id: "b", name: "B" }],
115
+ });
116
+ const r1 = store.delete("a");
117
+ assert.equal(r1.removed, true);
118
+ assert.equal(store.count(), 1);
119
+ // Re-delete is a no-op
120
+ const r2 = store.delete("a");
121
+ assert.equal(r2.removed, false);
122
+ // Unknown id
123
+ const r3 = store.delete("nope");
124
+ assert.equal(r3.removed, false);
125
+ });
126
+ test("validateProduct — accepts a valid entry, rejects bad shape via same parser", async () => {
127
+ // Happy path
128
+ const p = await import("./loader.js").then((m) => m.validateProduct({ id: "x", name: "X" }));
129
+ assert.equal(p.name, "X");
130
+ // Bad shape uses the loader's strict rules
131
+ const { validateProduct } = await import("./loader.js");
132
+ assert.throws(() => validateProduct({ id: "x", name: "X", unknownKey: 1 }), /unexpected key 'unknownKey'/);
133
+ assert.throws(() => validateProduct({ id: "..bad", name: "X" }), /id must be a string matching/);
134
+ });
135
+ test("writeProductsFile + readProductsFile — atomic round-trip", async () => {
136
+ const { mkdtemp, rm } = await import("node:fs/promises");
137
+ const { tmpdir } = await import("node:os");
138
+ const { join } = await import("node:path");
139
+ const { writeProductsFile, readProductsFile } = await import("./loader.js");
140
+ const dir = await mkdtemp(join(tmpdir(), "omcp-products-"));
141
+ try {
142
+ const file = join(dir, "products.yaml");
143
+ await writeProductsFile(file, {
144
+ products: [
145
+ { id: "a", name: "A", status: "published" },
146
+ { id: "b", name: "B", tools: ["query_logs"], tenant: "acme" },
147
+ ],
148
+ });
149
+ const reloaded = await readProductsFile(file);
150
+ assert.equal(reloaded.products.length, 2);
151
+ assert.equal(reloaded.products[0].status, "published");
152
+ assert.equal(reloaded.products[1].tenant, "acme");
153
+ assert.deepEqual(reloaded.products[1].tools, ["query_logs"]);
154
+ }
155
+ finally {
156
+ await rm(dir, { recursive: true, force: true });
157
+ }
158
+ });
159
+ test("ProductsLoadError is the throw class", () => {
160
+ try {
161
+ parseProductsText("not-json", "t");
162
+ }
163
+ catch (e) {
164
+ assert.ok(e instanceof ProductsLoadError);
165
+ return;
166
+ }
167
+ assert.fail("expected throw");
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 {};