@thotischner/observability-mcp 1.8.1 → 3.0.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 (204) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/s3.d.ts +61 -0
  12. package/dist/audit/sinks/s3.js +179 -0
  13. package/dist/audit/sinks/s3.test.d.ts +1 -0
  14. package/dist/audit/sinks/s3.test.js +175 -0
  15. package/dist/audit/sinks/types.d.ts +18 -0
  16. package/dist/audit/sinks/types.js +1 -0
  17. package/dist/audit/sinks/webhook.d.ts +45 -0
  18. package/dist/audit/sinks/webhook.js +111 -0
  19. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  20. package/dist/audit/sinks/webhook.test.js +162 -0
  21. package/dist/auth/credentials.d.ts +11 -0
  22. package/dist/auth/credentials.js +27 -0
  23. package/dist/auth/credentials.test.js +21 -1
  24. package/dist/auth/csrf.d.ts +26 -0
  25. package/dist/auth/csrf.js +128 -0
  26. package/dist/auth/csrf.test.d.ts +1 -0
  27. package/dist/auth/csrf.test.js +143 -0
  28. package/dist/auth/local-users.d.ts +6 -0
  29. package/dist/auth/local-users.js +11 -0
  30. package/dist/auth/local-users.test.js +41 -0
  31. package/dist/auth/middleware.d.ts +7 -6
  32. package/dist/auth/oidc/dcr.d.ts +70 -0
  33. package/dist/auth/oidc/dcr.js +160 -0
  34. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  35. package/dist/auth/oidc/dcr.test.js +109 -0
  36. package/dist/auth/oidc/endpoints.js +44 -0
  37. package/dist/auth/oidc/profiles.d.ts +22 -0
  38. package/dist/auth/oidc/profiles.js +95 -0
  39. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  40. package/dist/auth/oidc/profiles.test.js +51 -0
  41. package/dist/auth/oidc/runtime.d.ts +3 -0
  42. package/dist/auth/oidc/runtime.js +16 -3
  43. package/dist/auth/oidc/runtime.test.js +1 -0
  44. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  45. package/dist/auth/policy/batch-dry-run.js +144 -0
  46. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  47. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  48. package/dist/auth/policy/engine.d.ts +20 -4
  49. package/dist/auth/policy/engine.js +16 -2
  50. package/dist/auth/policy/loader.d.ts +11 -1
  51. package/dist/auth/policy/loader.js +37 -0
  52. package/dist/auth/policy/loader.test.d.ts +1 -0
  53. package/dist/auth/policy/loader.test.js +86 -0
  54. package/dist/auth/policy/opa.d.ts +5 -5
  55. package/dist/auth/policy/opa.js +25 -14
  56. package/dist/auth/policy/opa.test.js +48 -0
  57. package/dist/auth/rbac.d.ts +23 -1
  58. package/dist/auth/rbac.js +43 -1
  59. package/dist/auth/rbac.test.js +62 -0
  60. package/dist/cli/index.js +3 -0
  61. package/dist/cli/inspector-config.d.ts +9 -0
  62. package/dist/cli/inspector-config.js +28 -0
  63. package/dist/cli/inspector-config.test.d.ts +1 -0
  64. package/dist/cli/inspector-config.test.js +33 -0
  65. package/dist/cli/lib.d.ts +1 -1
  66. package/dist/cli/lib.js +1 -0
  67. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  68. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  69. package/dist/connectors/interface.d.ts +5 -1
  70. package/dist/connectors/loader.d.ts +8 -0
  71. package/dist/connectors/loader.js +55 -4
  72. package/dist/connectors/loader.test.d.ts +1 -0
  73. package/dist/connectors/loader.test.js +78 -0
  74. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  75. package/dist/connectors/manifest-hooks.test.js +206 -0
  76. package/dist/connectors/prometheus.test.js +31 -13
  77. package/dist/connectors/registry.d.ts +13 -0
  78. package/dist/connectors/registry.js +30 -0
  79. package/dist/connectors/registry.test.js +56 -2
  80. package/dist/context.d.ts +32 -0
  81. package/dist/context.js +35 -0
  82. package/dist/context.test.d.ts +1 -0
  83. package/dist/context.test.js +58 -0
  84. package/dist/federation/registry.d.ts +54 -0
  85. package/dist/federation/registry.js +122 -0
  86. package/dist/federation/registry.test.d.ts +1 -0
  87. package/dist/federation/registry.test.js +206 -0
  88. package/dist/federation/upstream.d.ts +86 -0
  89. package/dist/federation/upstream.js +162 -0
  90. package/dist/federation/upstream.test.d.ts +1 -0
  91. package/dist/federation/upstream.test.js +118 -0
  92. package/dist/index.js +1435 -126
  93. package/dist/metrics/self.d.ts +1 -0
  94. package/dist/metrics/self.js +8 -0
  95. package/dist/middleware/ssrfGuard.d.ts +15 -0
  96. package/dist/middleware/ssrfGuard.js +103 -0
  97. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  98. package/dist/middleware/ssrfGuard.test.js +81 -0
  99. package/dist/observability/otel.d.ts +20 -0
  100. package/dist/observability/otel.js +118 -0
  101. package/dist/observability/otel.test.d.ts +1 -0
  102. package/dist/observability/otel.test.js +56 -0
  103. package/dist/openapi.js +215 -7
  104. package/dist/openapi.test.js +34 -0
  105. package/dist/policy/redact.js +1 -1
  106. package/dist/postmortem/store.d.ts +34 -0
  107. package/dist/postmortem/store.js +113 -0
  108. package/dist/postmortem/store.test.d.ts +1 -0
  109. package/dist/postmortem/store.test.js +118 -0
  110. package/dist/postmortem/synthesizer.d.ts +83 -0
  111. package/dist/postmortem/synthesizer.js +205 -0
  112. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  113. package/dist/postmortem/synthesizer.test.js +141 -0
  114. package/dist/products/loader.d.ts +31 -3
  115. package/dist/products/loader.js +77 -4
  116. package/dist/products/loader.test.js +90 -1
  117. package/dist/quota/charge.d.ts +28 -0
  118. package/dist/quota/charge.js +30 -0
  119. package/dist/quota/charge.test.d.ts +1 -0
  120. package/dist/quota/charge.test.js +83 -0
  121. package/dist/quota/limiter.d.ts +29 -4
  122. package/dist/quota/limiter.js +64 -8
  123. package/dist/quota/limiter.test.js +86 -0
  124. package/dist/scim/compliance.test.d.ts +1 -0
  125. package/dist/scim/compliance.test.js +169 -0
  126. package/dist/scim/factory.test.d.ts +1 -0
  127. package/dist/scim/factory.test.js +54 -0
  128. package/dist/scim/group-role-map.d.ts +4 -0
  129. package/dist/scim/group-role-map.js +33 -0
  130. package/dist/scim/group-role-map.test.d.ts +1 -0
  131. package/dist/scim/group-role-map.test.js +33 -0
  132. package/dist/scim/patch-ops.test.d.ts +1 -0
  133. package/dist/scim/patch-ops.test.js +100 -0
  134. package/dist/scim/redis-store.d.ts +38 -0
  135. package/dist/scim/redis-store.js +178 -0
  136. package/dist/scim/redis-store.test.d.ts +1 -0
  137. package/dist/scim/redis-store.test.js +138 -0
  138. package/dist/scim/routes.d.ts +40 -0
  139. package/dist/scim/routes.js +395 -0
  140. package/dist/scim/store.d.ts +76 -0
  141. package/dist/scim/store.js +196 -0
  142. package/dist/scim/store.test.d.ts +1 -0
  143. package/dist/scim/store.test.js +121 -0
  144. package/dist/scim/types.d.ts +73 -0
  145. package/dist/scim/types.js +29 -0
  146. package/dist/sdk/hook-wrappers.d.ts +39 -0
  147. package/dist/sdk/hook-wrappers.js +113 -0
  148. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  149. package/dist/sdk/hook-wrappers.test.js +204 -0
  150. package/dist/sdk/hooks.d.ts +77 -0
  151. package/dist/sdk/hooks.js +72 -0
  152. package/dist/sdk/hooks.test.d.ts +1 -0
  153. package/dist/sdk/hooks.test.js +159 -0
  154. package/dist/sdk/index.d.ts +15 -0
  155. package/dist/sdk/index.js +1 -0
  156. package/dist/sdk/manifest-schema.d.ts +17 -0
  157. package/dist/sdk/manifest-schema.js +21 -0
  158. package/dist/tools/context-seam.test.js +6 -1
  159. package/dist/tools/detect-anomalies.d.ts +12 -1
  160. package/dist/tools/detect-anomalies.js +26 -5
  161. package/dist/tools/generate-postmortem.d.ts +35 -0
  162. package/dist/tools/generate-postmortem.js +191 -0
  163. package/dist/tools/get-anomaly-history.d.ts +35 -0
  164. package/dist/tools/get-anomaly-history.js +126 -0
  165. package/dist/tools/get-service-health.d.ts +1 -1
  166. package/dist/tools/get-service-health.js +4 -3
  167. package/dist/tools/list-services.d.ts +1 -1
  168. package/dist/tools/list-services.js +3 -2
  169. package/dist/tools/list-sources.d.ts +1 -1
  170. package/dist/tools/list-sources.js +6 -2
  171. package/dist/tools/query-logs.d.ts +1 -1
  172. package/dist/tools/query-logs.js +2 -2
  173. package/dist/tools/query-metrics.d.ts +1 -1
  174. package/dist/tools/query-metrics.js +19 -6
  175. package/dist/tools/query-traces.d.ts +47 -0
  176. package/dist/tools/query-traces.js +145 -0
  177. package/dist/tools/query-traces.test.d.ts +1 -0
  178. package/dist/tools/query-traces.test.js +110 -0
  179. package/dist/tools/registry-names.d.ts +35 -0
  180. package/dist/tools/registry-names.js +54 -0
  181. package/dist/tools/registry-names.test.d.ts +1 -0
  182. package/dist/tools/registry-names.test.js +61 -0
  183. package/dist/tools/topology.d.ts +3 -3
  184. package/dist/tools/topology.js +33 -11
  185. package/dist/tools/topology.test.js +45 -0
  186. package/dist/topology/merge.d.ts +22 -0
  187. package/dist/topology/merge.js +178 -0
  188. package/dist/topology/merge.test.d.ts +1 -0
  189. package/dist/topology/merge.test.js +110 -0
  190. package/dist/transport/sessionStore.d.ts +66 -0
  191. package/dist/transport/sessionStore.js +138 -0
  192. package/dist/transport/sessionStore.test.d.ts +1 -0
  193. package/dist/transport/sessionStore.test.js +118 -0
  194. package/dist/transport/transportSessionMap.d.ts +70 -0
  195. package/dist/transport/transportSessionMap.js +128 -0
  196. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  197. package/dist/transport/transportSessionMap.test.js +111 -0
  198. package/dist/transport/websocket.d.ts +35 -0
  199. package/dist/transport/websocket.js +133 -0
  200. package/dist/transport/websocket.test.d.ts +1 -0
  201. package/dist/transport/websocket.test.js +124 -0
  202. package/dist/types.d.ts +51 -0
  203. package/dist/ui/index.html +2529 -145
  204. package/package.json +13 -3
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Pure helper that turns a TokenBudget decision into either the
3
+ * original tool result (when allowed / uncapped) or a structured
4
+ * error payload distinguishing the two budget-denial cases:
5
+ *
6
+ * - OMCP_TOKEN_BUDGET_EXCEEDED — cumulative trailing-24h
7
+ * usage would push the principal past its cap. Waiting helps;
8
+ * `retryAfterSeconds` says how long until enough buckets drop
9
+ * off to fit the request.
10
+ *
11
+ * - OMCP_TOKEN_REQUEST_EXCEEDS_BUDGET — this single response is
12
+ * larger than the entire daily cap. Waiting does NOT help — the
13
+ * agent must narrow the query or the operator must raise the
14
+ * cap. `retryAfterSeconds` is 0 here so retry-with-backoff loops
15
+ * terminate instead of churning.
16
+ *
17
+ * Extracted from the createMcpServer closure in index.ts purely for
18
+ * unit-testability. Behaviour is identical to the previous inline
19
+ * version.
20
+ */
21
+ import type { CheckResult } from "./token-budget.js";
22
+ export interface ToolResult {
23
+ content: Array<{
24
+ text: string;
25
+ [k: string]: unknown;
26
+ }>;
27
+ }
28
+ export declare function applyBudgetDecision<T extends ToolResult>(result: T, decision: CheckResult, tokens: number, toolName: string): T;
@@ -0,0 +1,30 @@
1
+ export function applyBudgetDecision(result, decision, tokens, toolName) {
2
+ if (decision.allowed || decision.limit === 0)
3
+ return result;
4
+ // A request larger than the entire daily cap can never succeed by
5
+ // waiting — distinct error code so the agent doesn't spin.
6
+ const requestExceedsCap = tokens > decision.limit;
7
+ const errBody = {
8
+ error: requestExceedsCap ? "OMCP_TOKEN_REQUEST_EXCEEDS_BUDGET" : "OMCP_TOKEN_BUDGET_EXCEEDED",
9
+ tool: toolName,
10
+ used: decision.used,
11
+ limit: decision.limit,
12
+ requested: tokens,
13
+ retryAfterSeconds: requestExceedsCap ? 0 : decision.retryAfterSeconds,
14
+ freedAtRetry: decision.freedAtRetry,
15
+ message: requestExceedsCap
16
+ ? `This single response (~${tokens} tokens) is larger than the entire daily budget (${decision.limit}). Retrying won't help — narrow the query (smaller window / lower limit / more selective filter) or raise OMCP_TOOL_DAILY_TOKENS.`
17
+ : `Daily token budget exceeded (${decision.used}/${decision.limit} tokens used in the trailing 24h; this call would have added ~${tokens}). Try again in ~${Math.ceil(decision.retryAfterSeconds / 3600)}h or raise OMCP_TOOL_DAILY_TOKENS.`,
18
+ };
19
+ // Preserve any additional content entries (e.g. a future tool
20
+ // returning [text, image]) — only the text payload of the first
21
+ // entry is replaced with the error JSON; everything after passes
22
+ // through unchanged.
23
+ return {
24
+ ...result,
25
+ content: [
26
+ { ...result.content[0], text: JSON.stringify(errBody) },
27
+ ...result.content.slice(1),
28
+ ],
29
+ };
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,83 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { applyBudgetDecision } from "./charge.js";
4
+ function decision(over) {
5
+ return {
6
+ allowed: false,
7
+ used: 0,
8
+ limit: 1000,
9
+ retryAfterSeconds: 3600,
10
+ freedAtRetry: 100,
11
+ ...over,
12
+ };
13
+ }
14
+ const sampleResult = () => ({ content: [{ text: "original tool output" }] });
15
+ test("applyBudgetDecision — passes the result through when allowed", () => {
16
+ const r = sampleResult();
17
+ const out = applyBudgetDecision(r, decision({ allowed: true }), 50, "query_logs");
18
+ assert.equal(out, r, "exact passthrough when allowed");
19
+ });
20
+ test("applyBudgetDecision — passes through when uncapped (limit === 0)", () => {
21
+ const r = sampleResult();
22
+ const out = applyBudgetDecision(r, decision({ allowed: false, limit: 0 }), 50_000, "query_logs");
23
+ assert.equal(out.content[0].text, "original tool output");
24
+ });
25
+ test("applyBudgetDecision — cumulative exceed emits OMCP_TOKEN_BUDGET_EXCEEDED", () => {
26
+ // Tokens fit a single request (<= limit) but cumulative pushes over.
27
+ const r = sampleResult();
28
+ const out = applyBudgetDecision(r, decision({ used: 950, limit: 1000, retryAfterSeconds: 7200, freedAtRetry: 200 }), 100, "query_logs");
29
+ const body = JSON.parse(out.content[0].text);
30
+ assert.equal(body.error, "OMCP_TOKEN_BUDGET_EXCEEDED");
31
+ assert.equal(body.tool, "query_logs");
32
+ assert.equal(body.used, 950);
33
+ assert.equal(body.limit, 1000);
34
+ assert.equal(body.requested, 100);
35
+ assert.equal(body.retryAfterSeconds, 7200);
36
+ assert.equal(body.freedAtRetry, 200);
37
+ assert.match(body.message, /Daily token budget exceeded/);
38
+ assert.match(body.message, /Try again in ~2h/);
39
+ });
40
+ test("applyBudgetDecision — single request > limit emits the DISTINCT OMCP_TOKEN_REQUEST_EXCEEDS_BUDGET", () => {
41
+ // The whole point of the distinct code: an agent that sees this
42
+ // must NOT retry — waiting can never fit a request larger than the
43
+ // entire daily cap. retryAfterSeconds is forced to 0 so naive
44
+ // backoff loops terminate.
45
+ const r = sampleResult();
46
+ const out = applyBudgetDecision(r, decision({ used: 0, limit: 1000, retryAfterSeconds: 3600, freedAtRetry: 0 }), 5000, // request > limit
47
+ "query_metrics");
48
+ const body = JSON.parse(out.content[0].text);
49
+ assert.equal(body.error, "OMCP_TOKEN_REQUEST_EXCEEDS_BUDGET");
50
+ assert.equal(body.tool, "query_metrics");
51
+ assert.equal(body.requested, 5000);
52
+ assert.equal(body.limit, 1000);
53
+ assert.equal(body.retryAfterSeconds, 0, "retry-loop killer: 0 instead of inherited 3600");
54
+ assert.match(body.message, /larger than the entire daily budget/);
55
+ assert.match(body.message, /Retrying won't help/);
56
+ });
57
+ test("applyBudgetDecision — boundary: request == limit is NOT the request-exceeds-cap code", () => {
58
+ // A request exactly equal to the cap can theoretically succeed on
59
+ // an empty bucket — it's the cumulative-exceeded path, not the
60
+ // unconditional-deny path.
61
+ const r = sampleResult();
62
+ const out = applyBudgetDecision(r, decision({ used: 100, limit: 1000 }), 1000, "query_logs");
63
+ const body = JSON.parse(out.content[0].text);
64
+ assert.equal(body.error, "OMCP_TOKEN_BUDGET_EXCEEDED");
65
+ });
66
+ test("applyBudgetDecision — preserves additional content entries past the first", () => {
67
+ const r = {
68
+ content: [
69
+ { text: "first", extraField: 42 },
70
+ { text: "second" },
71
+ { text: "third" },
72
+ ],
73
+ };
74
+ const out = applyBudgetDecision(r, decision({}), 10, "t");
75
+ assert.equal(out.content.length, 3);
76
+ // First entry's text replaced; its other fields (extraField) preserved.
77
+ const body = JSON.parse(out.content[0].text);
78
+ assert.equal(body.error, "OMCP_TOKEN_BUDGET_EXCEEDED");
79
+ assert.equal(out.content[0].extraField, 42);
80
+ // Trailing entries pass through verbatim.
81
+ assert.equal(out.content[1].text, "second");
82
+ assert.equal(out.content[2].text, "third");
83
+ });
@@ -25,19 +25,40 @@
25
25
  * - unset / empty / non-numeric → DEFAULT_LIMIT_PER_MIN (60)
26
26
  * - `"0"` → DEFAULT_LIMIT_PER_MIN (limit=0 would deny every request,
27
27
  * which is almost never what an operator setting "0" wants — they
28
- * either mean "default" or "disable"; we treat it as "default" and
29
- * leave the explicit disable path on the roadmap)
28
+ * either mean "default" or "disable"; this function maps it to the
29
+ * default so they aren't accidentally locked out, and the explicit
30
+ * disable path is one of the UNLIMITED_TOKENS instead)
31
+ * - `"off"` / `"none"` / `"unlimited"` / `"disabled"` / `"false"`
32
+ * (case-insensitive) → Number.POSITIVE_INFINITY, which the
33
+ * `count >= limit` comparison in check() always allows. JSON
34
+ * serialisation renders Infinity as `null`; consumers can treat
35
+ * a null limit as "uncapped".
30
36
  * - negative → DEFAULT_LIMIT_PER_MIN (limit=-1 with the current
31
37
  * `count >= limit` check would also deny every request)
32
38
  * - any positive integer ≥ 1 → that value
33
39
  */
34
40
  export declare function resolveToolRatePerMin(raw: string | undefined): number;
35
41
  export interface LimiterConfig {
36
- /** Cap per identity per window. Defaults to 60. */
42
+ /** Default cap per identity per window. Defaults to 60. */
37
43
  limit?: number;
38
44
  /** Window length in milliseconds. Defaults to 60_000. */
39
45
  windowMs?: number;
46
+ /** Optional per-identity override. Returns the cap for the named
47
+ * identity, or undefined to fall back to the default `limit`.
48
+ * Useful for the OMCP_KEY_RATE_PER_MIN credential-level override
49
+ * (`agent=600;ci=240`) — admin gives a noisy automation a higher
50
+ * quota without affecting every other caller. Returning Infinity
51
+ * disables the cap for that identity (matches the global
52
+ * unlimited-token contract). */
53
+ limitFor?: (identity: string) => number | undefined;
40
54
  }
55
+ /** Parse OMCP_KEY_RATE_PER_MIN — `name=count;name2=count2`. Same
56
+ * shape as parseKeyTenants / parseKeyProducts so operators have one
57
+ * syntactic model across all per-credential overrides. Unknown
58
+ * counts (non-numeric / ≤ 0) silently skip. Magic disable tokens
59
+ * (off/none/unlimited/disabled/false) map to Infinity, same as the
60
+ * global OMCP_TOOL_RATE_PER_MIN. */
61
+ export declare function parseKeyRateLimits(raw: string | undefined): Map<string, number>;
41
62
  export interface CheckResult {
42
63
  /** True when the call is allowed (and the timestamp recorded). */
43
64
  allowed: boolean;
@@ -52,10 +73,14 @@ export interface CheckResult {
52
73
  retryAfterSeconds: number;
53
74
  }
54
75
  export declare class IdentityRateLimiter {
55
- private readonly limit;
76
+ private readonly defaultLimit;
56
77
  private readonly windowMs;
78
+ private readonly limitFor?;
57
79
  private readonly buckets;
58
80
  constructor(cfg?: LimiterConfig);
81
+ /** Resolved cap for one identity: the per-identity override wins
82
+ * when defined; otherwise the process-wide default applies. */
83
+ private resolveLimit;
59
84
  /** Record-and-test a call for the given identity. Returns the
60
85
  * decision plus enough context to render a 429 with Retry-After. */
61
86
  check(identity: string, now?: number): CheckResult;
@@ -18,6 +18,11 @@
18
18
  */
19
19
  const DEFAULT_LIMIT_PER_MIN = 60;
20
20
  const DEFAULT_WINDOW_MS = 60_000;
21
+ /** Magic strings that explicitly disable the per-identity cap.
22
+ * Matched case-insensitively. Operators picked any of these to
23
+ * mean "no rate limit at all" — useful when the caps are enforced
24
+ * upstream (envoy / API-gateway) and OMCP shouldn't double-count. */
25
+ const UNLIMITED_TOKENS = new Set(["off", "none", "unlimited", "disabled", "false"]);
21
26
  /** Resolve `OMCP_TOOL_RATE_PER_MIN` (or any equivalent caller-supplied
22
27
  * string) into the per-identity cap used by the limiter and reported
23
28
  * by `/api/info` + `/api/usage`. Single source of truth, so the three
@@ -27,8 +32,14 @@ const DEFAULT_WINDOW_MS = 60_000;
27
32
  * - unset / empty / non-numeric → DEFAULT_LIMIT_PER_MIN (60)
28
33
  * - `"0"` → DEFAULT_LIMIT_PER_MIN (limit=0 would deny every request,
29
34
  * 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)
35
+ * either mean "default" or "disable"; this function maps it to the
36
+ * default so they aren't accidentally locked out, and the explicit
37
+ * disable path is one of the UNLIMITED_TOKENS instead)
38
+ * - `"off"` / `"none"` / `"unlimited"` / `"disabled"` / `"false"`
39
+ * (case-insensitive) → Number.POSITIVE_INFINITY, which the
40
+ * `count >= limit` comparison in check() always allows. JSON
41
+ * serialisation renders Infinity as `null`; consumers can treat
42
+ * a null limit as "uncapped".
32
43
  * - negative → DEFAULT_LIMIT_PER_MIN (limit=-1 with the current
33
44
  * `count >= limit` check would also deny every request)
34
45
  * - any positive integer ≥ 1 → that value
@@ -36,19 +47,63 @@ const DEFAULT_WINDOW_MS = 60_000;
36
47
  export function resolveToolRatePerMin(raw) {
37
48
  if (raw === undefined || raw === "")
38
49
  return DEFAULT_LIMIT_PER_MIN;
50
+ if (UNLIMITED_TOKENS.has(raw.trim().toLowerCase()))
51
+ return Number.POSITIVE_INFINITY;
39
52
  const n = Number(raw);
40
53
  if (!Number.isFinite(n) || n < 1)
41
54
  return DEFAULT_LIMIT_PER_MIN;
42
55
  return Math.floor(n);
43
56
  }
57
+ /** Parse OMCP_KEY_RATE_PER_MIN — `name=count;name2=count2`. Same
58
+ * shape as parseKeyTenants / parseKeyProducts so operators have one
59
+ * syntactic model across all per-credential overrides. Unknown
60
+ * counts (non-numeric / ≤ 0) silently skip. Magic disable tokens
61
+ * (off/none/unlimited/disabled/false) map to Infinity, same as the
62
+ * global OMCP_TOOL_RATE_PER_MIN. */
63
+ export function parseKeyRateLimits(raw) {
64
+ const m = new Map();
65
+ if (!raw)
66
+ return m;
67
+ for (const entry of raw.split(";").map((s) => s.trim()).filter(Boolean)) {
68
+ const eq = entry.indexOf("=");
69
+ if (eq <= 0)
70
+ continue;
71
+ const name = entry.slice(0, eq).trim();
72
+ const valueRaw = entry.slice(eq + 1).trim();
73
+ if (!name || !valueRaw)
74
+ continue;
75
+ if (UNLIMITED_TOKENS.has(valueRaw.toLowerCase())) {
76
+ m.set(name, Number.POSITIVE_INFINITY);
77
+ continue;
78
+ }
79
+ const n = Number(valueRaw);
80
+ if (!Number.isFinite(n) || n < 1)
81
+ continue;
82
+ m.set(name, Math.floor(n));
83
+ }
84
+ return m;
85
+ }
44
86
  export class IdentityRateLimiter {
45
- limit;
87
+ defaultLimit;
46
88
  windowMs;
89
+ limitFor;
47
90
  // identity → ring of millisecond timestamps, newest at the end.
48
91
  buckets = new Map();
49
92
  constructor(cfg = {}) {
50
- this.limit = cfg.limit ?? DEFAULT_LIMIT_PER_MIN;
93
+ this.defaultLimit = cfg.limit ?? DEFAULT_LIMIT_PER_MIN;
51
94
  this.windowMs = cfg.windowMs ?? DEFAULT_WINDOW_MS;
95
+ this.limitFor = cfg.limitFor;
96
+ }
97
+ /** Resolved cap for one identity: the per-identity override wins
98
+ * when defined; otherwise the process-wide default applies. */
99
+ resolveLimit(identity) {
100
+ if (this.limitFor) {
101
+ const v = this.limitFor(identity);
102
+ if (typeof v === "number" && (Number.isFinite(v) ? v >= 1 : v === Number.POSITIVE_INFINITY)) {
103
+ return v;
104
+ }
105
+ }
106
+ return this.defaultLimit;
52
107
  }
53
108
  /** Record-and-test a call for the given identity. Returns the
54
109
  * decision plus enough context to render a 429 with Retry-After. */
@@ -60,7 +115,8 @@ export class IdentityRateLimiter {
60
115
  while (i < bucket.length && bucket[i] <= cutoff)
61
116
  i++;
62
117
  const fresh = i === 0 ? bucket : bucket.slice(i);
63
- if (fresh.length >= this.limit) {
118
+ const limit = this.resolveLimit(identity);
119
+ if (fresh.length >= limit) {
64
120
  // Compute when the oldest in-window record drops off.
65
121
  const retryAfterMs = fresh[0] + this.windowMs - now;
66
122
  // Don't store the call we just denied — that would push the
@@ -69,7 +125,7 @@ export class IdentityRateLimiter {
69
125
  return {
70
126
  allowed: false,
71
127
  count: fresh.length,
72
- limit: this.limit,
128
+ limit,
73
129
  windowMs: this.windowMs,
74
130
  retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
75
131
  };
@@ -79,7 +135,7 @@ export class IdentityRateLimiter {
79
135
  return {
80
136
  allowed: true,
81
137
  count: fresh.length,
82
- limit: this.limit,
138
+ limit,
83
139
  windowMs: this.windowMs,
84
140
  retryAfterSeconds: 0,
85
141
  };
@@ -92,7 +148,7 @@ export class IdentityRateLimiter {
92
148
  for (const t of bucket)
93
149
  if (t > cutoff)
94
150
  count++;
95
- return { count, limit: this.limit, windowMs: this.windowMs };
151
+ return { count, limit: this.resolveLimit(identity), windowMs: this.windowMs };
96
152
  }
97
153
  /** All identities we've ever seen — for /api/usage aggregation. */
98
154
  knownIdentities() {
@@ -117,3 +117,89 @@ test("default limit applies when constructed with no args", () => {
117
117
  }
118
118
  assert.equal(lim.check("alice", t + 60).allowed, false);
119
119
  });
120
+ test("resolveToolRatePerMin — explicit-disable tokens map to Infinity (any case, with whitespace)", () => {
121
+ for (const tok of ["off", "OFF", "Off", "none", "NONE", "unlimited", "UNLIMITED", "disabled", "false", " off "]) {
122
+ assert.equal(resolveToolRatePerMin(tok), Number.POSITIVE_INFINITY, `'${tok}' should disable the limiter`);
123
+ }
124
+ });
125
+ test("IdentityRateLimiter — limit=Infinity always allows (the explicit-disable contract)", () => {
126
+ const lim = new IdentityRateLimiter({ limit: Number.POSITIVE_INFINITY });
127
+ const t = 1_700_000_000_000;
128
+ // Burst far past the default cap; every call must allow.
129
+ for (let i = 0; i < 1000; i++) {
130
+ const r = lim.check("alice", t + i);
131
+ assert.equal(r.allowed, true);
132
+ assert.equal(r.retryAfterSeconds, 0);
133
+ // Limit reflects the configured Infinity — JSON serialisation
134
+ // would render this as null; callers can branch on Number.isFinite.
135
+ assert.equal(r.limit, Number.POSITIVE_INFINITY);
136
+ }
137
+ });
138
+ test("resolveToolRatePerMin — disable tokens are NOT a number trap (\"infinity\" alone is not a token)", () => {
139
+ // We deliberately do NOT accept the literal string "infinity"
140
+ // because Number("Infinity") === Infinity — operators expect
141
+ // OMCP_TOOL_RATE_PER_MIN=Infinity to error out, not silently
142
+ // mean "unlimited". The explicit tokens are off/none/unlimited/
143
+ // disabled/false. (Number.isFinite is what the resolver checks.)
144
+ assert.equal(resolveToolRatePerMin("Infinity"), 60, "literal 'Infinity' must NOT secretly enable unlimited mode");
145
+ });
146
+ import { parseKeyRateLimits } from "./limiter.js";
147
+ test("parseKeyRateLimits — parses name=count pairs, skips malformed entries", () => {
148
+ const m = parseKeyRateLimits("agent=600;ci=240;bad;empty=;negative=-1;zero=0;notnum=abc");
149
+ assert.equal(m.get("agent"), 600);
150
+ assert.equal(m.get("ci"), 240);
151
+ assert.equal(m.get("bad"), undefined);
152
+ assert.equal(m.get("empty"), undefined);
153
+ assert.equal(m.get("negative"), undefined);
154
+ assert.equal(m.get("zero"), undefined);
155
+ assert.equal(m.get("notnum"), undefined);
156
+ });
157
+ test("parseKeyRateLimits — disable tokens map to Infinity (same vocabulary as the global override)", () => {
158
+ const m = parseKeyRateLimits("agent=off;ci=unlimited;loud=DISABLED");
159
+ assert.equal(m.get("agent"), Number.POSITIVE_INFINITY);
160
+ assert.equal(m.get("ci"), Number.POSITIVE_INFINITY);
161
+ assert.equal(m.get("loud"), Number.POSITIVE_INFINITY);
162
+ });
163
+ test("IdentityRateLimiter — limitFor override wins over the default cap", () => {
164
+ // Default is 60; override gives agent=2.
165
+ const lim = new IdentityRateLimiter({
166
+ limit: 60,
167
+ limitFor: (id) => (id === "default agent" ? 2 : undefined),
168
+ });
169
+ const t = 1_700_000_000_000;
170
+ // Two calls allowed for agent, third denies.
171
+ assert.equal(lim.check("default agent", t).allowed, true);
172
+ assert.equal(lim.check("default agent", t + 1).allowed, true);
173
+ const denied = lim.check("default agent", t + 2);
174
+ assert.equal(denied.allowed, false);
175
+ assert.equal(denied.limit, 2, "reported limit must be the per-identity override, not the default");
176
+ // Default identity still gets the global 60.
177
+ for (let i = 0; i < 60; i++) {
178
+ assert.equal(lim.check("default other", t + i).allowed, true);
179
+ }
180
+ assert.equal(lim.check("default other", t + 60).allowed, false);
181
+ });
182
+ test("IdentityRateLimiter — limitFor returning undefined falls back to default; Infinity disables for that identity", () => {
183
+ const lim = new IdentityRateLimiter({
184
+ limit: 5,
185
+ limitFor: (id) => (id === "default vip" ? Number.POSITIVE_INFINITY : undefined),
186
+ });
187
+ const t = 1_700_000_000_000;
188
+ // VIP can burst far past 5.
189
+ for (let i = 0; i < 1000; i++) {
190
+ assert.equal(lim.check("default vip", t + i).allowed, true);
191
+ }
192
+ // Default user still capped at 5.
193
+ for (let i = 0; i < 5; i++) {
194
+ assert.equal(lim.check("default user", t + i).allowed, true);
195
+ }
196
+ assert.equal(lim.check("default user", t + 5).allowed, false);
197
+ });
198
+ test("IdentityRateLimiter — inspect() reports the per-identity limit too (not just the default)", () => {
199
+ const lim = new IdentityRateLimiter({
200
+ limit: 60,
201
+ limitFor: (id) => (id === "default agent" ? 600 : undefined),
202
+ });
203
+ assert.equal(lim.inspect("default agent").limit, 600);
204
+ assert.equal(lim.inspect("default other").limit, 60);
205
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,169 @@
1
+ // SCIM 2.0 (RFC 7643 / 7644) compliance harness.
2
+ //
3
+ // Run against a running gateway with SCIM enabled by setting:
4
+ // OMCP_SCIM_COMPLIANCE_URL — the SCIM base, e.g.
5
+ // http://localhost:3000/scim/v2
6
+ // OMCP_SCIM_COMPLIANCE_TOKEN — the bearer matching OMCP_SCIM_TOKEN
7
+ //
8
+ // When the URL is unset every test skips — so the file lives happily
9
+ // in a plain `find src -name "*.test.ts"` unit run without needing a
10
+ // server. The `make scim-compliance` target boots the demo with SCIM
11
+ // configured, waits for /healthz, then runs this file.
12
+ //
13
+ // OMCP_SCIM_COMPLIANCE_URL=http://localhost:3000/scim/v2 \
14
+ // OMCP_SCIM_COMPLIANCE_TOKEN=secret \
15
+ // npx tsx --test src/scim/compliance.test.ts
16
+ //
17
+ // The suite is self-contained: it creates resources, exercises them,
18
+ // and deletes them, leaving the store as it found it.
19
+ import { test } from "node:test";
20
+ import assert from "node:assert/strict";
21
+ const BASE = process.env.OMCP_SCIM_COMPLIANCE_URL?.replace(/\/+$/, "");
22
+ const TOKEN = process.env.OMCP_SCIM_COMPLIANCE_TOKEN || "";
23
+ const skip = !BASE;
24
+ const opts = skip ? { skip: "OMCP_SCIM_COMPLIANCE_URL not set" } : {};
25
+ const SCHEMA_USER = "urn:ietf:params:scim:schemas:core:2.0:User";
26
+ const SCHEMA_GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group";
27
+ const SCHEMA_PATCH = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
28
+ const SCHEMA_LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
29
+ const SCHEMA_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error";
30
+ async function scim(method, path, body, withAuth = true) {
31
+ if (!BASE)
32
+ throw new Error("OMCP_SCIM_COMPLIANCE_URL not set");
33
+ const headers = { "content-type": "application/scim+json" };
34
+ if (withAuth)
35
+ headers["authorization"] = `Bearer ${TOKEN}`;
36
+ const res = await fetch(`${BASE}${path}`, {
37
+ method,
38
+ headers,
39
+ body: body === undefined ? undefined : JSON.stringify(body),
40
+ });
41
+ const h = {};
42
+ res.headers.forEach((v, k) => { h[k] = v; });
43
+ const text = await res.text();
44
+ let json = {};
45
+ if (text.trim().startsWith("{"))
46
+ json = JSON.parse(text);
47
+ return { status: res.status, json, headers: h };
48
+ }
49
+ // Resources created during the run, deleted in the final test.
50
+ const created = { users: [], groups: [] };
51
+ function uniq(p) { return `${p}-${Math.floor(performance.now() * 1000)}-${created.users.length + created.groups.length}`; }
52
+ // --- Discovery (RFC 7643 §5) -----------------------------------------
53
+ test("ServiceProviderConfig advertises the spec schema + patch support", opts, async () => {
54
+ const r = await scim("GET", "/ServiceProviderConfig");
55
+ assert.equal(r.status, 200);
56
+ assert.ok(r.json.schemas.includes("urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"));
57
+ assert.ok(r.json.patch, "patch capability block present");
58
+ });
59
+ test("ResourceTypes lists User + Group endpoints", opts, async () => {
60
+ const r = await scim("GET", "/ResourceTypes");
61
+ assert.equal(r.status, 200);
62
+ const txt = JSON.stringify(r.json);
63
+ assert.ok(txt.includes("/Users") && txt.includes("/Groups"));
64
+ });
65
+ test("Schemas endpoint returns the core schema definitions", opts, async () => {
66
+ const r = await scim("GET", "/Schemas");
67
+ assert.equal(r.status, 200);
68
+ const txt = JSON.stringify(r.json);
69
+ assert.ok(txt.includes(SCHEMA_USER) && txt.includes(SCHEMA_GROUP));
70
+ });
71
+ // --- Auth (RFC 7644 §2) ----------------------------------------------
72
+ test("requests without a bearer token are rejected 401", opts, async () => {
73
+ const r = await scim("GET", "/Users", undefined, false);
74
+ assert.equal(r.status, 401);
75
+ assert.ok(r.json.schemas?.includes(SCHEMA_ERROR));
76
+ });
77
+ // --- User lifecycle (RFC 7644 §3.3–3.6) ------------------------------
78
+ let userId = "";
79
+ const userName = uniq("compliance-user") + "@example.com";
80
+ test("POST /Users creates a user → 201 with id + meta", opts, async () => {
81
+ const r = await scim("POST", "/Users", {
82
+ schemas: [SCHEMA_USER],
83
+ userName,
84
+ name: { givenName: "Comp", familyName: "Liance" },
85
+ emails: [{ value: userName, primary: true }],
86
+ active: true,
87
+ });
88
+ assert.equal(r.status, 201);
89
+ assert.equal(typeof r.json.id, "string");
90
+ assert.equal(r.json.userName, userName);
91
+ const meta = r.json.meta;
92
+ assert.equal(meta.resourceType, "User");
93
+ assert.ok(meta.created && meta.lastModified);
94
+ userId = r.json.id;
95
+ created.users.push(userId);
96
+ });
97
+ test("duplicate userName is rejected 409 uniqueness", opts, async () => {
98
+ const r = await scim("POST", "/Users", { schemas: [SCHEMA_USER], userName });
99
+ assert.equal(r.status, 409);
100
+ assert.equal(r.json.scimType, "uniqueness");
101
+ });
102
+ test("GET /Users/:id returns the created user", opts, async () => {
103
+ const r = await scim("GET", `/Users/${userId}`);
104
+ assert.equal(r.status, 200);
105
+ assert.equal(r.json.id, userId);
106
+ assert.equal(r.json.userName, userName);
107
+ });
108
+ test("GET /Users returns a ListResponse envelope", opts, async () => {
109
+ const r = await scim("GET", "/Users");
110
+ assert.equal(r.status, 200);
111
+ assert.ok(r.json.schemas.includes(SCHEMA_LIST));
112
+ assert.equal(typeof r.json.totalResults, "number");
113
+ assert.ok(Array.isArray(r.json.Resources));
114
+ });
115
+ test("PATCH /Users/:id replace toggles active", opts, async () => {
116
+ const r = await scim("PATCH", `/Users/${userId}`, {
117
+ schemas: [SCHEMA_PATCH],
118
+ Operations: [{ op: "replace", path: "active", value: false }],
119
+ });
120
+ assert.equal(r.status, 200);
121
+ assert.equal(r.json.active, false);
122
+ });
123
+ test("unknown user id → 404 with SCIM error schema", opts, async () => {
124
+ const r = await scim("GET", "/Users/does-not-exist");
125
+ assert.equal(r.status, 404);
126
+ assert.ok(r.json.schemas.includes(SCHEMA_ERROR));
127
+ });
128
+ // --- Group lifecycle + membership PATCH (Q14) ------------------------
129
+ let groupId = "";
130
+ test("POST /Groups creates a group", opts, async () => {
131
+ const r = await scim("POST", "/Groups", { schemas: [SCHEMA_GROUP], displayName: uniq("compliance-grp") });
132
+ assert.equal(r.status, 201);
133
+ groupId = r.json.id;
134
+ created.groups.push(groupId);
135
+ assert.equal(r.json.meta.resourceType, "Group");
136
+ });
137
+ test("PATCH /Groups/:id add member → membership reflected", opts, async () => {
138
+ const r = await scim("PATCH", `/Groups/${groupId}`, {
139
+ schemas: [SCHEMA_PATCH],
140
+ Operations: [{ op: "add", path: "members", value: [{ value: userId, display: userName }] }],
141
+ });
142
+ assert.equal(r.status, 200);
143
+ const members = r.json.members || [];
144
+ assert.ok(members.some((m) => m.value === userId), "added member present");
145
+ });
146
+ test("PATCH /Groups/:id remove member by filter → membership cleared", opts, async () => {
147
+ const r = await scim("PATCH", `/Groups/${groupId}`, {
148
+ schemas: [SCHEMA_PATCH],
149
+ Operations: [{ op: "remove", path: `members[value eq "${userId}"]` }],
150
+ });
151
+ assert.equal(r.status, 200);
152
+ const members = r.json.members || [];
153
+ assert.ok(!members.some((m) => m.value === userId), "removed member gone");
154
+ });
155
+ // --- Cleanup (DELETE → 204, then 404) --------------------------------
156
+ test("DELETE created resources → 204 and subsequent GET → 404", opts, async () => {
157
+ for (const id of created.groups) {
158
+ const del = await scim("DELETE", `/Groups/${id}`);
159
+ assert.ok(del.status === 204 || del.status === 200, `group delete status ${del.status}`);
160
+ const get = await scim("GET", `/Groups/${id}`);
161
+ assert.equal(get.status, 404);
162
+ }
163
+ for (const id of created.users) {
164
+ const del = await scim("DELETE", `/Users/${id}`);
165
+ assert.ok(del.status === 204 || del.status === 200, `user delete status ${del.status}`);
166
+ const get = await scim("GET", `/Users/${id}`);
167
+ assert.equal(get.status, 404);
168
+ }
169
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createScimStore, ScimStore } from "./store.js";
7
+ import { RedisScimStore } from "./redis-store.js";
8
+ function tmpStorePath() {
9
+ return join(mkdtempSync(join(tmpdir(), "omcp-scim-factory-")), "scim.json");
10
+ }
11
+ test("createScimStore default = file backend", async () => {
12
+ const path = tmpStorePath();
13
+ delete process.env.OMCP_SCIM_BACKEND;
14
+ const store = await createScimStore({ path });
15
+ assert.ok(store instanceof ScimStore);
16
+ // The store is usable
17
+ const u = await store.createUser({ userName: "a@x" });
18
+ assert.equal(u.userName, "a@x");
19
+ });
20
+ test("createScimStore explicit backend=file", async () => {
21
+ const store = await createScimStore({ backend: "file", path: tmpStorePath() });
22
+ assert.ok(store instanceof ScimStore);
23
+ });
24
+ test("createScimStore backend=redis requires a client", async () => {
25
+ await assert.rejects(createScimStore({ backend: "redis" }), /backend=redis requires a redis client/);
26
+ });
27
+ test("createScimStore backend=redis returns RedisScimStore", async () => {
28
+ const fake = {
29
+ _s: new Map(),
30
+ async get(k) { return this._s.has(k) ? this._s.get(k) : null; },
31
+ async set(k, v) { this._s.set(k, v); return "OK"; },
32
+ };
33
+ const store = await createScimStore({ backend: "redis", redis: fake });
34
+ assert.ok(store instanceof RedisScimStore);
35
+ const u = await store.createUser({ userName: "u@x" });
36
+ assert.equal(u.userName, "u@x");
37
+ // Persisted to the fake redis
38
+ assert.ok(fake._s.has("omcp:scim:snapshot"));
39
+ });
40
+ test("createScimStore reads OMCP_SCIM_BACKEND env when no explicit backend", async () => {
41
+ process.env.OMCP_SCIM_BACKEND = "redis";
42
+ try {
43
+ const fake = {
44
+ _s: new Map(),
45
+ async get(k) { return this._s.get(k) ?? null; },
46
+ async set(k, v) { this._s.set(k, v); return "OK"; },
47
+ };
48
+ const store = await createScimStore({ redis: fake });
49
+ assert.ok(store instanceof RedisScimStore);
50
+ }
51
+ finally {
52
+ delete process.env.OMCP_SCIM_BACKEND;
53
+ }
54
+ });
@@ -0,0 +1,4 @@
1
+ export declare function parseScimGroupRoleMap(raw: string | undefined): Map<string, string>;
2
+ /** Map a user's group-display-names to the gateway's RBAC roles.
3
+ * Unknown groups are silently dropped (least-privilege). */
4
+ export declare function rolesForGroups(groupDisplayNames: string[], map: Map<string, string>): string[];