@thotischner/observability-mcp 1.8.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/types.d.ts +18 -0
  12. package/dist/audit/sinks/types.js +1 -0
  13. package/dist/audit/sinks/webhook.d.ts +45 -0
  14. package/dist/audit/sinks/webhook.js +111 -0
  15. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  16. package/dist/audit/sinks/webhook.test.js +162 -0
  17. package/dist/auth/credentials.d.ts +11 -0
  18. package/dist/auth/credentials.js +27 -0
  19. package/dist/auth/credentials.test.js +21 -1
  20. package/dist/auth/csrf.d.ts +26 -0
  21. package/dist/auth/csrf.js +128 -0
  22. package/dist/auth/csrf.test.d.ts +1 -0
  23. package/dist/auth/csrf.test.js +143 -0
  24. package/dist/auth/local-users.d.ts +6 -0
  25. package/dist/auth/local-users.js +11 -0
  26. package/dist/auth/local-users.test.js +41 -0
  27. package/dist/auth/middleware.d.ts +7 -6
  28. package/dist/auth/oidc/dcr.d.ts +70 -0
  29. package/dist/auth/oidc/dcr.js +160 -0
  30. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  31. package/dist/auth/oidc/dcr.test.js +109 -0
  32. package/dist/auth/oidc/endpoints.js +44 -0
  33. package/dist/auth/oidc/profiles.d.ts +22 -0
  34. package/dist/auth/oidc/profiles.js +95 -0
  35. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  36. package/dist/auth/oidc/profiles.test.js +51 -0
  37. package/dist/auth/oidc/runtime.d.ts +3 -0
  38. package/dist/auth/oidc/runtime.js +16 -3
  39. package/dist/auth/oidc/runtime.test.js +1 -0
  40. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  41. package/dist/auth/policy/batch-dry-run.js +129 -0
  42. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  43. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  44. package/dist/auth/policy/engine.d.ts +20 -4
  45. package/dist/auth/policy/engine.js +16 -2
  46. package/dist/auth/policy/loader.d.ts +11 -1
  47. package/dist/auth/policy/loader.js +37 -0
  48. package/dist/auth/policy/loader.test.d.ts +1 -0
  49. package/dist/auth/policy/loader.test.js +86 -0
  50. package/dist/auth/policy/opa.d.ts +5 -5
  51. package/dist/auth/policy/opa.js +25 -14
  52. package/dist/auth/policy/opa.test.js +48 -0
  53. package/dist/auth/rbac.d.ts +23 -1
  54. package/dist/auth/rbac.js +43 -1
  55. package/dist/auth/rbac.test.js +62 -0
  56. package/dist/cli/index.js +3 -0
  57. package/dist/cli/inspector-config.d.ts +9 -0
  58. package/dist/cli/inspector-config.js +28 -0
  59. package/dist/cli/inspector-config.test.d.ts +1 -0
  60. package/dist/cli/inspector-config.test.js +33 -0
  61. package/dist/cli/lib.d.ts +1 -1
  62. package/dist/cli/lib.js +1 -0
  63. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  64. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  65. package/dist/connectors/interface.d.ts +5 -1
  66. package/dist/connectors/loader.js +6 -4
  67. package/dist/connectors/loader.test.d.ts +1 -0
  68. package/dist/connectors/loader.test.js +78 -0
  69. package/dist/connectors/prometheus.test.js +31 -13
  70. package/dist/connectors/registry.d.ts +13 -0
  71. package/dist/connectors/registry.js +30 -0
  72. package/dist/connectors/registry.test.js +56 -2
  73. package/dist/context.d.ts +32 -0
  74. package/dist/context.js +35 -0
  75. package/dist/context.test.d.ts +1 -0
  76. package/dist/context.test.js +58 -0
  77. package/dist/federation/registry.d.ts +32 -0
  78. package/dist/federation/registry.js +77 -0
  79. package/dist/federation/registry.test.d.ts +1 -0
  80. package/dist/federation/registry.test.js +130 -0
  81. package/dist/federation/upstream.d.ts +60 -0
  82. package/dist/federation/upstream.js +114 -0
  83. package/dist/index.js +1188 -120
  84. package/dist/middleware/ssrfGuard.d.ts +15 -0
  85. package/dist/middleware/ssrfGuard.js +103 -0
  86. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  87. package/dist/middleware/ssrfGuard.test.js +81 -0
  88. package/dist/observability/otel.d.ts +20 -0
  89. package/dist/observability/otel.js +118 -0
  90. package/dist/observability/otel.test.d.ts +1 -0
  91. package/dist/observability/otel.test.js +56 -0
  92. package/dist/openapi.js +215 -7
  93. package/dist/openapi.test.js +34 -0
  94. package/dist/postmortem/synthesizer.d.ts +83 -0
  95. package/dist/postmortem/synthesizer.js +205 -0
  96. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  97. package/dist/postmortem/synthesizer.test.js +141 -0
  98. package/dist/products/loader.d.ts +31 -3
  99. package/dist/products/loader.js +77 -4
  100. package/dist/products/loader.test.js +90 -1
  101. package/dist/quota/charge.d.ts +28 -0
  102. package/dist/quota/charge.js +30 -0
  103. package/dist/quota/charge.test.d.ts +1 -0
  104. package/dist/quota/charge.test.js +83 -0
  105. package/dist/quota/limiter.d.ts +29 -4
  106. package/dist/quota/limiter.js +64 -8
  107. package/dist/quota/limiter.test.js +86 -0
  108. package/dist/scim/group-role-map.d.ts +4 -0
  109. package/dist/scim/group-role-map.js +33 -0
  110. package/dist/scim/group-role-map.test.d.ts +1 -0
  111. package/dist/scim/group-role-map.test.js +33 -0
  112. package/dist/scim/routes.d.ts +15 -0
  113. package/dist/scim/routes.js +249 -0
  114. package/dist/scim/store.d.ts +37 -0
  115. package/dist/scim/store.js +178 -0
  116. package/dist/scim/store.test.d.ts +1 -0
  117. package/dist/scim/store.test.js +121 -0
  118. package/dist/scim/types.d.ts +73 -0
  119. package/dist/scim/types.js +29 -0
  120. package/dist/sdk/hooks.d.ts +77 -0
  121. package/dist/sdk/hooks.js +72 -0
  122. package/dist/sdk/hooks.test.d.ts +1 -0
  123. package/dist/sdk/hooks.test.js +159 -0
  124. package/dist/sdk/index.d.ts +2 -0
  125. package/dist/sdk/index.js +1 -0
  126. package/dist/sdk/manifest-schema.d.ts +17 -0
  127. package/dist/sdk/manifest-schema.js +21 -0
  128. package/dist/tools/context-seam.test.js +6 -1
  129. package/dist/tools/detect-anomalies.d.ts +1 -1
  130. package/dist/tools/detect-anomalies.js +5 -4
  131. package/dist/tools/generate-postmortem.d.ts +35 -0
  132. package/dist/tools/generate-postmortem.js +191 -0
  133. package/dist/tools/get-anomaly-history.d.ts +35 -0
  134. package/dist/tools/get-anomaly-history.js +126 -0
  135. package/dist/tools/get-service-health.d.ts +1 -1
  136. package/dist/tools/get-service-health.js +4 -3
  137. package/dist/tools/list-services.d.ts +1 -1
  138. package/dist/tools/list-services.js +3 -2
  139. package/dist/tools/list-sources.d.ts +1 -1
  140. package/dist/tools/list-sources.js +6 -2
  141. package/dist/tools/query-logs.d.ts +1 -1
  142. package/dist/tools/query-logs.js +2 -2
  143. package/dist/tools/query-metrics.d.ts +1 -1
  144. package/dist/tools/query-metrics.js +19 -6
  145. package/dist/tools/query-traces.d.ts +47 -0
  146. package/dist/tools/query-traces.js +145 -0
  147. package/dist/tools/query-traces.test.d.ts +1 -0
  148. package/dist/tools/query-traces.test.js +110 -0
  149. package/dist/tools/registry-names.d.ts +35 -0
  150. package/dist/tools/registry-names.js +54 -0
  151. package/dist/tools/registry-names.test.d.ts +1 -0
  152. package/dist/tools/registry-names.test.js +61 -0
  153. package/dist/tools/topology.d.ts +3 -3
  154. package/dist/tools/topology.js +10 -6
  155. package/dist/topology/merge.d.ts +22 -0
  156. package/dist/topology/merge.js +178 -0
  157. package/dist/topology/merge.test.d.ts +1 -0
  158. package/dist/topology/merge.test.js +110 -0
  159. package/dist/transport/sessionStore.d.ts +66 -0
  160. package/dist/transport/sessionStore.js +138 -0
  161. package/dist/transport/sessionStore.test.d.ts +1 -0
  162. package/dist/transport/sessionStore.test.js +118 -0
  163. package/dist/transport/websocket.d.ts +35 -0
  164. package/dist/transport/websocket.js +133 -0
  165. package/dist/transport/websocket.test.d.ts +1 -0
  166. package/dist/transport/websocket.test.js +124 -0
  167. package/dist/types.d.ts +51 -0
  168. package/dist/ui/index.html +1729 -100
  169. package/package.json +13 -3
