@thotischner/observability-mcp 1.7.0 → 1.8.1

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 (111) hide show
  1. package/config/products.yaml.example +48 -0
  2. package/dist/audit/log.d.ts +99 -0
  3. package/dist/audit/log.js +180 -0
  4. package/dist/audit/log.test.d.ts +1 -0
  5. package/dist/audit/log.test.js +147 -0
  6. package/dist/audit/middleware.d.ts +20 -0
  7. package/dist/audit/middleware.js +50 -0
  8. package/dist/auth/credentials.d.ts +18 -0
  9. package/dist/auth/credentials.js +26 -1
  10. package/dist/auth/credentials.test.js +26 -1
  11. package/dist/auth/local-users.d.ts +62 -0
  12. package/dist/auth/local-users.js +143 -0
  13. package/dist/auth/local-users.test.d.ts +1 -0
  14. package/dist/auth/local-users.test.js +80 -0
  15. package/dist/auth/middleware.d.ts +48 -0
  16. package/dist/auth/middleware.js +65 -0
  17. package/dist/auth/middleware.test.d.ts +1 -0
  18. package/dist/auth/middleware.test.js +90 -0
  19. package/dist/auth/oidc/client.d.ts +73 -0
  20. package/dist/auth/oidc/client.js +104 -0
  21. package/dist/auth/oidc/client.test.d.ts +1 -0
  22. package/dist/auth/oidc/client.test.js +121 -0
  23. package/dist/auth/oidc/discovery.d.ts +38 -0
  24. package/dist/auth/oidc/discovery.js +48 -0
  25. package/dist/auth/oidc/discovery.test.d.ts +1 -0
  26. package/dist/auth/oidc/discovery.test.js +68 -0
  27. package/dist/auth/oidc/endpoints.d.ts +20 -0
  28. package/dist/auth/oidc/endpoints.js +124 -0
  29. package/dist/auth/oidc/endpoints.test.d.ts +7 -0
  30. package/dist/auth/oidc/endpoints.test.js +304 -0
  31. package/dist/auth/oidc/flow-cookie.d.ts +57 -0
  32. package/dist/auth/oidc/flow-cookie.js +142 -0
  33. package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
  34. package/dist/auth/oidc/flow-cookie.test.js +0 -0
  35. package/dist/auth/oidc/index.d.ts +7 -0
  36. package/dist/auth/oidc/index.js +6 -0
  37. package/dist/auth/oidc/jwks.d.ts +36 -0
  38. package/dist/auth/oidc/jwks.js +69 -0
  39. package/dist/auth/oidc/jwks.test.d.ts +1 -0
  40. package/dist/auth/oidc/jwks.test.js +65 -0
  41. package/dist/auth/oidc/jwt.d.ts +62 -0
  42. package/dist/auth/oidc/jwt.js +113 -0
  43. package/dist/auth/oidc/jwt.test.d.ts +1 -0
  44. package/dist/auth/oidc/jwt.test.js +141 -0
  45. package/dist/auth/oidc/pkce.d.ts +19 -0
  46. package/dist/auth/oidc/pkce.js +43 -0
  47. package/dist/auth/oidc/pkce.test.d.ts +1 -0
  48. package/dist/auth/oidc/pkce.test.js +55 -0
  49. package/dist/auth/oidc/runtime.d.ts +63 -0
  50. package/dist/auth/oidc/runtime.js +129 -0
  51. package/dist/auth/oidc/runtime.test.d.ts +1 -0
  52. package/dist/auth/oidc/runtime.test.js +180 -0
  53. package/dist/auth/policy/engine.d.ts +48 -0
  54. package/dist/auth/policy/engine.js +73 -0
  55. package/dist/auth/policy/engine.test.d.ts +1 -0
  56. package/dist/auth/policy/engine.test.js +98 -0
  57. package/dist/auth/policy/loader.d.ts +35 -0
  58. package/dist/auth/policy/loader.js +100 -0
  59. package/dist/auth/policy/opa.d.ts +69 -0
  60. package/dist/auth/policy/opa.js +162 -0
  61. package/dist/auth/policy/opa.test.d.ts +1 -0
  62. package/dist/auth/policy/opa.test.js +158 -0
  63. package/dist/auth/rbac.d.ts +40 -0
  64. package/dist/auth/rbac.js +120 -0
  65. package/dist/auth/rbac.test.d.ts +1 -0
  66. package/dist/auth/rbac.test.js +121 -0
  67. package/dist/auth/session.d.ts +66 -0
  68. package/dist/auth/session.js +146 -0
  69. package/dist/auth/session.test.d.ts +1 -0
  70. package/dist/auth/session.test.js +90 -0
  71. package/dist/catalog/loader.d.ts +67 -0
  72. package/dist/catalog/loader.js +122 -0
  73. package/dist/catalog/loader.test.d.ts +1 -0
  74. package/dist/catalog/loader.test.js +108 -0
  75. package/dist/connectors/kubernetes.d.ts +1 -0
  76. package/dist/connectors/kubernetes.js +12 -2
  77. package/dist/connectors/topology-vocabulary.d.ts +41 -0
  78. package/dist/connectors/topology-vocabulary.js +120 -0
  79. package/dist/connectors/topology-vocabulary.test.d.ts +1 -0
  80. package/dist/connectors/topology-vocabulary.test.js +63 -0
  81. package/dist/context.d.ts +13 -1
  82. package/dist/context.js +5 -1
  83. package/dist/index.js +1012 -29
  84. package/dist/net/egress-policy.js +2 -0
  85. package/dist/openapi.js +440 -0
  86. package/dist/openapi.test.d.ts +1 -0
  87. package/dist/openapi.test.js +64 -0
  88. package/dist/policy/redact.d.ts +44 -0
  89. package/dist/policy/redact.js +144 -0
  90. package/dist/policy/redact.test.d.ts +1 -0
  91. package/dist/policy/redact.test.js +172 -0
  92. package/dist/products/loader.d.ts +84 -0
  93. package/dist/products/loader.js +216 -0
  94. package/dist/products/loader.test.d.ts +1 -0
  95. package/dist/products/loader.test.js +168 -0
  96. package/dist/quota/limiter.d.ts +72 -0
  97. package/dist/quota/limiter.js +105 -0
  98. package/dist/quota/limiter.test.d.ts +1 -0
  99. package/dist/quota/limiter.test.js +119 -0
  100. package/dist/quota/token-budget.d.ts +119 -0
  101. package/dist/quota/token-budget.js +297 -0
  102. package/dist/quota/token-budget.test.d.ts +1 -0
  103. package/dist/quota/token-budget.test.js +215 -0
  104. package/dist/tenancy/context.d.ts +45 -0
  105. package/dist/tenancy/context.js +97 -0
  106. package/dist/tenancy/context.test.d.ts +1 -0
  107. package/dist/tenancy/context.test.js +72 -0
  108. package/dist/tenancy/migration.test.d.ts +7 -0
  109. package/dist/tenancy/migration.test.js +75 -0
  110. package/dist/ui/index.html +1454 -88
  111. package/package.json +20 -3
