@thotischner/observability-mcp 1.6.0 → 1.7.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.
@@ -0,0 +1,165 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { isTopologyProvider } from "./interface.js";
4
+ // A minimal connector that exposes no topology methods.
5
+ function makeMetricsOnlyConnector() {
6
+ return {
7
+ name: "m1",
8
+ type: "prometheus",
9
+ signalType: "metrics",
10
+ async connect() { },
11
+ async healthCheck() {
12
+ return { status: "up", latencyMs: 0 };
13
+ },
14
+ async disconnect() { },
15
+ getDefaultMetrics() {
16
+ return [];
17
+ },
18
+ getMetrics() {
19
+ return [];
20
+ },
21
+ async listServices() {
22
+ return [];
23
+ },
24
+ };
25
+ }
26
+ // A fake topology connector that returns a tiny, well-formed graph.
27
+ function makeTopologyConnector() {
28
+ const resources = [
29
+ {
30
+ id: "k8s:pod:default/checkout-7f89d",
31
+ kind: "pod",
32
+ name: "checkout-7f89d",
33
+ source: "kind-cluster",
34
+ labels: { app: "checkout" },
35
+ attributes: { uid: "11111111-1111-1111-1111-111111111111" },
36
+ },
37
+ {
38
+ id: "k8s:node:worker-1",
39
+ kind: "node",
40
+ name: "worker-1",
41
+ source: "kind-cluster",
42
+ labels: {},
43
+ },
44
+ ];
45
+ const edges = [
46
+ {
47
+ from: "k8s:pod:default/checkout-7f89d",
48
+ to: "k8s:node:worker-1",
49
+ relation: "RUNS_ON",
50
+ source: "kind-cluster",
51
+ confidence: 1.0,
52
+ },
53
+ ];
54
+ return {
55
+ name: "kind-cluster",
56
+ type: "kubernetes",
57
+ signalType: "topology",
58
+ async connect() { },
59
+ async healthCheck() {
60
+ return { status: "up", latencyMs: 0 };
61
+ },
62
+ async disconnect() { },
63
+ getDefaultMetrics() {
64
+ return [];
65
+ },
66
+ getMetrics() {
67
+ return [];
68
+ },
69
+ async listServices() {
70
+ return [];
71
+ },
72
+ async listResources() {
73
+ return resources;
74
+ },
75
+ async listEdges() {
76
+ return edges;
77
+ },
78
+ async getTopologySnapshot() {
79
+ return { source: "kind-cluster", resources, edges, revision: 1 };
80
+ },
81
+ watchTopology(listener) {
82
+ // emit an initial resync, then a no-op unsubscribe
83
+ queueMicrotask(() => listener({
84
+ type: "resync",
85
+ snapshot: { source: "kind-cluster", resources, edges, revision: 1 },
86
+ }));
87
+ return () => { };
88
+ },
89
+ };
90
+ }
91
+ describe("isTopologyProvider", () => {
92
+ it("returns false for metrics-only connectors", () => {
93
+ assert.equal(isTopologyProvider(makeMetricsOnlyConnector()), false);
94
+ });
95
+ it("returns true when all four topology methods are present", () => {
96
+ assert.equal(isTopologyProvider(makeTopologyConnector()), true);
97
+ });
98
+ it("returns false if any topology method is missing", () => {
99
+ const conn = makeTopologyConnector();
100
+ // Strip one method — partial topology support is not a TopologyProvider.
101
+ delete conn.watchTopology;
102
+ assert.equal(isTopologyProvider(conn), false);
103
+ });
104
+ });
105
+ describe("topology data model", () => {
106
+ it("Resource.id follows the k8s:<kind>:<namespace>/<name> shape for namespaced kinds", async () => {
107
+ const conn = makeTopologyConnector();
108
+ assert.ok(isTopologyProvider(conn));
109
+ const resources = await conn.listResources();
110
+ const pod = resources.find((r) => r.kind === "pod");
111
+ assert.ok(pod, "expected a pod resource");
112
+ assert.match(pod.id, /^k8s:pod:[^/]+\/.+$/);
113
+ });
114
+ it("Resource.id for cluster-scoped kinds has no namespace segment", async () => {
115
+ const conn = makeTopologyConnector();
116
+ assert.ok(isTopologyProvider(conn));
117
+ const resources = await conn.listResources();
118
+ const node = resources.find((r) => r.kind === "node");
119
+ assert.ok(node, "expected a node resource");
120
+ assert.match(node.id, /^k8s:node:[^/]+$/);
121
+ });
122
+ it("every Resource and Edge carries a non-empty source", async () => {
123
+ const conn = makeTopologyConnector();
124
+ assert.ok(isTopologyProvider(conn));
125
+ const snap = await conn.getTopologySnapshot();
126
+ for (const r of snap.resources)
127
+ assert.ok(r.source.length > 0);
128
+ for (const e of snap.edges)
129
+ assert.ok(e.source.length > 0);
130
+ });
131
+ it("Edge endpoints reference existing Resource ids", async () => {
132
+ const conn = makeTopologyConnector();
133
+ assert.ok(isTopologyProvider(conn));
134
+ const snap = await conn.getTopologySnapshot();
135
+ const ids = new Set(snap.resources.map((r) => r.id));
136
+ for (const e of snap.edges) {
137
+ assert.ok(ids.has(e.from), `dangling edge.from: ${e.from}`);
138
+ assert.ok(ids.has(e.to), `dangling edge.to: ${e.to}`);
139
+ }
140
+ });
141
+ it("confidence is bounded to [0,1]", async () => {
142
+ const conn = makeTopologyConnector();
143
+ assert.ok(isTopologyProvider(conn));
144
+ const edges = await conn.listEdges();
145
+ for (const e of edges) {
146
+ assert.ok(e.confidence >= 0 && e.confidence <= 1);
147
+ }
148
+ });
149
+ });
150
+ describe("watchTopology", () => {
151
+ it("delivers a resync event with the current snapshot", async () => {
152
+ const conn = makeTopologyConnector();
153
+ assert.ok(isTopologyProvider(conn));
154
+ const events = [];
155
+ const unsubscribe = conn.watchTopology((e) => events.push(e));
156
+ // Allow the queued microtask to fire.
157
+ await new Promise((resolve) => setImmediate(resolve));
158
+ unsubscribe();
159
+ assert.equal(events.length, 1);
160
+ assert.equal(events[0].type, "resync");
161
+ if (events[0].type === "resync") {
162
+ assert.ok(events[0].snapshot.revision >= 1);
163
+ }
164
+ });
165
+ });
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
8
8
  import { z } from "zod";
