@thotischner/observability-mcp 1.6.0 → 1.7.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.
@@ -0,0 +1,64 @@
1
+ import type { ConnectorRegistry } from "../connectors/registry.js";
2
+ import type { Resource, Edge } from "../types.js";
3
+ import { type RequestContext } from "../context.js";
4
+ interface AggregatedTopology {
5
+ sources: Array<{
6
+ source: string;
7
+ type: string;
8
+ revision: number;
9
+ resources: number;
10
+ edges: number;
11
+ }>;
12
+ resources: Resource[];
13
+ edges: Edge[];
14
+ }
15
+ export declare function aggregateTopology(registry: ConnectorRegistry): Promise<AggregatedTopology>;
16
+ /**
17
+ * Resolve a caller-supplied identifier to a Resource. Accepts:
18
+ * - exact canonical id (e.g. "k8s:pod:default/checkout-7f89d")
19
+ * - exact resource name (e.g. "checkout-7f89d")
20
+ * - case-insensitive substring of name (only used if uniquely matching)
21
+ *
22
+ * Stays generic — no knowledge of kind-specific id grammars.
23
+ */
24
+ export declare function resolveResource(query: string, resources: Resource[]): Resource | {
25
+ error: string;
26
+ candidates?: string[];
27
+ };
28
+ export declare const getTopologyDefinition: {
29
+ name: "get_topology";
30
+ description: string;
31
+ };
32
+ export interface GetTopologyArgs {
33
+ source?: string;
34
+ kind?: string;
35
+ scope?: string;
36
+ limit?: number;
37
+ }
38
+ export declare function getTopologyHandler(registry: ConnectorRegistry, args?: GetTopologyArgs, _ctx?: RequestContext): Promise<{
39
+ content: {
40
+ type: "text";
41
+ text: string;
42
+ }[];
43
+ }>;
44
+ export declare const getBlastRadiusDefinition: {
45
+ name: "get_blast_radius";
46
+ description: string;
47
+ };
48
+ export interface GetBlastRadiusArgs {
49
+ resource: string;
50
+ }
51
+ export declare function getBlastRadiusHandler(registry: ConnectorRegistry, args: GetBlastRadiusArgs, _ctx?: RequestContext): Promise<{
52
+ isError: boolean;
53
+ content: {
54
+ type: "text";
55
+ text: string;
56
+ }[];
57
+ } | {
58
+ content: {
59
+ type: "text";
60
+ text: string;
61
+ }[];
62
+ isError?: undefined;
63
+ }>;
64
+ export {};
@@ -0,0 +1,233 @@
1
+ // MCP tools that expose the infrastructure topology graph to agents.
2
+ //
3
+ // Two tools live here:
4
+ // - `get_topology` — returns the merged resource/edge graph across
5
+ // every topology-capable connector, optionally
6
+ // filtered by source/kind/scope. Useful as a
7
+ // starting point for any cross-cutting question.
8
+ // - `get_blast_radius` — given a resource, returns who else is co-tenant
9
+ // on the same host(s). The canonical "if this
10
+ // host fails, who else fails?" question.
11
+ //
12
+ // Both stay generic — they pivot on the `RUNS_ON` and `OWNED_BY` relations
13
+ // rather than any specific kind. Adding a vCenter/NetBox/AWS topology
14
+ // connector later requires zero changes here.
15
+ import { isTopologyProvider } from "../connectors/interface.js";
16
+ import { defaultContext } from "../context.js";
17
+ export async function aggregateTopology(registry) {
18
+ const sources = [];
19
+ const resources = [];
20
+ const edges = [];
21
+ for (const c of registry.getAll()) {
22
+ if (!isTopologyProvider(c))
23
+ continue;
24
+ try {
25
+ const snap = await c.getTopologySnapshot();
26
+ sources.push({
27
+ source: snap.source,
28
+ type: c.type,
29
+ revision: snap.revision,
30
+ resources: snap.resources.length,
31
+ edges: snap.edges.length,
32
+ });
33
+ resources.push(...snap.resources);
34
+ edges.push(...snap.edges);
35
+ }
36
+ catch {
37
+ // A misbehaving connector must not poison the agent's view of the graph.
38
+ }
39
+ }
40
+ return { sources, resources, edges };
41
+ }
42
+ /**
43
+ * Resolve a caller-supplied identifier to a Resource. Accepts:
44
+ * - exact canonical id (e.g. "k8s:pod:default/checkout-7f89d")
45
+ * - exact resource name (e.g. "checkout-7f89d")
46
+ * - case-insensitive substring of name (only used if uniquely matching)
47
+ *
48
+ * Stays generic — no knowledge of kind-specific id grammars.
49
+ */
50
+ export function resolveResource(query, resources) {
51
+ if (!query)
52
+ return { error: "Missing resource query" };
53
+ const exactId = resources.find((r) => r.id === query);
54
+ if (exactId)
55
+ return exactId;
56
+ const exactName = resources.filter((r) => r.name === query);
57
+ if (exactName.length === 1)
58
+ return exactName[0];
59
+ if (exactName.length > 1) {
60
+ return {
61
+ error: `Name '${query}' is ambiguous across ${exactName.length} resources; pass the full id`,
62
+ candidates: exactName.map((r) => r.id),
63
+ };
64
+ }
65
+ const q = query.toLowerCase();
66
+ const fuzzy = resources.filter((r) => r.name.toLowerCase().includes(q) || r.id.toLowerCase().includes(q));
67
+ if (fuzzy.length === 1)
68
+ return fuzzy[0];
69
+ if (fuzzy.length > 1) {
70
+ return {
71
+ error: `Query '${query}' matched ${fuzzy.length} resources; pass the full id`,
72
+ candidates: fuzzy.slice(0, 25).map((r) => r.id),
73
+ };
74
+ }
75
+ return { error: `No resource found matching '${query}'` };
76
+ }
77
+ // --- get_topology ------------------------------------------------------
78
+ export const getTopologyDefinition = {
79
+ name: "get_topology",
80
+ 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
+ };
82
+ export async function getTopologyHandler(registry, args = {}, _ctx = defaultContext()) {
83
+ const agg = await aggregateTopology(registry);
84
+ // Filtering — all optional. Filters compose conjunctively.
85
+ let resources = agg.resources;
86
+ let edges = agg.edges;
87
+ if (args.source) {
88
+ resources = resources.filter((r) => r.source === args.source);
89
+ edges = edges.filter((e) => e.source === args.source);
90
+ }
91
+ if (args.kind) {
92
+ resources = resources.filter((r) => r.kind === args.kind);
93
+ }
94
+ if (args.scope) {
95
+ // Match either by scope resource id (e.g. "k8s:namespace:default") or by name (e.g. "default").
96
+ const inScope = new Set();
97
+ for (const e of agg.edges) {
98
+ if (e.relation !== "IN_NAMESPACE")
99
+ continue;
100
+ const target = agg.resources.find((r) => r.id === e.to);
101
+ if (!target)
102
+ continue;
103
+ if (target.id === args.scope || target.name === args.scope)
104
+ inScope.add(e.from);
105
+ }
106
+ resources = resources.filter((r) => inScope.has(r.id));
107
+ }
108
+ // Edges must still reference resources that survived filtering.
109
+ const keepIds = new Set(resources.map((r) => r.id));
110
+ edges = edges.filter((e) => keepIds.has(e.from) && keepIds.has(e.to));
111
+ // Soft truncation so an agent can't accidentally pull a 10k-node graph
112
+ // into context — defaults are generous but capped.
113
+ const limit = Math.min(Math.max(args.limit ?? 500, 1), 5000);
114
+ const truncated = resources.length > limit;
115
+ if (truncated) {
116
+ resources = resources.slice(0, limit);
117
+ const keep2 = new Set(resources.map((r) => r.id));
118
+ edges = edges.filter((e) => keep2.has(e.from) && keep2.has(e.to));
119
+ }
120
+ const payload = {
121
+ sources: agg.sources,
122
+ resources,
123
+ edges,
124
+ total: { resources: agg.resources.length, edges: agg.edges.length },
125
+ truncated,
126
+ };
127
+ return {
128
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
129
+ };
130
+ }
131
+ // --- get_blast_radius --------------------------------------------------
132
+ export const getBlastRadiusDefinition = {
133
+ name: "get_blast_radius",
134
+ 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
+ };
136
+ export async function getBlastRadiusHandler(registry, args, _ctx = defaultContext()) {
137
+ const agg = await aggregateTopology(registry);
138
+ const found = resolveResource(args.resource, agg.resources);
139
+ if ("error" in found) {
140
+ return {
141
+ isError: true,
142
+ content: [{ type: "text", text: JSON.stringify(found, null, 2) }],
143
+ };
144
+ }
145
+ // Index edges once.
146
+ const byId = new Map(agg.resources.map((r) => [r.id, r]));
147
+ const runsOnOut = new Map(); // child → host
148
+ const runsOnIn = new Map(); // host → children
149
+ const ownedByOut = new Map(); // child → owner
150
+ for (const e of agg.edges) {
151
+ if (e.relation === "RUNS_ON") {
152
+ runsOnOut.set(e.from, e.to);
153
+ const s = runsOnIn.get(e.to) || new Set();
154
+ s.add(e.from);
155
+ runsOnIn.set(e.to, s);
156
+ }
157
+ else if (e.relation === "OWNED_BY") {
158
+ if (!ownedByOut.has(e.from))
159
+ ownedByOut.set(e.from, e.to);
160
+ }
161
+ }
162
+ function ownershipRoot(id) {
163
+ let cur = id;
164
+ for (let i = 0; i < 16; i++) {
165
+ const next = ownedByOut.get(cur);
166
+ if (!next || next === cur)
167
+ return cur;
168
+ cur = next;
169
+ }
170
+ return cur;
171
+ }
172
+ // Determine which hosts the target depends on. If the resource is itself
173
+ // a host (incoming RUNS_ON exists), the host is the resource itself.
174
+ const hosts = [];
175
+ if (runsOnIn.has(found.id)) {
176
+ hosts.push(found.id);
177
+ }
178
+ else if (runsOnOut.has(found.id)) {
179
+ hosts.push(runsOnOut.get(found.id));
180
+ }
181
+ if (hosts.length === 0) {
182
+ const payload = {
183
+ target: { id: found.id, name: found.name, kind: found.kind },
184
+ hosts: [],
185
+ note: "This resource has no RUNS_ON edges in the current topology — either it is itself a top-level host with no tenants yet, or its connector does not emit RUNS_ON.",
186
+ };
187
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
188
+ }
189
+ const perHost = [];
190
+ for (const hostId of hosts) {
191
+ const host = byId.get(hostId);
192
+ if (!host)
193
+ continue;
194
+ const childIds = Array.from(runsOnIn.get(hostId) || []);
195
+ // Bucket children by their ownership root.
196
+ const buckets = new Map();
197
+ for (const cid of childIds) {
198
+ const child = byId.get(cid);
199
+ if (!child)
200
+ continue;
201
+ const rootId = ownershipRoot(cid);
202
+ const root = byId.get(rootId);
203
+ const bucket = buckets.get(rootId) || {
204
+ ownershipRoot: rootId,
205
+ ownershipRootName: root ? root.name : rootId,
206
+ ownershipRootKind: root ? root.kind : "?",
207
+ members: [],
208
+ };
209
+ bucket.members.push({ id: child.id, name: child.name, kind: child.kind });
210
+ buckets.set(rootId, bucket);
211
+ }
212
+ const coTenants = Array.from(buckets.values()).sort((a, b) => b.members.length - a.members.length);
213
+ perHost.push({
214
+ host: { id: host.id, name: host.name, kind: host.kind },
215
+ ownershipRoots: coTenants.length,
216
+ coTenants,
217
+ });
218
+ }
219
+ // Surface a one-line recommendation when ≥2 services share a host —
220
+ // exactly the "blast radius if it fails" case that justifies this tool.
221
+ const sharedHosts = perHost.filter((h) => h.ownershipRoots > 1);
222
+ const summary = sharedHosts.length > 0
223
+ ? `${sharedHosts.length} of ${perHost.length} host(s) carry ≥2 services — those hosts are blast-radius candidates if they fail.`
224
+ : `No host carries more than one service besides the target — limited shared-host blast radius.`;
225
+ const payload = {
226
+ target: { id: found.id, name: found.name, kind: found.kind },
227
+ hosts: perHost,
228
+ summary,
229
+ };
230
+ return {
231
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
232
+ };
233
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,210 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ConnectorRegistry } from "../connectors/registry.js";
4
+ import { PluginLoader } from "../connectors/loader.js";
5
+ import { getTopologyHandler, getBlastRadiusHandler, resolveResource, } from "./topology.js";
6
+ // --- A minimal stand-alone topology connector used as test fixture -----
7
+ // Lets the suite drive the tool handlers without a real K8s cluster.
8
+ class FakeTopologyConnector {
9
+ type = "fake";
10
+ signalType = "topology";
11
+ name = "fake-cluster";
12
+ resources;
13
+ edges;
14
+ constructor(resources, edges) {
15
+ this.resources = resources;
16
+ this.edges = edges;
17
+ }
18
+ async connect(c) { this.name = c.name; }
19
+ async healthCheck() { return { status: "up", latencyMs: 1 }; }
20
+ async disconnect() { }
21
+ getDefaultMetrics() { return []; }
22
+ getMetrics() { return []; }
23
+ async listServices() { return []; }
24
+ async listResources() { return this.resources; }
25
+ async listEdges() { return this.edges; }
26
+ async getTopologySnapshot() {
27
+ return { source: this.name, resources: this.resources, edges: this.edges, revision: 1 };
28
+ }
29
+ watchTopology(_l) { return () => { }; }
30
+ }
31
+ // Build a small but realistic topology covering both happy and edge cases:
32
+ // two services, two hosts, ownership chain, one orphan, one cross-kind link.
33
+ function fixture() {
34
+ const r = [
35
+ // Hosts
36
+ { id: "k8s:node:n1", kind: "node", name: "n1", source: "fake", labels: {} },
37
+ { id: "k8s:node:n2", kind: "node", name: "n2", source: "fake", labels: {} },
38
+ // Scope
39
+ { id: "k8s:namespace:prod", kind: "namespace", name: "prod", source: "fake", labels: {} },
40
+ { id: "k8s:namespace:staging", kind: "namespace", name: "staging", source: "fake", labels: {} },
41
+ // Ownership roots (deployments)
42
+ { id: "k8s:deployment:prod/api", kind: "deployment", name: "api", source: "fake", labels: {} },
43
+ { id: "k8s:deployment:prod/db", kind: "deployment", name: "db", source: "fake", labels: {} },
44
+ // Intermediate
45
+ { id: "k8s:replicaset:prod/api-1", kind: "replicaset", name: "api-1", source: "fake", labels: {} },
46
+ // Workloads
47
+ { id: "k8s:pod:prod/api-aaa", kind: "pod", name: "api-aaa", source: "fake", labels: { app: "api" } },
48
+ { id: "k8s:pod:prod/api-bbb", kind: "pod", name: "api-bbb", source: "fake", labels: { app: "api" } },
49
+ { id: "k8s:pod:prod/db-aaa", kind: "pod", name: "db-aaa", source: "fake", labels: { app: "db" } },
50
+ // Orphan with no RUNS_ON (e.g. pending)
51
+ { id: "k8s:pod:staging/pending-1", kind: "pod", name: "pending-1", source: "fake", labels: {} },
52
+ ];
53
+ const e = [
54
+ // ownership chain
55
+ { from: "k8s:pod:prod/api-aaa", to: "k8s:replicaset:prod/api-1", relation: "OWNED_BY", source: "fake", confidence: 1 },
56
+ { from: "k8s:pod:prod/api-bbb", to: "k8s:replicaset:prod/api-1", relation: "OWNED_BY", source: "fake", confidence: 1 },
57
+ { from: "k8s:replicaset:prod/api-1", to: "k8s:deployment:prod/api", relation: "OWNED_BY", source: "fake", confidence: 1 },
58
+ { from: "k8s:pod:prod/db-aaa", to: "k8s:deployment:prod/db", relation: "OWNED_BY", source: "fake", confidence: 1 },
59
+ // RUNS_ON — api-aaa shares n1 with db-aaa (blast-radius case);
60
+ // api-bbb alone on n2 (no shared-host case)
61
+ { from: "k8s:pod:prod/api-aaa", to: "k8s:node:n1", relation: "RUNS_ON", source: "fake", confidence: 1 },
62
+ { from: "k8s:pod:prod/db-aaa", to: "k8s:node:n1", relation: "RUNS_ON", source: "fake", confidence: 1 },
63
+ { from: "k8s:pod:prod/api-bbb", to: "k8s:node:n2", relation: "RUNS_ON", source: "fake", confidence: 1 },
64
+ // IN_NAMESPACE
65
+ { from: "k8s:pod:prod/api-aaa", to: "k8s:namespace:prod", relation: "IN_NAMESPACE", source: "fake", confidence: 1 },
66
+ { from: "k8s:pod:prod/api-bbb", to: "k8s:namespace:prod", relation: "IN_NAMESPACE", source: "fake", confidence: 1 },
67
+ { from: "k8s:pod:prod/db-aaa", to: "k8s:namespace:prod", relation: "IN_NAMESPACE", source: "fake", confidence: 1 },
68
+ { from: "k8s:deployment:prod/api", to: "k8s:namespace:prod", relation: "IN_NAMESPACE", source: "fake", confidence: 1 },
69
+ { from: "k8s:deployment:prod/db", to: "k8s:namespace:prod", relation: "IN_NAMESPACE", source: "fake", confidence: 1 },
70
+ { from: "k8s:replicaset:prod/api-1", to: "k8s:namespace:prod", relation: "IN_NAMESPACE", source: "fake", confidence: 1 },
71
+ { from: "k8s:pod:staging/pending-1", to: "k8s:namespace:staging", relation: "IN_NAMESPACE", source: "fake", confidence: 1 },
72
+ ];
73
+ return { resources: r, edges: e };
74
+ }
75
+ async function makeRegistry() {
76
+ const { resources, edges } = fixture();
77
+ const loader = new PluginLoader();
78
+ // Don't load builtins/filesystem — we plug a single fake connector in
79
+ // directly so the suite is hermetic and fast.
80
+ const reg = new ConnectorRegistry(loader);
81
+ const conn = new FakeTopologyConnector(resources, edges);
82
+ await conn.connect({ name: "fake-cluster", type: "fake", url: "", enabled: true });
83
+ // ConnectorRegistry has no public "register a live instance" method,
84
+ // so we install via the documented addSource path with a fake loader.
85
+ loader.connectors.set("fake", {
86
+ name: "fake",
87
+ source: "builtin",
88
+ factory: () => conn,
89
+ });
90
+ await reg.addSource({ name: "fake-cluster", type: "fake", url: "", enabled: true });
91
+ return reg;
92
+ }
93
+ function parseTool(result) {
94
+ return JSON.parse(result.content[0].text);
95
+ }
96
+ describe("resolveResource", () => {
97
+ const { resources } = fixture();
98
+ it("matches an exact id", () => {
99
+ const r = resolveResource("k8s:node:n1", resources);
100
+ assert.ok(!("error" in r));
101
+ if (!("error" in r))
102
+ assert.equal(r.kind, "node");
103
+ });
104
+ it("matches a unique exact name", () => {
105
+ const r = resolveResource("api-aaa", resources);
106
+ assert.ok(!("error" in r));
107
+ });
108
+ it("returns candidates for an ambiguous fuzzy match", () => {
109
+ // "aaa" doesn't exactly match any name, but fuzzy-matches api-aaa and db-aaa
110
+ const r = resolveResource("aaa", resources);
111
+ assert.ok("error" in r);
112
+ if ("error" in r) {
113
+ assert.ok((r.candidates?.length ?? 0) >= 2);
114
+ }
115
+ });
116
+ it("returns a clean error for no match", () => {
117
+ const r = resolveResource("does-not-exist", resources);
118
+ assert.ok("error" in r);
119
+ });
120
+ });
121
+ describe("get_topology tool", () => {
122
+ it("returns the full graph by default", async () => {
123
+ const reg = await makeRegistry();
124
+ const out = parseTool(await getTopologyHandler(reg, {}));
125
+ assert.equal(out.sources.length, 1);
126
+ assert.equal(out.resources.length, fixture().resources.length);
127
+ assert.equal(out.edges.length, fixture().edges.length);
128
+ assert.equal(out.truncated, false);
129
+ });
130
+ it("filters by kind", async () => {
131
+ const reg = await makeRegistry();
132
+ const out = parseTool(await getTopologyHandler(reg, { kind: "pod" }));
133
+ for (const r of out.resources)
134
+ assert.equal(r.kind, "pod");
135
+ // edges must reference only the kept resources
136
+ const ids = new Set(out.resources.map((r) => r.id));
137
+ for (const e of out.edges) {
138
+ assert.ok(ids.has(e.from) && ids.has(e.to));
139
+ }
140
+ });
141
+ it("filters by scope name or id", async () => {
142
+ const reg = await makeRegistry();
143
+ const byName = parseTool(await getTopologyHandler(reg, { scope: "prod" }));
144
+ const byId = parseTool(await getTopologyHandler(reg, { scope: "k8s:namespace:prod" }));
145
+ assert.equal(byName.resources.length, byId.resources.length);
146
+ // staging pod must not appear
147
+ assert.ok(!byName.resources.some((r) => r.name === "pending-1"));
148
+ });
149
+ it("respects the limit and reports truncation", async () => {
150
+ const reg = await makeRegistry();
151
+ const out = parseTool(await getTopologyHandler(reg, { limit: 3 }));
152
+ assert.equal(out.resources.length, 3);
153
+ assert.equal(out.truncated, true);
154
+ assert.equal(out.total.resources, fixture().resources.length);
155
+ });
156
+ });
157
+ describe("get_blast_radius tool", () => {
158
+ it("reports shared-host blast radius for a co-located pod", async () => {
159
+ const reg = await makeRegistry();
160
+ const out = parseTool(await getBlastRadiusHandler(reg, { resource: "api-aaa" }));
161
+ assert.equal(out.target.name, "api-aaa");
162
+ assert.equal(out.hosts.length, 1);
163
+ const host = out.hosts[0];
164
+ assert.equal(host.host.name, "n1");
165
+ // Two ownership roots on n1: deployment/api and deployment/db
166
+ assert.equal(host.ownershipRoots, 2);
167
+ const rootNames = new Set(host.coTenants.map((c) => c.ownershipRootName));
168
+ assert.ok(rootNames.has("api"));
169
+ assert.ok(rootNames.has("db"));
170
+ assert.match(out.summary, /blast-radius candidate/i);
171
+ });
172
+ it("reports no shared-host for a pod alone on its node", async () => {
173
+ const reg = await makeRegistry();
174
+ const out = parseTool(await getBlastRadiusHandler(reg, { resource: "api-bbb" }));
175
+ assert.equal(out.hosts.length, 1);
176
+ assert.equal(out.hosts[0].host.name, "n2");
177
+ assert.equal(out.hosts[0].ownershipRoots, 1);
178
+ assert.match(out.summary, /limited shared-host/i);
179
+ });
180
+ it("treats a host resource as its own host (incoming RUNS_ON pivot)", async () => {
181
+ const reg = await makeRegistry();
182
+ const out = parseTool(await getBlastRadiusHandler(reg, { resource: "k8s:node:n1" }));
183
+ assert.equal(out.target.kind, "node");
184
+ assert.equal(out.hosts.length, 1);
185
+ assert.equal(out.hosts[0].host.id, "k8s:node:n1");
186
+ });
187
+ it("returns a note when a resource has no RUNS_ON edges", async () => {
188
+ const reg = await makeRegistry();
189
+ const out = parseTool(await getBlastRadiusHandler(reg, { resource: "pending-1" }));
190
+ assert.equal(out.hosts.length, 0);
191
+ assert.match(out.note, /no RUNS_ON/i);
192
+ });
193
+ it("surfaces a structured error for unknown resources", async () => {
194
+ const reg = await makeRegistry();
195
+ const result = await getBlastRadiusHandler(reg, { resource: "totally-not-here" });
196
+ assert.equal(result.isError, true);
197
+ const out = parseTool(result);
198
+ assert.match(out.error, /No resource found/);
199
+ });
200
+ it("uses ownership root, not direct owner, when grouping co-tenants", async () => {
201
+ // api-aaa is OWNED_BY a ReplicaSet which is OWNED_BY a Deployment.
202
+ // The blast-radius should bucket api-aaa under the Deployment, not the RS.
203
+ const reg = await makeRegistry();
204
+ const out = parseTool(await getBlastRadiusHandler(reg, { resource: "api-aaa" }));
205
+ const onN1 = out.hosts[0];
206
+ const apiBucket = onN1.coTenants.find((c) => c.ownershipRootName === "api");
207
+ assert.ok(apiBucket, "expected an 'api' deployment bucket on n1");
208
+ assert.equal(apiBucket.ownershipRootKind, "deployment");
209
+ });
210
+ });
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type SignalType = "metrics" | "logs" | "traces";
1
+ export type SignalType = "metrics" | "logs" | "traces" | "topology";
2
2
  export type HealthStatus = "healthy" | "degraded" | "critical";