@@ -14,11 +14,15 @@
14
14
  // connector later requires zero changes here.
15
15
  import { isTopologyProvider } from "../connectors/interface.js";
16
16
  import { defaultContext } from "../context.js";
17
- export async function aggregateTopology(registry) {
17
+ export async function aggregateTopology(registry, tenant) {
18
18
  const sources = [];
19
19
  const resources = [];
20
20
  const edges = [];
21
- for (const c of registry.getAll()) {
21
+ // Tenant-scoped when a tenant is supplied (call sites at the MCP
22
+ // tool layer pass ctx.tenant); undefined preserves the original
23
+ // global behaviour for internal / non-request callers.
24
+ const connectors = tenant ? registry.getByTenant(tenant) : registry.getAll();
25
+ for (const c of connectors) {
22
26
  if (!isTopologyProvider(c))
23
27
  continue;
24
28
  try {
@@ -79,8 +83,8 @@ export const getTopologyDefinition = {
79
83
  name: "get_topology",
80
84
  description: "Return the infrastructure topology graph as Resources and Edges. Use this when an agent needs to reason about which workload runs where, who owns whom, or which scope (namespace/project/folder) a resource belongs to.",
81
85
  };
82
- export async function getTopologyHandler(registry, args = {}, _ctx = defaultContext()) {
83
- const agg = await aggregateTopology(registry);
86
+ export async function getTopologyHandler(registry, args = {}, ctx = defaultContext()) {
87
+ const agg = await aggregateTopology(registry, ctx.tenant);
84
88
  // Filtering — all optional. Filters compose conjunctively.
85
89
  let resources = agg.resources;
86
90
  let edges = agg.edges;
@@ -133,8 +137,8 @@ export const getBlastRadiusDefinition = {
133
137
  name: "get_blast_radius",
134
138
  description: "Given a resource, return the impact set if its underlying host(s) fail. Pivots on the generic RUNS_ON relation, so it works for pod→node, vm→hypervisor, container→host alike. Use this for cross-cutting RCA when several services degrade together.",
135
139
  };
136
- export async function getBlastRadiusHandler(registry, args, _ctx = defaultContext()) {
137
- const agg = await aggregateTopology(registry);
140
+ export async function getBlastRadiusHandler(registry, args, ctx = defaultContext()) {
141
+ const agg = await aggregateTopology(registry, ctx.tenant);
138
142
  const found = resolveResource(args.resource, agg.resources);
139
143
  if ("error" in found) {
140
144
  return {
@@ -0,0 +1,22 @@
1
+ import type { Resource, Edge, TopologySnapshot } from "../types.js";
2
+ /** Labels we treat as canonical-name carriers. First-match wins. */
3
+ export declare const CANONICAL_LABEL_KEYS: readonly ["app.kubernetes.io/name", "app.kubernetes.io/instance", "app", "service", "service.name", "k8s-app"];
4
+ /** Lower-cased label-key lookup; first match in CANONICAL_LABEL_KEYS
5
+ * ordering wins so two providers with different label conventions
6
+ * still converge if they both ship a known label. */
7
+ export declare function canonicalNameFor(r: Resource): string | undefined;
8
+ export interface MergeResult {
9
+ resources: Resource[];
10
+ edges: Edge[];
11
+ /** Map from original (source-scoped) id → merged canonical id. Used
12
+ * to rewrite edges that referenced one of the collapsed nodes. */
13
+ idMap: Map<string, string>;
14
+ }
15
+ /**
16
+ * Merge a set of provider snapshots into one unified graph. The
17
+ * input is the flat union of every provider's resources+edges; the
18
+ * output collapses every group of nodes that share a canonical name
19
+ * (and a compatible kind) into a single node, then rewrites the
20
+ * edge endpoints accordingly.
21
+ */
22
+ export declare function mergeTopologies(snapshots: TopologySnapshot[]): MergeResult;
@@ -0,0 +1,178 @@
1
+ // Topology merger — collapses Resources/Edges that come from
2
+ // multiple providers into a single deduped graph.
3
+ //
4
+ // The default unified-view is the union of every provider's
5
+ // snapshot, with no dedup. That's fine for unrelated providers
6
+ // (k8s + AWS in different accounts) but breaks down once two
7
+ // providers describe the same logical service (k8s `payment`
8
+ // Deployment + ECS `payment-service` + Tempo `trace_service`
9
+ // `payment`). Without a merger, get_blast_radius walks them as
10
+ // three independent nodes and the LLM sees ghost relationships
11
+ // instead of one real chain.
12
+ //
13
+ // Reconciliation rules, in priority order:
14
+ // 1. Explicit override via attributes.canonicalName
15
+ // 2. Match on any CANONICAL_LABEL_KEYS entry (case-insensitive)
16
+ // 3. Name + kind-compatibility table
17
+ //
18
+ // See docs/topology-vocabulary.md for the rationale.
19
+ /** Labels we treat as canonical-name carriers. First-match wins. */
20
+ export const CANONICAL_LABEL_KEYS = [
21
+ "app.kubernetes.io/name",
22
+ "app.kubernetes.io/instance",
23
+ "app",
24
+ "service",
25
+ "service.name",
26
+ "k8s-app",
27
+ ];
28
+ /** Pairs of kinds that are allowed to merge when their canonical
29
+ * names match. Order doesn't matter (we normalise to sorted pair). */
30
+ const MERGEABLE_KIND_PAIRS = new Set([
31
+ ["deployment", "cloud_service"],
32
+ ["deployment", "trace_service"],
33
+ ["cloud_service", "trace_service"],
34
+ ["pod", "container"],
35
+ ].map((p) => p.slice().sort().join("|")));
36
+ function kindsMergeable(a, b) {
37
+ if (a === b)
38
+ return true;
39
+ return MERGEABLE_KIND_PAIRS.has([a, b].sort().join("|"));
40
+ }
41
+ /** Lower-cased label-key lookup; first match in CANONICAL_LABEL_KEYS
42
+ * ordering wins so two providers with different label conventions
43
+ * still converge if they both ship a known label. */
44
+ export function canonicalNameFor(r) {
45
+ const override = typeof r.attributes?.canonicalName === "string"
46
+ ? r.attributes.canonicalName
47
+ : undefined;
48
+ if (override)
49
+ return override.toLowerCase();
50
+ if (!r.labels)
51
+ return undefined;
52
+ const lower = new Map();
53
+ for (const [k, v] of Object.entries(r.labels)) {
54
+ lower.set(k.toLowerCase(), v);
55
+ }
56
+ for (const key of CANONICAL_LABEL_KEYS) {
57
+ const v = lower.get(key.toLowerCase());
58
+ if (typeof v === "string" && v.length > 0)
59
+ return v.toLowerCase();
60
+ }
61
+ return undefined;
62
+ }
63
+ /**
64
+ * Merge a set of provider snapshots into one unified graph. The
65
+ * input is the flat union of every provider's resources+edges; the
66
+ * output collapses every group of nodes that share a canonical name
67
+ * (and a compatible kind) into a single node, then rewrites the
68
+ * edge endpoints accordingly.
69
+ */
70
+ export function mergeTopologies(snapshots) {
71
+ const allResources = [];
72
+ const allEdges = [];
73
+ for (const s of snapshots) {
74
+ allResources.push(...s.resources);
75
+ allEdges.push(...s.edges);
76
+ }
77
+ // Group by (canonical-name + kind-bucket). Resources without a
78
+ // canonical name are passed through unchanged (one bucket each).
79
+ const groups = new Map();
80
+ const passthrough = [];
81
+ for (const r of allResources) {
82
+ const canonical = canonicalNameFor(r);
83
+ if (!canonical) {
84
+ passthrough.push(r);
85
+ continue;
86
+ }
87
+ // Bucket key tries to merge across compatible kinds: we group on
88
+ // canonical-name alone, then verify pairwise compatibility when
89
+ // collapsing.
90
+ const key = canonical;
91
+ const existing = groups.get(key);
92
+ if (existing)
93
+ existing.push(r);
94
+ else
95
+ groups.set(key, [r]);
96
+ }
97
+ const merged = [...passthrough];
98
+ const idMap = new Map();
99
+ for (const bucket of groups.values()) {
100
+ if (bucket.length === 1) {
101
+ merged.push(bucket[0]);
102
+ continue;
103
+ }
104
+ // Verify all kinds in the bucket are pairwise mergeable. If any
105
+ // pair isn't, fall back to passing every member through
106
+ // unchanged — better a slightly verbose graph than a wrong join.
107
+ let allCompatible = true;
108
+ for (let i = 0; i < bucket.length && allCompatible; i++) {
109
+ for (let j = i + 1; j < bucket.length && allCompatible; j++) {
110
+ if (!kindsMergeable(bucket[i].kind, bucket[j].kind)) {
111
+ allCompatible = false;
112
+ }
113
+ }
114
+ }
115
+ if (!allCompatible) {
116
+ merged.push(...bucket);
117
+ continue;
118
+ }
119
+ const collapsed = collapseBucket(bucket);
120
+ merged.push(collapsed);
121
+ for (const r of bucket) {
122
+ if (r.id !== collapsed.id)
123
+ idMap.set(r.id, collapsed.id);
124
+ }
125
+ }
126
+ // Rewrite edge endpoints + dedupe identical (from,to,relation)
127
+ // tuples that arose from the collapse.
128
+ const seen = new Set();
129
+ const edges = [];
130
+ for (const e of allEdges) {
131
+ const from = idMap.get(e.from) ?? e.from;
132
+ const to = idMap.get(e.to) ?? e.to;
133
+ if (from === to)
134
+ continue; // self-loop after collapse → drop
135
+ const key = `${from}->${to}|${e.relation}`;
136
+ if (seen.has(key))
137
+ continue;
138
+ seen.add(key);
139
+ edges.push({ ...e, from, to });
140
+ }
141
+ return { resources: merged, edges, idMap };
142
+ }
143
+ function collapseBucket(bucket) {
144
+ // Stable choice for the canonical id: pick the first resource
145
+ // sorted lexicographically by source then id. Source rather than
146
+ // alphabetical so the choice doesn't flip when a label changes.
147
+ const sorted = [...bucket].sort((a, b) => {
148
+ const s = a.source.localeCompare(b.source);
149
+ return s !== 0 ? s : a.id.localeCompare(b.id);
150
+ });
151
+ const primary = sorted[0];
152
+ const labels = { ...primary.labels };
153
+ const attributes = { ...(primary.attributes ?? {}) };
154
+ const mergedFrom = [];
155
+ for (const r of sorted) {
156
+ if (r.labels)
157
+ for (const [k, v] of Object.entries(r.labels))
158
+ labels[k] = v;
159
+ if (r.attributes)
160
+ for (const [k, v] of Object.entries(r.attributes))
161
+ attributes[k] = v;
162
+ mergedFrom.push(`${r.source}:${r.id}`);
163
+ }
164
+ attributes.mergedFrom = mergedFrom;
165
+ // Pick the most-specific kind for the merged node: cloud_service
166
+ // > deployment > pod > trace_service > anything else. The merge
167
+ // rules above already capped the bucket to compatible kinds.
168
+ const kindPriority = ["cloud_service", "deployment", "pod", "trace_service", "container"];
169
+ const kind = kindPriority.find((k) => sorted.some((r) => r.kind === k)) ?? primary.kind;
170
+ return {
171
+ id: primary.id,
172
+ kind,
173
+ name: primary.name,
174
+ source: primary.source,
175
+ labels,
176
+ attributes,
177
+ };
178
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,110 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mergeTopologies, canonicalNameFor } from "./merge.js";
4
+ function r(id, kind, opts = {}) {
5
+ return {
6
+ id,
7
+ kind,
8
+ name: opts.name ?? id,
9
+ source: opts.source ?? "test",
10
+ labels: opts.labels ?? {},
11
+ attributes: opts.canonical ? { canonicalName: opts.canonical } : undefined,
12
+ };
13
+ }
14
+ function e(from, to, relation = "RUNS_ON", source = "test") {
15
+ return { from, to, relation, source, confidence: 1.0 };
16
+ }
17
+ function snap(resources, edges = []) {
18
+ return { source: "test", resources, edges, revision: 1 };
19
+ }
20
+ test("canonicalNameFor: attributes.canonicalName wins, lowercased", () => {
21
+ const got = canonicalNameFor(r("x", "deployment", { canonical: "Payment-Service" }));
22
+ assert.equal(got, "payment-service");
23
+ });
24
+ test("canonicalNameFor: first matching CANONICAL_LABEL_KEYS entry wins", () => {
25
+ // app.kubernetes.io/name beats app
26
+ assert.equal(canonicalNameFor(r("x", "deployment", { labels: { "app.kubernetes.io/name": "wins", app: "loses" } })), "wins");
27
+ // app beats service when app.kubernetes.io/name absent
28
+ assert.equal(canonicalNameFor(r("x", "deployment", { labels: { app: "wins", service: "loses" } })), "wins");
29
+ });
30
+ test("canonicalNameFor: case-insensitive label-key match", () => {
31
+ assert.equal(canonicalNameFor(r("x", "deployment", { labels: { "App": "Yes" } })), "yes");
32
+ });
33
+ test("canonicalNameFor: returns undefined when no canonical signal present", () => {
34
+ assert.equal(canonicalNameFor(r("x", "deployment")), undefined);
35
+ assert.equal(canonicalNameFor(r("x", "deployment", { labels: { tier: "frontend" } })), undefined);
36
+ });
37
+ test("mergeTopologies: empty input returns empty result", () => {
38
+ const m = mergeTopologies([]);
39
+ assert.deepEqual(m.resources, []);
40
+ assert.deepEqual(m.edges, []);
41
+ assert.equal(m.idMap.size, 0);
42
+ });
43
+ test("mergeTopologies: passes resources without canonical name unchanged", () => {
44
+ const s = snap([r("a", "node", { source: "k8s" }), r("b", "namespace", { source: "k8s" })]);
45
+ const m = mergeTopologies([s]);
46
+ assert.equal(m.resources.length, 2);
47
+ assert.equal(m.idMap.size, 0);
48
+ });
49
+ test("mergeTopologies: collapses k8s Deployment + cloud_service with same canonical name", () => {
50
+ const k8s = snap([r("dep-payment", "deployment", { source: "k8s", labels: { app: "payment" } })]);
51
+ const aws = snap([r("ecs-payment", "cloud_service", { source: "aws", canonical: "payment" })]);
52
+ const m = mergeTopologies([k8s, aws]);
53
+ assert.equal(m.resources.length, 1, "two providers, one canonical service");
54
+ const merged = m.resources[0];
55
+ // Higher-priority kind wins (cloud_service > deployment)
56
+ assert.equal(merged.kind, "cloud_service");
57
+ assert.deepEqual((merged.attributes?.mergedFrom).sort(), [
58
+ "aws:ecs-payment",
59
+ "k8s:dep-payment",
60
+ ]);
61
+ // idMap maps the non-canonical id to the canonical one (first by
62
+ // source asc, then id asc: aws < k8s, so aws's id wins).
63
+ assert.equal(merged.id, "ecs-payment");
64
+ assert.equal(m.idMap.get("dep-payment"), "ecs-payment");
65
+ });
66
+ test("mergeTopologies: incompatible kinds in the bucket → no merge (graph stays verbose)", () => {
67
+ // `pod` and `function` are not in MERGEABLE_KIND_PAIRS — even if
68
+ // the names collide we keep both.
69
+ const k8s = snap([r("p1", "pod", { source: "k8s", labels: { app: "payment" } })]);
70
+ const aws = snap([r("fn1", "function", { source: "aws", canonical: "payment" })]);
71
+ const m = mergeTopologies([k8s, aws]);
72
+ assert.equal(m.resources.length, 2);
73
+ assert.equal(m.idMap.size, 0);
74
+ });
75
+ test("mergeTopologies: rewrites edges that referenced a collapsed id", () => {
76
+ const k8s = snap([r("dep-payment", "deployment", { source: "k8s", labels: { app: "payment" } })], [e("pod-1", "dep-payment", "RUNS_AS", "k8s")]);
77
+ const aws = snap([r("ecs-payment", "cloud_service", { source: "aws", canonical: "payment" })], [e("ecs-payment", "rds-1", "READS_FROM", "aws")]);
78
+ const m = mergeTopologies([k8s, aws]);
79
+ // dep-payment was collapsed into ecs-payment
80
+ const edges = m.edges;
81
+ // The k8s edge's TO endpoint must be rewritten to ecs-payment.
82
+ assert.ok(edges.some((x) => x.from === "pod-1" && x.to === "ecs-payment"));
83
+ // The aws edge stays put.
84
+ assert.ok(edges.some((x) => x.from === "ecs-payment" && x.to === "rds-1"));
85
+ });
86
+ test("mergeTopologies: self-loops created by the collapse are dropped", () => {
87
+ // Two resources merge into one. An edge that pointed from A to B
88
+ // becomes A→A after rewrite; drop it.
89
+ const k8s = snap([
90
+ r("dep-payment", "deployment", { source: "k8s", labels: { app: "payment" } }),
91
+ r("ecs-payment", "cloud_service", { source: "aws", canonical: "payment" }),
92
+ ], [e("dep-payment", "ecs-payment", "ALIAS_OF", "synthetic")]);
93
+ const m = mergeTopologies([k8s]);
94
+ assert.equal(m.resources.length, 1);
95
+ assert.equal(m.edges.filter((x) => x.from === x.to).length, 0, "self-loops after collapse must be removed");
96
+ });
97
+ test("mergeTopologies: duplicate (from,to,relation) tuples deduped after rewrite", () => {
98
+ const k8s = snap([
99
+ r("a", "deployment", { source: "k8s", labels: { app: "payment" } }),
100
+ r("b", "cloud_service", { source: "aws", canonical: "payment" }),
101
+ r("client", "deployment", { source: "k8s" }),
102
+ ], [
103
+ e("client", "a", "CALLS", "k8s"),
104
+ e("client", "b", "CALLS", "aws"),
105
+ ]);
106
+ const m = mergeTopologies([k8s]);
107
+ // After collapse, both edges become client→b CALLS — dedup to one.
108
+ const callEdges = m.edges.filter((x) => x.relation === "CALLS");
109
+ assert.equal(callEdges.length, 1);
110
+ });
@@ -0,0 +1,66 @@
1
+ export interface SessionStore {
2
+ /** Stable backend identifier (used in /api/info diagnostics). */
3
+ readonly backend: string;
4
+ /** Get a JSON-serialisable value by key. */
5
+ get<T = unknown>(key: string): Promise<T | undefined>;
6
+ /** Set a value with no expiry. */
7
+ set<T = unknown>(key: string, value: T): Promise<void>;
8
+ /** Set a value that auto-expires after `ttlSeconds`. */
9
+ setEx<T = unknown>(key: string, ttlSeconds: number, value: T): Promise<void>;
10
+ /** Remove a key. No-op if missing. */
11
+ del(key: string): Promise<void>;
12
+ /** List all keys matching a glob-style prefix (used by SCIM /
13
+ * DCR enumeration). Not required to be efficient; backends MAY
14
+ * impose a soft cap (and document it). */
15
+ keys(prefix: string): Promise<string[]>;
16
+ /** Best-effort shutdown — flush + disconnect any pooled clients. */
17
+ close(): Promise<void>;
18
+ }
19
+ /** Single-process, in-memory store. Default when OMCP_REDIS_URL is
20
+ * unset. Lazy expiry — entries are evicted on the next access. */
21
+ export declare class InMemorySessionStore implements SessionStore {
22
+ readonly backend = "memory";
23
+ private readonly map;
24
+ get<T = unknown>(key: string): Promise<T | undefined>;
25
+ set<T = unknown>(key: string, value: T): Promise<void>;
26
+ setEx<T = unknown>(key: string, ttlSeconds: number, value: T): Promise<void>;
27
+ del(key: string): Promise<void>;
28
+ keys(prefix: string): Promise<string[]>;
29
+ close(): Promise<void>;
30
+ /** Test-only: introspect size. */
31
+ size(): number;
32
+ }
33
+ /** Redis-backed store. Constructed lazily via `connectRedisStore` so
34
+ * the `redis` driver only loads when actually used. */
35
+ export declare class RedisSessionStore implements SessionStore {
36
+ readonly backend = "redis";
37
+ private readonly client;
38
+ private readonly prefix;
39
+ constructor(client: RedisClientLike, prefix?: string);
40
+ private k;
41
+ get<T = unknown>(key: string): Promise<T | undefined>;
42
+ set<T = unknown>(key: string, value: T): Promise<void>;
43
+ setEx<T = unknown>(key: string, ttlSeconds: number, value: T): Promise<void>;
44
+ del(key: string): Promise<void>;
45
+ keys(prefix: string): Promise<string[]>;
46
+ close(): Promise<void>;
47
+ }
48
+ /** Minimum surface a Redis client must implement. Lets us inject a
49
+ * fake in tests and stays compatible across the `redis` / `ioredis`
50
+ * package shapes. */
51
+ export interface RedisClientLike {
52
+ get(key: string): Promise<string | null>;
53
+ set(key: string, value: string, opts?: {
54
+ EX?: number;
55
+ }): Promise<string | null | undefined>;
56
+ del(key: string): Promise<number>;
57
+ keys(pattern: string): Promise<string[]>;
58
+ quit(): Promise<unknown>;
59
+ }
60
+ /**
61
+ * Resolve the session store from env. Default: InMemorySessionStore.
62
+ * When OMCP_REDIS_URL is set: load `redis` dynamically, connect, and
63
+ * return a RedisSessionStore. On any connect failure, log + fall back
64
+ * to InMemory (the gateway must boot even when Redis is down).
65
+ */
66
+ export declare function resolveSessionStore(env?: NodeJS.ProcessEnv): Promise<SessionStore>;
@@ -0,0 +1,138 @@
1
+ // Shared session store — abstract backend for any short-lived state
2
+ // the gateway needs to keep across replicas: MCP Streamable HTTP
3
+ // session metadata, OIDC flow state (PKCE verifier, nonce, redirect
4
+ // target), DCR-registered client metadata, federation upstream
5
+ // catalogue cache.
6
+ //
7
+ // Two implementations ship in v2.0:
8
+ //
9
+ // InMemorySessionStore — the default; preserves the pre-F8 behaviour
10
+ // (single-replica, ephemeral on restart). No new dep, no new env.
11
+ //
12
+ // RedisSessionStore — opt-in via OMCP_REDIS_URL. Backs all consumers
13
+ // with a shared Redis so multi-replica deployments stop losing
14
+ // sessions on rollouts and so federated upstreams can share a
15
+ // cache. Driver loaded via dynamic import so the `redis` package
16
+ // only loads when the store is configured.
17
+ //
18
+ // Consumers MUST treat returns as eventually-consistent across
19
+ // replicas: a get() right after a set() on a different replica may
20
+ // return undefined while replication catches up. Use TTLs for any
21
+ // state that must self-cleanup (the store's `setEx` honours the
22
+ // requested ttl seconds; `set` is no-expiry).
23
+ /** Single-process, in-memory store. Default when OMCP_REDIS_URL is
24
+ * unset. Lazy expiry — entries are evicted on the next access. */
25
+ export class InMemorySessionStore {
26
+ backend = "memory";
27
+ map = new Map();
28
+ async get(key) {
29
+ const entry = this.map.get(key);
30
+ if (!entry)
31
+ return undefined;
32
+ if (entry.expiresAt <= Date.now()) {
33
+ this.map.delete(key);
34
+ return undefined;
35
+ }
36
+ return entry.value;
37
+ }
38
+ async set(key, value) {
39
+ this.map.set(key, { value, expiresAt: Number.POSITIVE_INFINITY });
40
+ }
41
+ async setEx(key, ttlSeconds, value) {
42
+ this.map.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
43
+ }
44
+ async del(key) {
45
+ this.map.delete(key);
46
+ }
47
+ async keys(prefix) {
48
+ const out = [];
49
+ const now = Date.now();
50
+ for (const [k, v] of this.map) {
51
+ if (v.expiresAt <= now) {
52
+ this.map.delete(k);
53
+ continue;
54
+ }
55
+ if (k.startsWith(prefix))
56
+ out.push(k);
57
+ }
58
+ return out;
59
+ }
60
+ async close() {
61
+ this.map.clear();
62
+ }
63
+ /** Test-only: introspect size. */
64
+ size() {
65
+ return this.map.size;
66
+ }
67
+ }
68
+ /** Redis-backed store. Constructed lazily via `connectRedisStore` so
69
+ * the `redis` driver only loads when actually used. */
70
+ export class RedisSessionStore {
71
+ backend = "redis";
72
+ client;
73
+ prefix;
74
+ constructor(client, prefix = "omcp:") {
75
+ this.client = client;
76
+ this.prefix = prefix;
77
+ }
78
+ k(key) {
79
+ return `${this.prefix}${key}`;
80
+ }
81
+ async get(key) {
82
+ const raw = await this.client.get(this.k(key));
83
+ if (raw == null)
84
+ return undefined;
85
+ try {
86
+ return JSON.parse(raw);
87
+ }
88
+ catch {
89
+ return undefined;
90
+ }
91
+ }
92
+ async set(key, value) {
93
+ await this.client.set(this.k(key), JSON.stringify(value));
94
+ }
95
+ async setEx(key, ttlSeconds, value) {
96
+ await this.client.set(this.k(key), JSON.stringify(value), {
97
+ EX: ttlSeconds,
98
+ });
99
+ }
100
+ async del(key) {
101
+ await this.client.del(this.k(key));
102
+ }
103
+ async keys(prefix) {
104
+ const found = await this.client.keys(`${this.k(prefix)}*`);
105
+ return found.map((k) => k.slice(this.prefix.length));
106
+ }
107
+ async close() {
108
+ try {
109
+ await this.client.quit();
110
+ }
111
+ catch {
112
+ /* socket may already be down */
113
+ }
114
+ }
115
+ }
116
+ /**
117
+ * Resolve the session store from env. Default: InMemorySessionStore.
118
+ * When OMCP_REDIS_URL is set: load `redis` dynamically, connect, and
119
+ * return a RedisSessionStore. On any connect failure, log + fall back
120
+ * to InMemory (the gateway must boot even when Redis is down).
121
+ */
122
+ export async function resolveSessionStore(env = process.env) {
123
+ const url = env.OMCP_REDIS_URL?.trim();
124
+ if (!url)
125
+ return new InMemorySessionStore();
126
+ try {
127
+ const { createClient } = await import("redis");
128
+ const client = createClient({ url });
129
+ client.on("error", (err) => console.warn("RedisSessionStore: client error: %s", err instanceof Error ? err.message : String(err)));
130
+ await client.connect();
131
+ console.log("RedisSessionStore: connected (url scheme=%s, prefix=%s)", new URL(url).protocol.replace(/:$/, ""), env.OMCP_REDIS_KEY_PREFIX ?? "omcp:");
132
+ return new RedisSessionStore(client, env.OMCP_REDIS_KEY_PREFIX ?? "omcp:");
133
+ }
134
+ catch (err) {
135
+ console.warn("RedisSessionStore: connect failed, falling back to in-memory store: %s", err instanceof Error ? err.message : String(err));
136
+ return new InMemorySessionStore();
137
+ }
138
+ }
@@ -0,0 +1 @@
1
+ export {};