9
9
  import { loadConfig, saveConfig, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_SETTINGS } from "./config/loader.js";
10
10
  import { ConnectorRegistry, getSupportedTypes } from "./connectors/registry.js";
11
+ import { isTopologyProvider } from "./connectors/interface.js";
11
12
  import { defaultContext, principalContext } from "./context.js";
12
13
  import { enforceEntitledAccess, enterpriseGateStatus, enterpriseGateInfo, enterprisePolicyView, enterpriseCatalogView, enterpriseAuditTail, authorizeAdmin, updateRbacPolicy, updateCatalog, } from "./enterprise-gate.js";
13
14
  import { loadCredentials, credentialsConfigured, extractToken, resolveToken, } from "./auth/credentials.js";
@@ -23,6 +24,7 @@ import { queryMetricsHandler } from "./tools/query-metrics.js";
23
24
  import { queryLogsHandler } from "./tools/query-logs.js";
24
25
  import { getServiceHealthHandler, setHealthThresholds } from "./tools/get-service-health.js";
25
26
  import { detectAnomaliesHandler } from "./tools/detect-anomalies.js";
27
+ import { getTopologyHandler, getBlastRadiusHandler } from "./tools/topology.js";
26
28
  import { fileURLToPath } from "node:url";
27
29
  import { dirname, join } from "node:path";