@@ -0,0 +1,168 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseProductsText, ProductsStore, ProductsLoadError } 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
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Per-identity sliding-window rate limiter for the MCP tool surface.
3
+ *
4
+ * The bearer-token credential resolved by `auth/credentials.ts`
5
+ * (`OMCP_API_KEYS`) names every distinct caller; this limiter caps how
6
+ * many MCP tool calls each named caller can make per minute. Anonymous
7
+ * MCP traffic (when no `OMCP_API_KEYS` is set) bypasses the per-identity
8
+ * cap — the existing express-rate-limit IP gate at the /mcp transport
9
+ * still applies.
10
+ *
11
+ * The window is sliding: each call records its timestamp under the
12
+ * identity's key, and `check()` first prunes entries older than the
13
+ * configured window before counting. Memory bound is O(callers × N)
14
+ * where N is the per-window cap — a few KB even for a busy deployment.
15
+ *
16
+ * Persistence is out of scope here. A future revision can plug a
17
+ * Redis-backed store via the same interface.
18
+ */
19
+ /** Resolve `OMCP_TOOL_RATE_PER_MIN` (or any equivalent caller-supplied
20
+ * string) into the per-identity cap used by the limiter and reported
21
+ * by `/api/info` + `/api/usage`. Single source of truth, so the three
22
+ * call sites don't drift.
23
+ *
24
+ * Behaviour:
25
+ * - unset / empty / non-numeric → DEFAULT_LIMIT_PER_MIN (60)
26
+ * - `"0"` → DEFAULT_LIMIT_PER_MIN (limit=0 would deny every request,
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)
30
+ * - negative → DEFAULT_LIMIT_PER_MIN (limit=-1 with the current
31
+ * `count >= limit` check would also deny every request)
32
+ * - any positive integer ≥ 1 → that value
33
+ */
34
+ export declare function resolveToolRatePerMin(raw: string | undefined): number;
35
+ export interface LimiterConfig {
36
+ /** Cap per identity per window. Defaults to 60. */
37
+ limit?: number;
38
+ /** Window length in milliseconds. Defaults to 60_000. */
39
+ windowMs?: number;
40
+ }
41
+ export interface CheckResult {
42
+ /** True when the call is allowed (and the timestamp recorded). */
43
+ allowed: boolean;
44
+ /** Number of calls already made in the current window (after counting this one if allowed). */
45
+ count: number;
46
+ /** Configured per-window cap. */
47
+ limit: number;
48
+ /** Window length in ms. */
49
+ windowMs: number;
50
+ /** Seconds until the oldest in-window record falls off and a new
51
+ * slot opens. 0 when allowed. */
52
+ retryAfterSeconds: number;
53
+ }
54
+ export declare class IdentityRateLimiter {
55
+ private readonly limit;
56
+ private readonly windowMs;
57
+ private readonly buckets;
58
+ constructor(cfg?: LimiterConfig);
59
+ /** Record-and-test a call for the given identity. Returns the
60
+ * decision plus enough context to render a 429 with Retry-After. */
61
+ check(identity: string, now?: number): CheckResult;
62
+ /** Read-only snapshot — useful for /api/usage and tests. */
63
+ inspect(identity: string, now?: number): {
64
+ count: number;
65
+ limit: number;
66
+ windowMs: number;
67
+ };
68
+ /** All identities we've ever seen — for /api/usage aggregation. */
69
+ knownIdentities(): string[];
70
+ /** For testing — reset every identity's bucket. */
71
+ reset(): void;
72
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Per-identity sliding-window rate limiter for the MCP tool surface.
3
+ *
4
+ * The bearer-token credential resolved by `auth/credentials.ts`
5
+ * (`OMCP_API_KEYS`) names every distinct caller; this limiter caps how
6
+ * many MCP tool calls each named caller can make per minute. Anonymous
7
+ * MCP traffic (when no `OMCP_API_KEYS` is set) bypasses the per-identity
8
+ * cap — the existing express-rate-limit IP gate at the /mcp transport
9
+ * still applies.
10
+ *
11
+ * The window is sliding: each call records its timestamp under the
12
+ * identity's key, and `check()` first prunes entries older than the
13
+ * configured window before counting. Memory bound is O(callers × N)
14
+ * where N is the per-window cap — a few KB even for a busy deployment.
15
+ *
16
+ * Persistence is out of scope here. A future revision can plug a
17
+ * Redis-backed store via the same interface.
18
+ */
19
+ const DEFAULT_LIMIT_PER_MIN = 60;
20
+ const DEFAULT_WINDOW_MS = 60_000;
21
+ /** Resolve `OMCP_TOOL_RATE_PER_MIN` (or any equivalent caller-supplied
22
+ * string) into the per-identity cap used by the limiter and reported
23
+ * by `/api/info` + `/api/usage`. Single source of truth, so the three
24
+ * call sites don't drift.
25
+ *
26
+ * Behaviour:
27
+ * - unset / empty / non-numeric → DEFAULT_LIMIT_PER_MIN (60)
28
+ * - `"0"` → DEFAULT_LIMIT_PER_MIN (limit=0 would deny every request,
29
+ * which is almost never what an operator setting "0" wants — they
30
+ * either mean "default" or "disable"; we treat it as "default" and
31
+ * leave the explicit disable path on the roadmap)
32
+ * - negative → DEFAULT_LIMIT_PER_MIN (limit=-1 with the current
33
+ * `count >= limit` check would also deny every request)
34
+ * - any positive integer ≥ 1 → that value
35
+ */
36
+ export function resolveToolRatePerMin(raw) {
37
+ if (raw === undefined || raw === "")
38
+ return DEFAULT_LIMIT_PER_MIN;
39
+ const n = Number(raw);
40
+ if (!Number.isFinite(n) || n < 1)
41
+ return DEFAULT_LIMIT_PER_MIN;
42
+ return Math.floor(n);
43
+ }
44
+ export class IdentityRateLimiter {
45
+ limit;
46
+ windowMs;
47
+ // identity → ring of millisecond timestamps, newest at the end.
48
+ buckets = new Map();
49
+ constructor(cfg = {}) {
50
+ this.limit = cfg.limit ?? DEFAULT_LIMIT_PER_MIN;
51
+ this.windowMs = cfg.windowMs ?? DEFAULT_WINDOW_MS;
52
+ }
53
+ /** Record-and-test a call for the given identity. Returns the
54
+ * decision plus enough context to render a 429 with Retry-After. */
55
+ check(identity, now = Date.now()) {
56
+ const cutoff = now - this.windowMs;
57
+ const bucket = this.buckets.get(identity) ?? [];
58
+ // Drop expired entries from the front of the bucket.
59
+ let i = 0;
60
+ while (i < bucket.length && bucket[i] <= cutoff)
61
+ i++;
62
+ const fresh = i === 0 ? bucket : bucket.slice(i);
63
+ if (fresh.length >= this.limit) {
64
+ // Compute when the oldest in-window record drops off.
65
+ const retryAfterMs = fresh[0] + this.windowMs - now;
66
+ // Don't store the call we just denied — that would push the
67
+ // window forward and starve the next legitimate request.
68
+ this.buckets.set(identity, fresh);
69
+ return {
70
+ allowed: false,
71
+ count: fresh.length,
72
+ limit: this.limit,
73
+ windowMs: this.windowMs,
74
+ retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
75
+ };
76
+ }
77
+ fresh.push(now);
78
+ this.buckets.set(identity, fresh);
79
+ return {
80
+ allowed: true,
81
+ count: fresh.length,
82
+ limit: this.limit,
83
+ windowMs: this.windowMs,
84
+ retryAfterSeconds: 0,
85
+ };
86
+ }
87
+ /** Read-only snapshot — useful for /api/usage and tests. */
88
+ inspect(identity, now = Date.now()) {
89
+ const cutoff = now - this.windowMs;
90
+ const bucket = this.buckets.get(identity) ?? [];
91
+ let count = 0;
92
+ for (const t of bucket)
93
+ if (t > cutoff)
94
+ count++;
95
+ return { count, limit: this.limit, windowMs: this.windowMs };
96
+ }
97
+ /** All identities we've ever seen — for /api/usage aggregation. */
98
+ knownIdentities() {
99
+ return Array.from(this.buckets.keys());
100
+ }
101
+ /** For testing — reset every identity's bucket. */
102
+ reset() {
103
+ this.buckets.clear();
104
+ }
105
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,119 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { IdentityRateLimiter, resolveToolRatePerMin } from "./limiter.js";
4
+ test("resolveToolRatePerMin — unset / empty / non-numeric returns default 60", () => {
5
+ assert.equal(resolveToolRatePerMin(undefined), 60);
6
+ assert.equal(resolveToolRatePerMin(""), 60);
7
+ assert.equal(resolveToolRatePerMin("not-a-number"), 60);
8
+ assert.equal(resolveToolRatePerMin("NaN"), 60);
9
+ });
10
+ test("resolveToolRatePerMin — zero / negative falls back to default (limit=0 would deny every call)", () => {
11
+ // Footgun pin: "0" looks like "disable" but the limiter treats it as
12
+ // "instantly over-cap". Treat as default so operators don't lock
13
+ // themselves out by mistake.
14
+ assert.equal(resolveToolRatePerMin("0"), 60);
15
+ assert.equal(resolveToolRatePerMin("-1"), 60);
16
+ assert.equal(resolveToolRatePerMin("-1000"), 60);
17
+ });
18
+ test("resolveToolRatePerMin — positive integer passes through; decimals floored", () => {
19
+ assert.equal(resolveToolRatePerMin("1"), 1);
20
+ assert.equal(resolveToolRatePerMin("120"), 120);
21
+ assert.equal(resolveToolRatePerMin("240"), 240);
22
+ assert.equal(resolveToolRatePerMin("60.7"), 60);
23
+ });
24
+ test("allows up to the configured limit, then denies", () => {
25
+ const lim = new IdentityRateLimiter({ limit: 3, windowMs: 60_000 });
26
+ const t = 1_700_000_000_000;
27
+ assert.equal(lim.check("alice", t + 0).allowed, true);
28
+ assert.equal(lim.check("alice", t + 100).allowed, true);
29
+ assert.equal(lim.check("alice", t + 200).allowed, true);
30
+ const denied = lim.check("alice", t + 300);
31
+ assert.equal(denied.allowed, false);
32
+ assert.equal(denied.count, 3);
33
+ assert.equal(denied.limit, 3);
34
+ assert.ok(denied.retryAfterSeconds >= 1);
35
+ });
36
+ test("sliding window: expired entries free up slots", () => {
37
+ const lim = new IdentityRateLimiter({ limit: 2, windowMs: 10_000 });
38
+ const t = 1_700_000_000_000;
39
+ lim.check("alice", t + 0);
40
+ lim.check("alice", t + 5_000);
41
+ // At t+9s alice is still at the cap.
42
+ assert.equal(lim.check("alice", t + 9_000).allowed, false);
43
+ // At t+11s the first entry has aged out → one slot opens.
44
+ const after = lim.check("alice", t + 11_000);
45
+ assert.equal(after.allowed, true);
46
+ assert.equal(after.count, 2);
47
+ });
48
+ test("identities are isolated from each other", () => {
49
+ const lim = new IdentityRateLimiter({ limit: 1, windowMs: 60_000 });
50
+ const t = 1_700_000_000_000;
51
+ assert.equal(lim.check("alice", t).allowed, true);
52
+ assert.equal(lim.check("alice", t).allowed, false);
53
+ // bob has his own fresh bucket.
54
+ assert.equal(lim.check("bob", t).allowed, true);
55
+ });
56
+ test("retryAfterSeconds points at the oldest in-window record's expiry", () => {
57
+ const lim = new IdentityRateLimiter({ limit: 1, windowMs: 30_000 });
58
+ const t = 1_700_000_000_000;
59
+ lim.check("alice", t);
60
+ const denied = lim.check("alice", t + 5_000);
61
+ assert.equal(denied.allowed, false);
62
+ // 30s window started at t, so expiry is t+30s → 25s from t+5s.
63
+ assert.equal(denied.retryAfterSeconds, 25);
64
+ });
65
+ test("denied calls do NOT push the window forward", () => {
66
+ const lim = new IdentityRateLimiter({ limit: 1, windowMs: 10_000 });
67
+ const t = 1_700_000_000_000;
68
+ lim.check("alice", t);
69
+ // Multiple denies — none of them should reset the oldest-timestamp.
70
+ for (let i = 1; i < 10; i++)
71
+ lim.check("alice", t + i * 100);
72
+ // Still expecting expiry at t+10s, not pushed forward by the denies.
73
+ const justAfterExpiry = lim.check("alice", t + 10_001);
74
+ assert.equal(justAfterExpiry.allowed, true);
75
+ });
76
+ test("inspect: returns counts without consuming a slot", () => {
77
+ const lim = new IdentityRateLimiter({ limit: 5, windowMs: 60_000 });
78
+ const t = 1_700_000_000_000;
79
+ lim.check("alice", t);
80
+ lim.check("alice", t);
81
+ const ins = lim.inspect("alice", t);
82
+ assert.equal(ins.count, 2);
83
+ assert.equal(ins.limit, 5);
84
+ // Subsequent check still has room for 3 more.
85
+ assert.equal(lim.check("alice", t).allowed, true);
86
+ });
87
+ test("knownIdentities — returns every identity that has been checked", () => {
88
+ const lim = new IdentityRateLimiter({ limit: 5, windowMs: 60_000 });
89
+ const t = 1_700_000_000_000;
90
+ lim.check("alice", t);
91
+ lim.check("bob", t);
92
+ lim.check("alice", t);
93
+ const ids = lim.knownIdentities().sort();
94
+ assert.deepEqual(ids, ["alice", "bob"]);
95
+ });
96
+ test("inspect on an unknown identity returns count=0", () => {
97
+ const lim = new IdentityRateLimiter({ limit: 5, windowMs: 60_000 });
98
+ const ins = lim.inspect("never-seen");
99
+ assert.equal(ins.count, 0);
100
+ assert.equal(ins.limit, 5);
101
+ });
102
+ test("reset clears all buckets", () => {
103
+ const lim = new IdentityRateLimiter({ limit: 1, windowMs: 60_000 });
104
+ const t = 1_700_000_000_000;
105
+ lim.check("alice", t);
106
+ lim.check("bob", t);
107
+ lim.reset();
108
+ assert.equal(lim.check("alice", t).allowed, true);
109
+ assert.equal(lim.check("bob", t).allowed, true);
110
+ });
111
+ test("default limit applies when constructed with no args", () => {
112
+ const lim = new IdentityRateLimiter();
113
+ // Exhaust the default 60/min cap.
114
+ const t = 1_700_000_000_000;
115
+ for (let i = 0; i < 60; i++) {
116
+ assert.equal(lim.check("alice", t + i).allowed, true);
117
+ }
118
+ assert.equal(lim.check("alice", t + 60).allowed, false);
119
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Per-identity token-budget tracker.
3
+ *
4
+ * The MCP transport gets per-call sliding-window cap from
5
+ * `IdentityRateLimiter`. Operators with paid-tier LLM agents want a
6
+ * second axis: a daily token quota that limits the number of tokens
7
+ * a credential can pull through the tool layer in a 24-hour rolling
8
+ * window. This module is the data-plane half of that knob.
9
+ *
10
+ * Token estimation:
11
+ * The MCP tool response (and the agent's request args) cross the
12
+ * boundary as JSON text. We don't tokenize with a real tokenizer
13
+ * here — pulling in tiktoken / gpt-tokenizer would add a non-trivial
14
+ * wasm/dep that the airgapped-friendly posture wants to avoid. The
15
+ * estimate uses a deliberate over-approximation:
16
+ * tokens ≈ ceil(chars / 4) * 1.05
17
+ * which tends to over-count by ~5% vs. cl100k_base on prose payloads
18
+ * and ~15% on dense code/JSON. Under-counting is the worse error
19
+ * mode for budget control, so the rounding direction is intentional.
20
+ *
21
+ * Window:
22
+ * 24h rolling, bucketed at 1-hour resolution to keep memory bounded.
23
+ * Each bucket records (hour-aligned timestamp, tokens). On every
24
+ * `check()` we drop buckets older than 24h and sum the rest.
25
+ *
26
+ * Persistence is OUT OF SCOPE for this slice (planned for E6/3). The
27
+ * in-memory tracker is constructed fresh at boot; restart-survival
28
+ * requires the persistence layer.
29
+ */
30
+ /** Estimate tokens from a string. Intentionally over-counts. */
31
+ export declare function estimateTokens(text: string): number;
32
+ /** Estimate tokens for an arbitrary JSON-serialisable value. */
33
+ export declare function estimateTokensFor(v: unknown): number;
34
+ export interface TokenBudgetConfig {
35
+ /** Daily cap in tokens per identity. 0 / undefined / negative
36
+ * disables the cap (the limiter never denies). */
37
+ dailyLimit?: number;
38
+ /** Override Date.now for tests. */
39
+ now?: () => number;
40
+ /** Optional path to a JSON snapshot file. When set, the tracker
41
+ * loads buckets on bootstrap() and atomically rewrites the
42
+ * snapshot on a debounced timer after each charge — so a server
43
+ * restart picks up the rolling 24h window where it left off.
44
+ * Unset → in-memory only (fine for demo / single-instance). */
45
+ filePath?: string;
46
+ /** Debounce window in ms for snapshot writes; default 1000. Tests
47
+ * pass 0 to flush synchronously between assertions. */
48
+ flushDebounceMs?: number;
49
+ }
50
+ export interface CheckResult {
51
+ allowed: boolean;
52
+ /** Tokens used in the trailing 24h window AFTER this call was
53
+ * counted (when allowed) — or as of now (when denied). */
54
+ used: number;
55
+ /** Configured daily cap. 0 means uncapped. */
56
+ limit: number;
57
+ /** Seconds until ENOUGH buckets drop off to fit the denied request.
58
+ * Walks the bucket list oldest-first and stops at the first
59
+ * timestamp where dropping every bucket older would free
60
+ * >= (used + tokens - limit) tokens. Rounded up. 0 when allowed
61
+ * (or when uncapped). */
62
+ retryAfterSeconds: number;
63
+ /** How many tokens will be available again at retryAfterSeconds.
64
+ * Useful for HTTP 429 bodies + Retry-After hints. 0 when allowed. */
65
+ freedAtRetry: number;
66
+ }
67
+ /** Per-identity 24h-rolling token budget with 1h buckets. */
68
+ export declare class TokenBudget {
69
+ private readonly limit;
70
+ private readonly now;
71
+ private readonly buckets;
72
+ private readonly filePath;
73
+ private readonly debounceMs;
74
+ private flushTimer;
75
+ private writeQueue;
76
+ private bootstrapped;
77
+ constructor(cfg?: TokenBudgetConfig);
78
+ /** Load a prior snapshot from disk (when filePath is set).
79
+ * Safe to call multiple times — bootstraps once and caches. */
80
+ bootstrap(): Promise<void>;
81
+ /** Record-and-test: does adding `tokens` keep `identity` under the
82
+ * daily cap? When `allowed`, the tokens are persisted into the
83
+ * bucket; when denied, they are NOT recorded (so a single huge
84
+ * request can't push the bucket arbitrarily over the cap and
85
+ * starve the rest of the window). */
86
+ check(identity: string, tokens: number, now?: number): CheckResult;
87
+ /** Read-only snapshot for /api/usage. */
88
+ inspect(identity: string, now?: number): {
89
+ used: number;
90
+ limit: number;
91
+ windowMs: number;
92
+ };
93
+ /** All identities the tracker has ever seen — for /api/usage aggregation. */
94
+ knownIdentities(): string[];
95
+ /** For tests — clear everything. */
96
+ reset(): void;
97
+ /** Internal: append `tokens` to the current hour's bucket for
98
+ * `identity`. Creates a new bucket when the hour boundary rolls. */
99
+ private record;
100
+ /** Debounce a snapshot write. No-op when filePath isn't configured. */
101
+ private scheduleFlush;
102
+ /** Write the current bucket state to disk atomically (tmp + rename).
103
+ * Public so a graceful shutdown can `await tokenBudget.flushNow()`. */
104
+ flushNow(): Promise<void>;
105
+ /** Internal: drop buckets older than 24h and return the remainder. */
106
+ private pruneOld;
107
+ private usedInWindow;
108
+ /** Walk the bucket list oldest-first until enough tokens would have
109
+ * dropped off to fit a request needing `needed` extra headroom.
110
+ * Returns the wait in ms + the cumulative freed tokens at that
111
+ * point. When `needed` exceeds the entire window's content (the
112
+ * caller wants more than the cap), returns the time until the
113
+ * newest bucket drops + everything freed. */
114
+ private nextEnoughHeadroom;
115
+ }
116
+ /** Parse OMCP_TOOL_DAILY_TOKENS into a daily limit. Mirrors the
117
+ * resolveToolRatePerMin pattern: unset / empty / non-numeric /
118
+ * zero / negative → uncapped (0). Positive integers pass through. */
119
+ export declare function resolveDailyTokenLimit(raw: string | undefined): number;