@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,54 @@
1
+ /**
2
+ * Canonical list of MCP tool names exposed by createMcpServer().
3
+ *
4
+ * Used by:
5
+ * - the Product validator (typo guard): a Product's `tools` allow-
6
+ * list must reference names that actually register, otherwise a
7
+ * bound credential opens an /mcp session with an empty tool set
8
+ * and the agent silently fails.
9
+ * - the keystone integration test in registry-names.test.ts that
10
+ * reads index.ts and asserts the registerTool() call sites match
11
+ * this list 1:1 — a missing entry or an extra one trips the test.
12
+ *
13
+ * Keep this list and the registerTool("name", ...) calls in
14
+ * createMcpServer in sync. The test enforces it.
15
+ */
16
+ export const REGISTERED_TOOL_NAMES = [
17
+ "list_sources",
18
+ "list_services",
19
+ "query_metrics",
20
+ "query_logs",
21
+ "query_traces",
22
+ "get_service_health",
23
+ "detect_anomalies",
24
+ "get_anomaly_history",
25
+ "generate_postmortem",
26
+ "get_topology",
27
+ "get_blast_radius",
28
+ ];
29
+ export const REGISTERED_TOOLS = [
30
+ { name: "list_sources", category: "discovery", summary: "List configured observability backends + reachability." },
31
+ { name: "list_services", category: "discovery", summary: "Discover service names across every connected backend." },
32
+ { name: "query_metrics", category: "query", summary: "Fetch the raw time-series for one metric of one service over a window." },
33
+ { name: "query_logs", category: "query", summary: "Fetch matching log lines for one service over a window." },
34
+ { name: "query_traces", category: "query", summary: "Fetch ranked trace summaries for one service over a window." },
35
+ { name: "get_service_health", category: "diagnose", summary: "Aggregated health verdict for one service (metrics + logs)." },
36
+ { name: "detect_anomalies", category: "diagnose", summary: "Scan for anomalous services using z-score / heuristics." },
37
+ { name: "get_anomaly_history", category: "diagnose", summary: "Replay historical anomaly scores for a service (post-mortem context)." },
38
+ { name: "generate_postmortem", category: "diagnose", summary: "One-shot markdown post-mortem stitching anomaly history + traces + blast-radius + logs for a service." },
39
+ { name: "get_topology", category: "topology", summary: "Return the infrastructure topology graph (resources + edges)." },
40
+ { name: "get_blast_radius", category: "topology", summary: "Given a resource, return the impact set if its host(s) fail." },
41
+ ];
42
+ /** Validate a candidate Product tools[] array. Returns the unknown
43
+ * names (empty array = all OK). Pure helper — the caller decides
44
+ * how to surface the rejection (the API handler emits a 422 with a
45
+ * hint of valid names; the YAML loader could decide to warn). */
46
+ export function unknownToolNames(tools) {
47
+ const known = new Set(REGISTERED_TOOL_NAMES);
48
+ const out = [];
49
+ for (const t of tools) {
50
+ if (!known.has(t))
51
+ out.push(t);
52
+ }
53
+ return out;
54
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ import { REGISTERED_TOOL_NAMES, REGISTERED_TOOLS, unknownToolNames } from "./registry-names.js";
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ const INDEX_TS = join(here, "..", "index.ts");
9
+ test("REGISTERED_TOOL_NAMES — 1:1 with createMcpServer's registerTool() calls", () => {
10
+ // This is the integration-test-shaped guard: if a future PR adds
11
+ // or removes a tool registration in index.ts without updating
12
+ // REGISTERED_TOOL_NAMES, the Product validator will silently
13
+ // accept or reject the wrong names. We parse index.ts as text and
14
+ // assert the registered set matches the constant exactly.
15
+ const src = readFileSync(INDEX_TS, "utf8");
16
+ // registerTool("name", → captures the first argument of every call site.
17
+ const re = /\bregisterTool\(\s*"([a-z_][a-z0-9_]*)"/g;
18
+ const found = [];
19
+ for (const m of src.matchAll(re))
20
+ found.push(m[1]);
21
+ found.sort();
22
+ const expected = [...REGISTERED_TOOL_NAMES].sort();
23
+ assert.deepEqual(found, expected, `registerTool() call sites in index.ts don't match REGISTERED_TOOL_NAMES. ` +
24
+ `Add or remove names in src/tools/registry-names.ts to match the actual ` +
25
+ `registrations.\n found: ${JSON.stringify(found)}\n expected: ${JSON.stringify(expected)}`);
26
+ });
27
+ test("unknownToolNames — empty input → no unknowns", () => {
28
+ assert.deepEqual(unknownToolNames([]), []);
29
+ });
30
+ test("unknownToolNames — every registered name is accepted", () => {
31
+ assert.deepEqual(unknownToolNames([...REGISTERED_TOOL_NAMES]), []);
32
+ });
33
+ test("unknownToolNames — surfaces typos and unknown names verbatim", () => {
34
+ const r = unknownToolNames(["list_sources", "query_logz", "get_topologyy"]);
35
+ assert.deepEqual(r, ["query_logz", "get_topologyy"]);
36
+ });
37
+ test("unknownToolNames — case-sensitive (MCP spec)", () => {
38
+ // The spec requires exact name match; "List_Sources" is not the
39
+ // same tool as "list_sources" and silently accepting it would be a
40
+ // worse failure mode than rejecting (mismatched casing wouldn't
41
+ // route to a real tool).
42
+ assert.deepEqual(unknownToolNames(["List_Sources"]), ["List_Sources"]);
43
+ });
44
+ test("REGISTERED_TOOLS — every name has a category + summary", () => {
45
+ for (const t of REGISTERED_TOOLS) {
46
+ assert.ok(t.name, "name required");
47
+ assert.ok(t.category, `category required on ${t.name}`);
48
+ assert.ok(t.summary && t.summary.length > 10, `summary required on ${t.name}`);
49
+ }
50
+ });
51
+ test("REGISTERED_TOOLS — matches REGISTERED_TOOL_NAMES 1:1 (no drift)", () => {
52
+ const a = REGISTERED_TOOLS.map((t) => t.name).sort();
53
+ const b = [...REGISTERED_TOOL_NAMES].sort();
54
+ assert.deepEqual(a, b, "REGISTERED_TOOLS and REGISTERED_TOOL_NAMES must agree");
55
+ });
56
+ test("REGISTERED_TOOLS — every category is one of the four valid values", () => {
57
+ const valid = new Set(["discovery", "query", "diagnose", "topology"]);
58
+ for (const t of REGISTERED_TOOLS) {
59
+ assert.ok(valid.has(t.category), `unknown category '${t.category}' on ${t.name}`);
60
+ }
61
+ });
@@ -12,7 +12,7 @@ interface AggregatedTopology {
12
12
  resources: Resource[];
13
13
  edges: Edge[];
14
14
  }
15
- export declare function aggregateTopology(registry: ConnectorRegistry): Promise<AggregatedTopology>;
15
+ export declare function aggregateTopology(registry: ConnectorRegistry, tenant?: string): Promise<AggregatedTopology>;
16
16
  /**
17
17
  * Resolve a caller-supplied identifier to a Resource. Accepts:
18
18
  * - exact canonical id (e.g. "k8s:pod:default/checkout-7f89d")
@@ -35,7 +35,7 @@ export interface GetTopologyArgs {
35
35
  scope?: string;
36
36
  limit?: number;
37
37
  }
38
- export declare function getTopologyHandler(registry: ConnectorRegistry, args?: GetTopologyArgs, _ctx?: RequestContext): Promise<{
38
+ export declare function getTopologyHandler(registry: ConnectorRegistry, args?: GetTopologyArgs, ctx?: RequestContext): Promise<{
39
39
  content: {
40
40
  type: "text";
41
41
  text: string;
@@ -48,7 +48,7 @@ export declare const getBlastRadiusDefinition: {
48
48
  export interface GetBlastRadiusArgs {
49
49
  resource: string;
50
50
  }
51
- export declare function getBlastRadiusHandler(registry: ConnectorRegistry, args: GetBlastRadiusArgs, _ctx?: RequestContext): Promise<{
51
+ export declare function getBlastRadiusHandler(registry: ConnectorRegistry, args: GetBlastRadiusArgs, ctx?: RequestContext): Promise<{
52
52
  isError: boolean;
53
53
  content: {
54
54
  type: "text";
@@ -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
+ import { mergeTopologies } from "../topology/merge.js";
18
+ export async function aggregateTopology(registry, tenant) {
18
19
  const sources = [];
19
- const resources = [];
20
- const edges = [];
21
- for (const c of registry.getAll()) {
20
+ const snapshots = [];
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 {
@@ -30,14 +34,32 @@ export async function aggregateTopology(registry) {
30
34
  resources: snap.resources.length,
31
35
  edges: snap.edges.length,
32
36
  });
33
- resources.push(...snap.resources);
34
- edges.push(...snap.edges);
37
+ snapshots.push(snap);
35
38
  }
36
39
  catch {
37
40
  // A misbehaving connector must not poison the agent's view of the graph.
38
41
  }
39
42
  }
40
- return { sources, resources, edges };
43
+ // P1: run the snapshots through mergeTopologies so workloads
44
+ // surfaced by more than one provider (e.g. the same Deployment
45
+ // observed by both Kubernetes + a service-mesh connector) collapse
46
+ // into a single canonical node and edges are rewritten to match.
47
+ //
48
+ // ONLY engages for multi-source topologies — with a single snapshot
49
+ // the merger would mis-group intra-source siblings that happen to
50
+ // share a canonical label (e.g. two pod replicas with
51
+ // `app.kubernetes.io/name=api`). The merger is designed for
52
+ // cross-provider de-duplication, not intra-provider.
53
+ if (snapshots.length <= 1) {
54
+ const only = snapshots[0];
55
+ return {
56
+ sources,
57
+ resources: only?.resources ?? [],
58
+ edges: only?.edges ?? [],
59
+ };
60
+ }
61
+ const merged = mergeTopologies(snapshots);
62
+ return { sources, resources: merged.resources, edges: merged.edges };
41
63
  }
42
64
  /**
43
65
  * Resolve a caller-supplied identifier to a Resource. Accepts:
@@ -79,8 +101,8 @@ export const getTopologyDefinition = {
79
101
  name: "get_topology",
80
102
  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
103
  };
82
- export async function getTopologyHandler(registry, args = {}, _ctx = defaultContext()) {
83
- const agg = await aggregateTopology(registry);
104
+ export async function getTopologyHandler(registry, args = {}, ctx = defaultContext()) {
105
+ const agg = await aggregateTopology(registry, ctx.tenant);
84
106
  // Filtering — all optional. Filters compose conjunctively.
85
107
  let resources = agg.resources;
86
108
  let edges = agg.edges;
@@ -133,8 +155,8 @@ export const getBlastRadiusDefinition = {
133
155
  name: "get_blast_radius",
134
156
  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
157
  };
136
- export async function getBlastRadiusHandler(registry, args, _ctx = defaultContext()) {
137
- const agg = await aggregateTopology(registry);
158
+ export async function getBlastRadiusHandler(registry, args, ctx = defaultContext()) {
159
+ const agg = await aggregateTopology(registry, ctx.tenant);
138
160
  const found = resolveResource(args.resource, agg.resources);
139
161
  if ("error" in found) {
140
162
  return {
@@ -208,3 +208,48 @@ describe("get_blast_radius tool", () => {
208
208
  assert.equal(apiBucket.ownershipRootKind, "deployment");
209
209
  });
210
210
  });
211
+ // --- Multi-source merge (Phase P1 wiring) ----------------------------
212
+ // `aggregateTopology` now delegates to `mergeTopologies` when 2+
213
+ // snapshots are present so the same logical workload reported by
214
+ // e.g. Kubernetes + a cloud connector collapses into one node.
215
+ // Single-snapshot calls pass through unchanged (guarded so we don't
216
+ // mis-merge intra-source siblings that share an `app:` label).
217
+ describe("aggregateTopology — multi-source merger (P1 wire)", () => {
218
+ it("collapses cross-source duplicates that share a canonical label", async () => {
219
+ // Source A (k8s): one Deployment "checkout" in prod
220
+ const aRes = [
221
+ { id: "k8s:deployment:prod/checkout", kind: "deployment", name: "checkout", source: "k8s",
222
+ labels: { "app.kubernetes.io/name": "checkout" } },
223
+ ];
224
+ // Source B (trace provider): the same logical service
225
+ const bRes = [
226
+ { id: "tempo:service:checkout", kind: "trace_service", name: "checkout", source: "tempo",
227
+ labels: { "service.name": "checkout" } },
228
+ ];
229
+ const loader = new PluginLoader();
230
+ const reg = new ConnectorRegistry(loader);
231
+ const connA = new FakeTopologyConnector(aRes, []);
232
+ const connB = new FakeTopologyConnector(bRes, []);
233
+ await connA.connect({ name: "k8s", type: "fake", url: "", enabled: true });
234
+ await connB.connect({ name: "tempo", type: "fake", url: "", enabled: true });
235
+ const loaderInternal = loader;
236
+ loaderInternal.connectors.set("fake-a", { name: "fake-a", source: "builtin", factory: () => connA });
237
+ loaderInternal.connectors.set("fake-b", { name: "fake-b", source: "builtin", factory: () => connB });
238
+ await reg.addSource({ name: "k8s", type: "fake-a", url: "", enabled: true });
239
+ await reg.addSource({ name: "tempo", type: "fake-b", url: "", enabled: true });
240
+ const out = parseTool(await getTopologyHandler(reg, {}));
241
+ // 2 sources reported in summary
242
+ assert.equal(out.sources.length, 2);
243
+ // But ONE resource after merge (deployment + trace_service of the
244
+ // same canonical name collapse via MERGEABLE_KIND_PAIRS).
245
+ assert.equal(out.resources.length, 1);
246
+ assert.equal(out.resources[0].name, "checkout");
247
+ });
248
+ it("single-source passes through unchanged (no intra-source merging)", async () => {
249
+ // The existing 4-pod fixture has two pods sharing `app: api`.
250
+ // With a single snapshot the merger must NOT collapse them.
251
+ const reg = await makeRegistry();
252
+ const out = parseTool(await getTopologyHandler(reg, {}));
253
+ assert.equal(out.resources.length, fixture().resources.length);
254
+ });
255
+ });
@@ -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>;