28
30
  import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
@@ -238,6 +240,48 @@ async function main() {
238
240
  await enforceEntitledAccess(ctx, { tool: "detect_anomalies", source: args?.source, service: args?.service });
239
241
  return withToolMetrics("detect_anomalies", () => detectAnomaliesHandler(registry, args, ctx));
240
242
  });
243
+ mcpServer.tool("get_topology", [
244
+ "Return the infrastructure topology graph (Resources and Edges) from every topology-capable connector.",
245
+ "When to use: when an agent needs to reason about which workload runs on which host, who owns whom, or which scope (namespace/project/folder) a resource belongs to. Pair with `get_blast_radius` for shared-host RCA.",
246
+ "Behavior: read-only, no side effects. Returns `{ sources, resources, edges, total, truncated }`. Filters compose: `source` to one connector, `kind` to one resource type (e.g. 'pod', 'node', 'deployment'), `scope` to members of a namespace/folder/project. Output is capped by `limit` (default 500, max 5000) and edges referencing dropped resources are removed.",
247
+ "Related: `get_blast_radius` to evaluate the impact of a host failure; `list_sources` to discover topology-capable connectors.",
248
+ ].join(" "), {
249
+ source: z
250
+ .string()
251
+ .optional()
252
+ .describe("Optional. Restrict the graph to one topology connector by source name (see `list_sources`). Default: merge across all connectors."),
253
+ kind: z
254
+ .string()
255
+ .optional()
256
+ .describe("Optional. Restrict to resources of one kind. Common values for Kubernetes: 'pod', 'node', 'deployment', 'replicaset', 'namespace'. Other connectors may emit different kinds (e.g. 'vm', 'hypervisor', 'volume'). Default: all kinds."),
257
+ scope: z
258
+ .string()
259
+ .optional()
260
+ .describe("Optional. Restrict to resources contained in a scope (anything pointed to by `IN_NAMESPACE` edges). Pass the scope's resource id (e.g. 'k8s:namespace:default') or its name (e.g. 'default'). Default: no scope filter."),
261
+ limit: z
262
+ .number()
263
+ .int()
264
+ .min(1)
265
+ .max(5000)
266
+ .optional()
267
+ .describe("Optional. Maximum resources to return; edges are trimmed to the kept set. Default 500, max 5000."),
268
+ }, async (args) => {
269
+ await enforceEntitledAccess(ctx, { tool: "get_topology", source: args?.source });
270
+ return withToolMetrics("get_topology", () => getTopologyHandler(registry, args, ctx));
271
+ });
272
+ mcpServer.tool("get_blast_radius", [
273
+ "Given a resource, return who else fails if its underlying host(s) fail.",
274
+ "When to use: cross-cutting RCA — when several services degrade together and you suspect a shared host. Works for any RUNS_ON relationship: pod→node, vm→hypervisor, container→host.",
275
+ "Behavior: read-only, no side effects. Resolves `resource` to a Resource (accepts canonical id, exact name, or unique substring), determines its host(s) via RUNS_ON, then lists every other resource that runs on those hosts, bucketed by ownership root (the terminal `OWNED_BY` target — e.g. the Deployment, not the ReplicaSet). If the target is itself a host, its tenants are reported. Returns a structured error if the resource is ambiguous or unknown.",
276
+ "Related: `get_topology` for the full graph; `get_service_health` for the per-service verdict on each co-tenant.",
277
+ ].join(" "), {
278
+ resource: z
279
+ .string()
280
+ .describe("Required. Resource to evaluate. Accepts the canonical id (e.g. 'k8s:pod:default/checkout-7f89d'), the exact resource name (e.g. 'checkout-7f89d'), or a unique substring of either."),
281
+ }, async (args) => {
282
+ await enforceEntitledAccess(ctx, { tool: "get_blast_radius" });
283
+ return withToolMetrics("get_blast_radius", () => getBlastRadiusHandler(registry, args, ctx));
284
+ });
241
285
  return mcpServer;
