@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.
- package/config/products.yaml.example +48 -0
- package/dist/analysis/history.d.ts +70 -0
- package/dist/analysis/history.js +170 -0
- package/dist/analysis/history.test.d.ts +1 -0
- package/dist/analysis/history.test.js +141 -0
- package/dist/audit/log.d.ts +108 -0
- package/dist/audit/log.js +200 -0
- package/dist/audit/log.test.d.ts +1 -0
- package/dist/audit/log.test.js +147 -0
- package/dist/audit/middleware.d.ts +20 -0
- package/dist/audit/middleware.js +50 -0
- package/dist/audit/redaction-bypass.d.ts +67 -0
- package/dist/audit/redaction-bypass.js +64 -0
- package/dist/audit/redaction-bypass.test.d.ts +1 -0
- package/dist/audit/redaction-bypass.test.js +72 -0
- package/dist/audit/sinks/types.d.ts +18 -0
- package/dist/audit/sinks/types.js +1 -0
- package/dist/audit/sinks/webhook.d.ts +45 -0
- package/dist/audit/sinks/webhook.js +111 -0
- package/dist/audit/sinks/webhook.test.d.ts +1 -0
- package/dist/audit/sinks/webhook.test.js +162 -0
- package/dist/auth/credentials.d.ts +29 -0
- package/dist/auth/credentials.js +53 -1
- package/dist/auth/credentials.test.js +46 -1
- package/dist/auth/csrf.d.ts +26 -0
- package/dist/auth/csrf.js +128 -0
- package/dist/auth/csrf.test.d.ts +1 -0
- package/dist/auth/csrf.test.js +143 -0
- package/dist/auth/local-users.d.ts +68 -0
- package/dist/auth/local-users.js +154 -0
- package/dist/auth/local-users.test.d.ts +1 -0
- package/dist/auth/local-users.test.js +121 -0
- package/dist/auth/middleware.d.ts +49 -0
- package/dist/auth/middleware.js +65 -0
- package/dist/auth/middleware.test.d.ts +1 -0
- package/dist/auth/middleware.test.js +90 -0
- package/dist/auth/oidc/client.d.ts +73 -0
- package/dist/auth/oidc/client.js +104 -0
- package/dist/auth/oidc/client.test.d.ts +1 -0
- package/dist/auth/oidc/client.test.js +121 -0
- package/dist/auth/oidc/dcr.d.ts +70 -0
- package/dist/auth/oidc/dcr.js +160 -0
- package/dist/auth/oidc/dcr.test.d.ts +1 -0
- package/dist/auth/oidc/dcr.test.js +109 -0
- package/dist/auth/oidc/discovery.d.ts +38 -0
- package/dist/auth/oidc/discovery.js +48 -0
- package/dist/auth/oidc/discovery.test.d.ts +1 -0
- package/dist/auth/oidc/discovery.test.js +68 -0
- package/dist/auth/oidc/endpoints.d.ts +20 -0
- package/dist/auth/oidc/endpoints.js +168 -0
- package/dist/auth/oidc/endpoints.test.d.ts +7 -0
- package/dist/auth/oidc/endpoints.test.js +304 -0
- package/dist/auth/oidc/flow-cookie.d.ts +57 -0
- package/dist/auth/oidc/flow-cookie.js +142 -0
- package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
- package/dist/auth/oidc/flow-cookie.test.js +0 -0
- package/dist/auth/oidc/index.d.ts +7 -0
- package/dist/auth/oidc/index.js +6 -0
- package/dist/auth/oidc/jwks.d.ts +36 -0
- package/dist/auth/oidc/jwks.js +69 -0
- package/dist/auth/oidc/jwks.test.d.ts +1 -0
- package/dist/auth/oidc/jwks.test.js +65 -0
- package/dist/auth/oidc/jwt.d.ts +62 -0
- package/dist/auth/oidc/jwt.js +113 -0
- package/dist/auth/oidc/jwt.test.d.ts +1 -0
- package/dist/auth/oidc/jwt.test.js +141 -0
- package/dist/auth/oidc/pkce.d.ts +19 -0
- package/dist/auth/oidc/pkce.js +43 -0
- package/dist/auth/oidc/pkce.test.d.ts +1 -0
- package/dist/auth/oidc/pkce.test.js +55 -0
- package/dist/auth/oidc/profiles.d.ts +22 -0
- package/dist/auth/oidc/profiles.js +95 -0
- package/dist/auth/oidc/profiles.test.d.ts +1 -0
- package/dist/auth/oidc/profiles.test.js +51 -0
- package/dist/auth/oidc/runtime.d.ts +66 -0
- package/dist/auth/oidc/runtime.js +142 -0
- package/dist/auth/oidc/runtime.test.d.ts +1 -0
- package/dist/auth/oidc/runtime.test.js +181 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +129 -0
- package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
- package/dist/auth/policy/batch-dry-run.test.js +140 -0
- package/dist/auth/policy/engine.d.ts +64 -0
- package/dist/auth/policy/engine.js +87 -0
- package/dist/auth/policy/engine.test.d.ts +1 -0
- package/dist/auth/policy/engine.test.js +98 -0
- package/dist/auth/policy/loader.d.ts +45 -0
- package/dist/auth/policy/loader.js +137 -0
- package/dist/auth/policy/loader.test.d.ts +1 -0
- package/dist/auth/policy/loader.test.js +86 -0
- package/dist/auth/policy/opa.d.ts +69 -0
- package/dist/auth/policy/opa.js +173 -0
- package/dist/auth/policy/opa.test.d.ts +1 -0
- package/dist/auth/policy/opa.test.js +206 -0
- package/dist/auth/rbac.d.ts +62 -0
- package/dist/auth/rbac.js +162 -0
- package/dist/auth/rbac.test.d.ts +1 -0
- package/dist/auth/rbac.test.js +183 -0
- package/dist/auth/session.d.ts +66 -0
- package/dist/auth/session.js +146 -0
- package/dist/auth/session.test.d.ts +1 -0
- package/dist/auth/session.test.js +90 -0
- package/dist/catalog/loader.d.ts +67 -0
- package/dist/catalog/loader.js +122 -0
- package/dist/catalog/loader.test.d.ts +1 -0
- package/dist/catalog/loader.test.js +108 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/inspector-config.d.ts +9 -0
- package/dist/cli/inspector-config.js +28 -0
- package/dist/cli/inspector-config.test.d.ts +1 -0
- package/dist/cli/inspector-config.test.js +33 -0
- package/dist/cli/lib.d.ts +1 -1
- package/dist/cli/lib.js +1 -0
- package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
- package/dist/conformance/mcp-2025-11-25.test.js +206 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.js +6 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/prometheus.test.js +31 -13
- package/dist/connectors/registry.d.ts +13 -0
- package/dist/connectors/registry.js +30 -0
- package/dist/connectors/registry.test.js +56 -2
- package/dist/context.d.ts +45 -1
- package/dist/context.js +40 -1
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +32 -0
- package/dist/federation/registry.js +77 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +130 -0
- package/dist/federation/upstream.d.ts +60 -0
- package/dist/federation/upstream.js +114 -0
- package/dist/index.js +2124 -73
- package/dist/middleware/ssrfGuard.d.ts +15 -0
- package/dist/middleware/ssrfGuard.js +103 -0
- package/dist/middleware/ssrfGuard.test.d.ts +1 -0
- package/dist/middleware/ssrfGuard.test.js +81 -0
- package/dist/net/egress-policy.js +2 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.js +118 -0
- package/dist/observability/otel.test.d.ts +1 -0
- package/dist/observability/otel.test.js +56 -0
- package/dist/openapi.js +654 -6
- package/dist/openapi.test.d.ts +1 -0
- package/dist/openapi.test.js +98 -0
- package/dist/policy/redact.d.ts +44 -0
- package/dist/policy/redact.js +144 -0
- package/dist/policy/redact.test.d.ts +1 -0
- package/dist/policy/redact.test.js +172 -0
- package/dist/postmortem/synthesizer.d.ts +83 -0
- package/dist/postmortem/synthesizer.js +205 -0
- package/dist/postmortem/synthesizer.test.d.ts +1 -0
- package/dist/postmortem/synthesizer.test.js +141 -0
- package/dist/products/loader.d.ts +112 -0
- package/dist/products/loader.js +289 -0
- package/dist/products/loader.test.d.ts +1 -0
- package/dist/products/loader.test.js +257 -0
- package/dist/quota/charge.d.ts +28 -0
- package/dist/quota/charge.js +30 -0
- package/dist/quota/charge.test.d.ts +1 -0
- package/dist/quota/charge.test.js +83 -0
- package/dist/quota/limiter.d.ts +97 -0
- package/dist/quota/limiter.js +161 -0
- package/dist/quota/limiter.test.d.ts +1 -0
- package/dist/quota/limiter.test.js +205 -0
- package/dist/quota/token-budget.d.ts +119 -0
- package/dist/quota/token-budget.js +297 -0
- package/dist/quota/token-budget.test.d.ts +1 -0
- package/dist/quota/token-budget.test.js +215 -0
- package/dist/scim/group-role-map.d.ts +4 -0
- package/dist/scim/group-role-map.js +33 -0
- package/dist/scim/group-role-map.test.d.ts +1 -0
- package/dist/scim/group-role-map.test.js +33 -0
- package/dist/scim/routes.d.ts +15 -0
- package/dist/scim/routes.js +249 -0
- package/dist/scim/store.d.ts +37 -0
- package/dist/scim/store.js +178 -0
- package/dist/scim/store.test.d.ts +1 -0
- package/dist/scim/store.test.js +121 -0
- package/dist/scim/types.d.ts +73 -0
- package/dist/scim/types.js +29 -0
- package/dist/sdk/hooks.d.ts +77 -0
- package/dist/sdk/hooks.js +72 -0
- package/dist/sdk/hooks.test.d.ts +1 -0
- package/dist/sdk/hooks.test.js +159 -0
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/manifest-schema.d.ts +17 -0
- package/dist/sdk/manifest-schema.js +21 -0
- package/dist/tenancy/context.d.ts +45 -0
- package/dist/tenancy/context.js +97 -0
- package/dist/tenancy/context.test.d.ts +1 -0
- package/dist/tenancy/context.test.js +72 -0
- package/dist/tenancy/migration.test.d.ts +7 -0
- package/dist/tenancy/migration.test.js +75 -0
- package/dist/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +1 -1
- package/dist/tools/detect-anomalies.js +5 -4
- package/dist/tools/generate-postmortem.d.ts +35 -0
- package/dist/tools/generate-postmortem.js +191 -0
- package/dist/tools/get-anomaly-history.d.ts +35 -0
- package/dist/tools/get-anomaly-history.js +126 -0
- package/dist/tools/get-service-health.d.ts +1 -1
- package/dist/tools/get-service-health.js +4 -3
- package/dist/tools/list-services.d.ts +1 -1
- package/dist/tools/list-services.js +3 -2
- package/dist/tools/list-sources.d.ts +1 -1
- package/dist/tools/list-sources.js +6 -2
- package/dist/tools/query-logs.d.ts +1 -1
- package/dist/tools/query-logs.js +2 -2
- package/dist/tools/query-metrics.d.ts +1 -1
- package/dist/tools/query-metrics.js +19 -6
- package/dist/tools/query-traces.d.ts +47 -0
- package/dist/tools/query-traces.js +145 -0
- package/dist/tools/query-traces.test.d.ts +1 -0
- package/dist/tools/query-traces.test.js +110 -0
- package/dist/tools/registry-names.d.ts +35 -0
- package/dist/tools/registry-names.js +54 -0
- package/dist/tools/registry-names.test.d.ts +1 -0
- package/dist/tools/registry-names.test.js +61 -0
- package/dist/tools/topology.d.ts +3 -3
- package/dist/tools/topology.js +10 -6
- package/dist/topology/merge.d.ts +22 -0
- package/dist/topology/merge.js +178 -0
- package/dist/topology/merge.test.d.ts +1 -0
- package/dist/topology/merge.test.js +110 -0
- package/dist/transport/sessionStore.d.ts +66 -0
- package/dist/transport/sessionStore.js +138 -0
- package/dist/transport/sessionStore.test.d.ts +1 -0
- package/dist/transport/sessionStore.test.js +118 -0
- package/dist/transport/websocket.d.ts +35 -0
- package/dist/transport/websocket.js +133 -0
- package/dist/transport/websocket.test.d.ts +1 -0
- package/dist/transport/websocket.test.js +124 -0
- package/dist/types.d.ts +51 -0
- package/dist/ui/index.html +3083 -88
- 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 {};
|