3
3
  export type Trend = "rising" | "falling" | "stable";
4
4
  export type AnomalySeverity = "low" | "medium" | "high";
@@ -151,6 +151,72 @@ export interface LogResult {
151
151
  entries: LogEntry[];
152
152
  summary: LogSummary;
153
153
  }
154
+ /**
155
+ * A discrete infrastructure entity discovered by a topology-aware connector.
156
+ *
157
+ * `kind` and the relation strings on Edge are intentionally open (not unions):
158
+ * future connectors (vCenter, NetBox, SNMP, ...) will introduce new kinds and
159
+ * relations. Document common values here, but do not hard-restrict the type.
160
+ *
161
+ * `id` is a stable, human-readable canonical key. For Kubernetes we use
162
+ * `k8s:<kind>:<namespace>/<name>` for namespaced kinds, `k8s:<kind>:<name>`
163
+ * for cluster-scoped kinds. Pod names are ephemeral by design — that's
164
+ * acceptable since pods are short-lived; deployments/nodes/services are
165
+ * stable. Backend identifiers (e.g. K8s metadata.uid) belong in `attributes`.
166
+ *
167
+ * `source` is mandatory so a future entity-resolution layer can merge views
168
+ * from multiple connectors without ambiguity.
169
+ *
170
+ * Common kinds (Kubernetes): "pod", "node", "deployment", "service", "namespace".
171
+ */
172
+ export interface Resource {
173
+ id: string;
174
+ kind: string;
175
+ name: string;
176
+ source: string;
177
+ labels: Record<string, string>;
178
+ attributes?: Record<string, unknown>;
179
+ }
180
+ /**
181
+ * A directed relationship between two Resources.
182
+ *
183
+ * Common relations (Kubernetes):
184
+ * - "RUNS_ON" pod -> node, container -> host, vm -> hypervisor
185
+ * - "OWNED_BY" pod -> replicaset/deployment
186
+ * - "ROUTES_TO" service -> pod
187
+ * - "IN_NAMESPACE" pod/service/deployment -> namespace
188
+ *
189
+ * `confidence` is 0..1. For data that comes straight from an authoritative
190
+ * source (K8s API), use 1.0. Inferred relations (e.g. label-based matching)
191
+ * should report lower values.
192
+ */
193
+ export interface Edge {
194
+ from: string;
195
+ to: string;
196
+ relation: string;
197
+ source: string;
198
+ confidence: number;
199
+ }
200
+ /** Snapshot of the topology graph as known by a single connector. */
201
+ export interface TopologySnapshot {
202
+ source: string;
203
+ resources: Resource[];
204
+ edges: Edge[];
205
+ /** Monotonic counter; bumped on each successful watch event apply. */
206
+ revision: number;
207
+ }
208
+ /** Event emitted by a watching connector when its in-memory graph changes. */
209
+ export type TopologyChangeEvent = {
210
+ type: "resource_added" | "resource_updated" | "resource_removed";
211
+ resource: Resource;
212
+ } | {
213
+ type: "edge_added" | "edge_removed";
214
+ edge: Edge;
215
+ } | {
216
+ type: "resync";
217
+ snapshot: TopologySnapshot;
218
+ };
219
+ export type TopologyChangeListener = (event: TopologyChangeEvent) => void;
154
220
  export interface AnomalyReport {
155
221
  metric: string;
156
222
  severity: AnomalySeverity;