242
286
  }
243
287
  // --- HTTP server ---
@@ -691,6 +735,36 @@ async function main() {
691
735
  res.status(500).json({ error: "Failed to get health data" });
692
736
  }
693
737
  });
738
+ // --- Topology API ---
739
+ // Returns the union of topology snapshots across all topology-capable
740
+ // connectors (today only "kubernetes"). One JSON document so the UI can
741
+ // render summary + grouped views without N round-trips.
742
+ app.get("/api/topology", async (_req, res) => {
743
+ try {
744
+ const sources = [];
745
+ const allResources = [];
746
+ const allEdges = [];
747
+ for (const c of registry.getAll()) {
748
+ if (!isTopologyProvider(c))
749
+ continue;
750
+ const snap = await c.getTopologySnapshot();
751
+ sources.push({
752
+ source: snap.source,
753
+ type: c.type,
754
+ revision: snap.revision,
755
+ resources: snap.resources.length,
756
+ edges: snap.edges.length,
757
+ });
758
+ allResources.push(...snap.resources);
759
+ allEdges.push(...snap.edges);
760
+ }
761
+ res.json({ sources, resources: allResources, edges: allEdges });
762
+ }
763
+ catch (err) {
764
+ console.error("topology endpoint failed:", err);
765
+ res.status(500).json({ error: "Failed to read topology" });
766
+ }
767
+ });
694
768
  // --- Settings API ---
695
769
  // Get general settings
696
770
  app.get("/api/settings", (_req, res) => {
@@ -1,7 +1,7 @@
1
1
  export type { ObservabilityConnector } from "../connectors/interface.js";
2
2
  export { manifestSchema } from "./manifest-schema.js";
3
3
  export type { ValidatedConnectorManifest } from "./manifest-schema.js";
4
- export type { SignalType, SourceConfig, SourceAuth, SourceTls, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, MetricSummary, DataPoint, LogQuery, LogResult, LogEntry, LogSummary, MetricDefinition, } from "../types.js";
4
+ export type { SignalType, SourceConfig, SourceAuth, SourceTls, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, MetricSummary, DataPoint, LogQuery, LogResult, LogEntry, LogSummary, MetricDefinition, Resource, Edge, TopologySnapshot, TopologyChangeEvent, TopologyChangeListener, } from "../types.js";
5
5
  /**
6
6
  * Manifest shape declared in a plugin's `manifest.json`. The server
7
7
  * validates plugin manifests against this at load time.
@@ -18,7 +18,7 @@ export interface ConnectorManifest {
18
18
  /** Semver of this connector build. */
19
19
  version: string;
20
20
  description: string;
21
- signalTypes: Array<"metrics" | "logs" | "traces">;
21
+ signalTypes: Array<"metrics" | "logs" | "traces" | "topology">;
22
22
  homepage?: string;
23
23
  license?: string;
24
24
  logo?: string;
@@ -9,6 +9,7 @@ export declare const manifestSchema: z.ZodObject<{
9
9
  metrics: "metrics";
10
10
  logs: "logs";
11
11
  traces: "traces";
12
+ topology: "topology";
12
13
  }>>;
13
14
  homepage: z.ZodOptional<z.ZodString>;
14
15
  license: z.ZodOptional<z.ZodString>;
@@ -15,7 +15,7 @@ export const manifestSchema = z.object({
15
15
  message: "version must be semver",
16
16
  }),
17
17
  description: z.string().min(1),
18
- signalTypes: z.array(z.enum(["metrics", "logs", "traces"])).min(1),
18
+ signalTypes: z.array(z.enum(["metrics", "logs", "traces", "topology"])).min(1),
19
19
  homepage: z.string().url().optional(),
20
20
  license: z.string().optional(),
21
21
  logo: z.string().optional(),
@@ -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 {};