@butlr/butlr-mcp-server 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/cache/topology-cache.d.ts +18 -3
  2. package/dist/cache/topology-cache.d.ts.map +1 -1
  3. package/dist/cache/topology-cache.js +19 -3
  4. package/dist/cache/topology-cache.js.map +1 -1
  5. package/dist/clients/queries/tags.d.ts +63 -6
  6. package/dist/clients/queries/tags.d.ts.map +1 -1
  7. package/dist/clients/queries/tags.js +26 -4
  8. package/dist/clients/queries/tags.js.map +1 -1
  9. package/dist/clients/types.d.ts +4 -0
  10. package/dist/clients/types.d.ts.map +1 -1
  11. package/dist/errors/mcp-errors.d.ts +34 -0
  12. package/dist/errors/mcp-errors.d.ts.map +1 -1
  13. package/dist/errors/mcp-errors.js +71 -5
  14. package/dist/errors/mcp-errors.js.map +1 -1
  15. package/dist/tools/butlr-available-rooms.d.ts +5 -23
  16. package/dist/tools/butlr-available-rooms.d.ts.map +1 -1
  17. package/dist/tools/butlr-available-rooms.js +104 -51
  18. package/dist/tools/butlr-available-rooms.js.map +1 -1
  19. package/dist/tools/butlr-fetch-entity-details.d.ts.map +1 -1
  20. package/dist/tools/butlr-fetch-entity-details.js +7 -1
  21. package/dist/tools/butlr-fetch-entity-details.js.map +1 -1
  22. package/dist/tools/butlr-get-asset-details.d.ts.map +1 -1
  23. package/dist/tools/butlr-get-asset-details.js +29 -4
  24. package/dist/tools/butlr-get-asset-details.js.map +1 -1
  25. package/dist/tools/butlr-get-current-occupancy.d.ts.map +1 -1
  26. package/dist/tools/butlr-get-current-occupancy.js +14 -3
  27. package/dist/tools/butlr-get-current-occupancy.js.map +1 -1
  28. package/dist/tools/butlr-get-occupancy-timeseries.d.ts.map +1 -1
  29. package/dist/tools/butlr-get-occupancy-timeseries.js +15 -4
  30. package/dist/tools/butlr-get-occupancy-timeseries.js.map +1 -1
  31. package/dist/tools/butlr-list-tags.d.ts +28 -10
  32. package/dist/tools/butlr-list-tags.d.ts.map +1 -1
  33. package/dist/tools/butlr-list-tags.js +81 -24
  34. package/dist/tools/butlr-list-tags.js.map +1 -1
  35. package/dist/tools/butlr-list-topology.d.ts +7 -1
  36. package/dist/tools/butlr-list-topology.d.ts.map +1 -1
  37. package/dist/tools/butlr-list-topology.js +845 -35
  38. package/dist/tools/butlr-list-topology.js.map +1 -1
  39. package/dist/tools/butlr-search-assets.d.ts.map +1 -1
  40. package/dist/tools/butlr-search-assets.js +7 -1
  41. package/dist/tools/butlr-search-assets.js.map +1 -1
  42. package/dist/tools/butlr-space-busyness.d.ts.map +1 -1
  43. package/dist/tools/butlr-space-busyness.js +17 -2
  44. package/dist/tools/butlr-space-busyness.js.map +1 -1
  45. package/dist/tools/butlr-traffic-flow.d.ts.map +1 -1
  46. package/dist/tools/butlr-traffic-flow.js +9 -3
  47. package/dist/tools/butlr-traffic-flow.js.map +1 -1
  48. package/dist/types/responses.d.ts +123 -5
  49. package/dist/types/responses.d.ts.map +1 -1
  50. package/dist/utils/field-validator.d.ts.map +1 -1
  51. package/dist/utils/field-validator.js +3 -0
  52. package/dist/utils/field-validator.js.map +1 -1
  53. package/dist/utils/occupancy-helpers.d.ts.map +1 -1
  54. package/dist/utils/occupancy-helpers.js +18 -4
  55. package/dist/utils/occupancy-helpers.js.map +1 -1
  56. package/dist/utils/tag-resolver.d.ts +99 -0
  57. package/dist/utils/tag-resolver.d.ts.map +1 -0
  58. package/dist/utils/tag-resolver.js +108 -0
  59. package/dist/utils/tag-resolver.js.map +1 -0
  60. package/package.json +1 -1
@@ -1,48 +1,458 @@
1
1
  import { apolloClient } from "../clients/graphql-client.js";
2
2
  import { GET_FULL_TOPOLOGY, GET_ALL_SENSORS, GET_ALL_HIVES } from "../clients/queries/topology.js";
3
+ import { GET_TAGS_WITH_USAGE, asTagName, } from "../clients/queries/tags.js";
3
4
  import { z } from "zod";
4
5
  import { getCachedTopology, setCachedTopology, generateTopologyCacheKey, } from "../cache/topology-cache.js";
5
6
  import { formatTopologyTree } from "../utils/tree-formatter.js";
6
- import { isProductionSensor, isProductionHive, rethrowIfGraphQLError, } from "../utils/graphql-helpers.js";
7
+ import { isProductionSensor, isProductionHive, rethrowIfGraphQLError, throwIfGraphQLErrors, } from "../utils/graphql-helpers.js";
8
+ import { resolveTagNames, projectValidRefs } from "../utils/tag-resolver.js";
7
9
  import { debug } from "../utils/debug.js";
8
- import { withToolErrorHandling } from "../errors/mcp-errors.js";
10
+ import { withToolErrorHandling, throwInternalError } from "../errors/mcp-errors.js";
9
11
  const LIST_TOPOLOGY_DESCRIPTION = "Display org hierarchy tree with flexible depth control. Can show full tree, specific subtrees, or flat lists. " +
10
- "Supports filtering by parent asset IDs. Depth levels: 0=sites, 1=buildings, 2=floors, 3=rooms/zones, 4=hives, 5=sensors. " +
12
+ "Supports filtering by parent asset IDs and by tag names. Depth levels: 0=sites, 1=buildings, 2=floors, 3=rooms/zones, 4=hives, 5=sensors. " +
11
13
  "Use starting_depth to choose which level to show, and traversal_depth to control how many levels below to include.\n\n" +
14
+ "Tag filter:\n" +
15
+ "- Pass tag_names (case-insensitive) to scope the tree to subtrees containing rooms, zones, or floors with those tags. Combines AND-style with asset_ids when both are supplied.\n" +
16
+ "- tag_match defaults to 'any' (entity is in any tagged subtree). 'all' intersects per-tag SUBTREES — e.g. a tag on Floor 1 AND a tag on a room inside Floor 1 yields the room (it's in both subtrees), not the empty set. Note this 'any' default differs from butlr_available_rooms, which defaults to 'all' because it filters a single entity type.\n" +
17
+ "- Use butlr_list_tags to discover what tag vocabulary exists in this org.\n\n" +
18
+ "Diagnostics:\n" +
19
+ "- The response includes a `warning` field when a filter input doesn't fully resolve — typo'd asset_ids, unknown tag_names, asset_ids and tag_names scoping disjoint subtrees, or tag associations pointing at deleted entities. Read it before retrying.\n" +
20
+ "- `unknown_tags` lists any tag names that didn't resolve, for the caller to surface or correct.\n\n" +
12
21
  "When NOT to Use:\n" +
13
22
  "- Searching for assets by name or keyword → use butlr_search_assets for fuzzy name-based lookups\n" +
14
23
  "- Need detailed info for a specific asset you already have an ID for → use butlr_get_asset_details instead\n" +
15
- "- Need only specific fields for known entity IDs → use butlr_fetch_entity_details for selective field fetching";
24
+ "- Need only specific fields for known entity IDs → use butlr_fetch_entity_details for selective field fetching\n" +
25
+ "- Want every tagged entity (not the surrounding hierarchy) → use butlr_list_tags with include_entities=true";
16
26
  /** Shared shape — used by both registerTool (SDK schema) and full validation */
17
27
  const listTopologyInputShape = {
18
28
  asset_ids: z
19
- .array(z.string())
29
+ .array(z.string().trim().min(1, "asset_ids entries cannot be empty"))
20
30
  .optional()
21
31
  .describe("Optional: Parent asset IDs to show tree for. If empty, shows all sites. " +
22
32
  "Examples: ['site_123'], ['building_456'], ['floor_789']"),
23
33
  starting_depth: z
24
34
  .number()
35
+ .int("starting_depth must be an integer")
36
+ .min(0, "starting_depth must be >= 0 (0=sites)")
37
+ .max(5, "starting_depth must be <= 5 (5=sensors)")
25
38
  .default(0)
26
39
  .describe("Depth level to start showing assets. 0=sites, 1=buildings, 2=floors, 3=rooms/zones, 4=hives, 5=sensors. " +
27
40
  "Use with traversal_depth=0 to show only assets at this level (flat list)."),
28
41
  traversal_depth: z
29
42
  .number()
43
+ .int("traversal_depth must be an integer")
44
+ .min(0, "traversal_depth must be >= 0")
45
+ .max(10, "traversal_depth must be <= 10")
30
46
  .default(0)
31
47
  .describe("How many levels below starting_depth to traverse. 0=starting level only, 1=one level below, etc. " +
32
48
  "Default is 0 to minimize token usage. Use 10 for full tree."),
49
+ tag_names: z
50
+ .array(z.string().trim().min(1, "Tag cannot be empty"))
51
+ .min(1, "tag_names array cannot be empty")
52
+ .optional()
53
+ .describe("Filter tree to subtrees containing rooms, zones, or floors with these tag names (case-insensitive). " +
54
+ "Combines AND-style with asset_ids. Use butlr_list_tags to discover available tag names."),
55
+ tag_match: z
56
+ .enum(["all", "any"])
57
+ .default("any")
58
+ .describe("Multi-tag semantics when tag_names has more than one entry: 'any' (default) keeps entities whose subtree is covered by at least one of the named tags. 'all' keeps entities whose subtree is covered by EVERY named tag — e.g. a floor-tag combined with a room-tag on a room inside that floor yields the room (it's in both subtrees), not the empty intersection. Defaults to 'any' here, unlike butlr_available_rooms which defaults to 'all' because it filters a single entity type."),
33
59
  };
34
60
  export const ListTopologyArgsSchema = z.object(listTopologyInputShape).strict();
61
+ /**
62
+ * Union of every directly-tagged entity ID across the resolved tag rows,
63
+ * regardless of `match`. Used for:
64
+ * 1. Early "tag_no_associations" empty check (before topology fetch)
65
+ * 2. Ghost-association detection (count of direct IDs absent from topology)
66
+ *
67
+ * `match`-aware filtering — including the closure-of-each-tag intersection
68
+ * needed for `match='all'` across hierarchically-related tags (e.g. a tag
69
+ * on a floor and a tag on a room inside that floor) — happens later in
70
+ * `collectMatchAwareClosure`, which requires the topology to already be
71
+ * fetched.
72
+ */
73
+ function collectDirectTaggedIds(resolvedRows) {
74
+ const matched = new Set();
75
+ // `projectValidRefs` drops refs whose `id` is null/undefined (stale
76
+ // tag→entity associations after a hard delete, or partial GraphQL
77
+ // responses) so they can't pollute the matched-id Set. Sharing the
78
+ // helper with `butlr_list_tags` keeps the validity predicate in one
79
+ // place — counts and entity arrays produced from the same filtered
80
+ // list cannot disagree across tools.
81
+ for (const row of resolvedRows) {
82
+ for (const r of projectValidRefs(row.rooms))
83
+ matched.add(r.id);
84
+ for (const z of projectValidRefs(row.zones))
85
+ matched.add(z.id);
86
+ for (const f of projectValidRefs(row.floors))
87
+ matched.add(f.id);
88
+ }
89
+ return matched;
90
+ }
91
+ /**
92
+ * Compute the match-aware closure of the resolved tag rows
93
+ * against the active topology. Each tag's directly-tagged entity IDs are
94
+ * expanded to their full subtree closure (rooms→sensors-via-room_id,
95
+ * floors→all descendants, etc.) BEFORE applying the match operator. This
96
+ * makes `tag_match='all'` work across hierarchical levels — a tag on
97
+ * Floor 1 and a tag on Room A inside Floor 1 now intersect to
98
+ * {Room A + its descendants}, not the empty set the per-type literal
99
+ * intersection used to produce.
100
+ *
101
+ * Why split from `collectDirectTaggedIds`: the direct IDs are needed
102
+ * before the topology is fetched (to short-circuit on no-associations
103
+ * and to drive ghost detection); the closure-aware set requires sites.
104
+ * Splitting also keeps the ghost-count stable — closure entities are
105
+ * by construction in the topology walk, so basing ghosts on closure
106
+ * would always report zero.
107
+ */
108
+ function collectMatchAwareClosure(sites, resolvedRows, match) {
109
+ if (resolvedRows.length === 0)
110
+ return new Set();
111
+ const perTagClosures = resolvedRows.map((row) => {
112
+ const rootIds = [];
113
+ for (const r of projectValidRefs(row.rooms))
114
+ rootIds.push(r.id);
115
+ for (const z of projectValidRefs(row.zones))
116
+ rootIds.push(z.id);
117
+ for (const f of projectValidRefs(row.floors))
118
+ rootIds.push(f.id);
119
+ return expandToSubtreeClosure(sites, rootIds);
120
+ });
121
+ if (match === "all") {
122
+ // Build a fresh Set per fold step rather than mutating `acc` mid-
123
+ // iteration. JS allows Set.delete during iteration and the previous
124
+ // implementation was correct, but the immutable form is more obvious
125
+ // to a future reader and removes a fragile language-spec dependency.
126
+ let acc = new Set(perTagClosures[0]);
127
+ for (let i = 1; i < perTagClosures.length; i++) {
128
+ const next = perTagClosures[i];
129
+ const intersected = new Set();
130
+ for (const id of acc)
131
+ if (next.has(id))
132
+ intersected.add(id);
133
+ acc = intersected;
134
+ }
135
+ return acc;
136
+ }
137
+ const acc = new Set();
138
+ for (const c of perTagClosures)
139
+ for (const id of c)
140
+ acc.add(id);
141
+ return acc;
142
+ }
143
+ /**
144
+ * Walk the topology and collect every entity ID that is descendant of (or
145
+ * equal to) the supplied `rootIds`. Used to compose `asset_ids` with
146
+ * `tag_names` as a true subtree-overlap AND.
147
+ *
148
+ * The closure is applied to BOTH asset_ids AND tagged-entity IDs because a
149
+ * tag can sit on an ancestor of an asset (e.g. tag on Floor 1,
150
+ * `asset_ids=[room_001]`) — the room is then inside the tagged subtree even
151
+ * though their raw IDs don't intersect. A literal-id intersection would
152
+ * silently miss this overlap; closure-vs-closure intersection catches it.
153
+ *
154
+ * Coverage by entity type:
155
+ * site → site + every building/floor/room/zone/hive/sensor under it
156
+ * building → building + every floor/room/zone/hive/sensor under it
157
+ * floor → floor + its rooms, zones, hives, sensors
158
+ * room → room + room_id-bound zones, sensors, hives — and
159
+ * transitively, every sensor whose `hive_serial` matches a
160
+ * room-bound hive's `serialNumber` (devices live in floor
161
+ * arrays but logically belong to their room when bound)
162
+ * zone → zone alone (zones are leaves under rooms in the
163
+ * topology and don't own descendants in our schema)
164
+ * hive → hive + sensors with matching `hive_serial`
165
+ * sensor → sensor alone
166
+ */
167
+ function expandToSubtreeClosure(sites, rootIds) {
168
+ const target = new Set(rootIds);
169
+ const closure = new Set();
170
+ const addFloor = (floor) => {
171
+ closure.add(floor.id);
172
+ for (const room of floor.rooms ?? [])
173
+ closure.add(room.id);
174
+ for (const zone of floor.zones ?? [])
175
+ closure.add(zone.id);
176
+ for (const hive of floor.hives ?? [])
177
+ closure.add(hive.id);
178
+ for (const sensor of floor.sensors ?? [])
179
+ closure.add(sensor.id);
180
+ };
181
+ const addBuilding = (building) => {
182
+ closure.add(building.id);
183
+ for (const floor of building.floors ?? [])
184
+ addFloor(floor);
185
+ };
186
+ for (const site of sites) {
187
+ if (target.has(site.id)) {
188
+ closure.add(site.id);
189
+ for (const building of site.buildings ?? [])
190
+ addBuilding(building);
191
+ continue;
192
+ }
193
+ for (const building of site.buildings ?? []) {
194
+ if (target.has(building.id)) {
195
+ addBuilding(building);
196
+ continue;
197
+ }
198
+ for (const floor of building.floors ?? []) {
199
+ if (target.has(floor.id)) {
200
+ addFloor(floor);
201
+ continue;
202
+ }
203
+ // Floor-level leaf scan: rooms, zones, hives, sensors. A targeted
204
+ // room also pulls in its room_id-bound zones, sensors, and hives —
205
+ // a tag-on-room implicitly applies to children of that room, and
206
+ // the formatter renders those entities under the room. Both
207
+ // snake_case (`room_id`) and camelCase (`roomID`) link fields are
208
+ // checked because the upstream API and cached payloads can carry
209
+ // either shape (mirrors `formatRoom` in src/utils/tree-formatter.ts).
210
+ for (const room of floor.rooms ?? []) {
211
+ if (!target.has(room.id))
212
+ continue;
213
+ closure.add(room.id);
214
+ for (const zone of floor.zones ?? []) {
215
+ if ((zone.room_id ?? zone.roomID) === room.id)
216
+ closure.add(zone.id);
217
+ }
218
+ for (const sensor of floor.sensors ?? []) {
219
+ if ((sensor.room_id ?? sensor.roomID) === room.id)
220
+ closure.add(sensor.id);
221
+ }
222
+ // Sensors reach a room two ways: directly via room_id, or
223
+ // transitively through a room-bound hive (sensor.hive_serial ===
224
+ // hive.serialNumber). The formatter renders both shapes under the
225
+ // room (`formatHive`'s `hiveSensors` filter nests sensors under
226
+ // hives by `hive_serial`, and a hive nests under its room by
227
+ // `room_id`), so the closure must follow the same chain.
228
+ // Otherwise a sensor with no direct room link but attached to a
229
+ // room-bound hive falls out of the room's tag closure entirely.
230
+ for (const hive of floor.hives ?? []) {
231
+ if ((hive.room_id ?? hive.roomID) !== room.id)
232
+ continue;
233
+ closure.add(hive.id);
234
+ if (!hive.serialNumber)
235
+ continue;
236
+ for (const sensor of floor.sensors ?? []) {
237
+ if (sensor.hive_serial === hive.serialNumber)
238
+ closure.add(sensor.id);
239
+ }
240
+ }
241
+ }
242
+ for (const zone of floor.zones ?? []) {
243
+ if (target.has(zone.id))
244
+ closure.add(zone.id);
245
+ }
246
+ for (const hive of floor.hives ?? []) {
247
+ if (!target.has(hive.id))
248
+ continue;
249
+ closure.add(hive.id);
250
+ // Defensive symmetry with the room→hive→sensor chain above.
251
+ // Tags can't currently attach to sensors per the GraphQL schema,
252
+ // so this branch has no observable effect on current data — but
253
+ // it closes the symmetric-closure invariant claimed in the
254
+ // doc-block and matches what `formatHive` renders under a hive.
255
+ // Do NOT delete thinking it's vestigial.
256
+ //
257
+ // If the GraphQL schema later supports sensor-level tags, drop
258
+ // the "no observable effect" phrasing — the branch becomes
259
+ // load-bearing and a regression here would silently miss
260
+ // sensor-targeted tag composition.
261
+ if (!hive.serialNumber)
262
+ continue;
263
+ for (const sensor of floor.sensors ?? []) {
264
+ if (sensor.hive_serial === hive.serialNumber)
265
+ closure.add(sensor.id);
266
+ }
267
+ }
268
+ for (const sensor of floor.sensors ?? []) {
269
+ if (target.has(sensor.id))
270
+ closure.add(sensor.id);
271
+ }
272
+ }
273
+ }
274
+ }
275
+ return closure;
276
+ }
35
277
  /**
36
278
  * Execute butlr_list_topology tool
37
279
  */
38
- export async function executeListTopology(args = {}) {
280
+ export async function executeListTopology(args) {
39
281
  const startingDepth = args.starting_depth ?? 0;
40
282
  const traversalDepth = args.traversal_depth ?? 0;
41
283
  const assetIds = args.asset_ids ?? [];
42
- debug("butlr-list-topology", `Fetching topology: starting_depth=${startingDepth}, traversal_depth=${traversalDepth}, assets=${assetIds.length || "all"}`);
43
- // Use a cache key that includes devices
284
+ // Brand the input tag names at the boundary so the response surface
285
+ // keeps the `TagName` brand. Zod gives us plain strings; downstream
286
+ // consumers benefit from the brand surviving onto `tag_filter.names`,
287
+ // `unknown_tags`, and the diagnostic union.
288
+ const tagNames = (args.tag_names ?? []).map((n) => asTagName(n));
289
+ // Default is "any" here, in contrast to butlr_available_rooms which
290
+ // defaults to "all". The asymmetry is intentional — list-topology
291
+ // filters across rooms/zones/floors where intersection is rarely
292
+ // satisfied; available-rooms filters a single entity type. Do not
293
+ // unify these defaults via a shared helper.
294
+ const tagMatch = args.tag_match ?? "any";
295
+ debug("butlr-list-topology", `Fetching topology: starting_depth=${startingDepth}, traversal_depth=${traversalDepth}, assets=${assetIds.length || "all"}, tags=${tagNames.length ? `${tagMatch}:${tagNames.join(",")}` : "none"}`);
296
+ // Resolve tag filter (if any) up-front so we can short-circuit on
297
+ // unsatisfiable / no-match cases without paying for the topology fetch.
298
+ let taggedEntityIds;
299
+ // Carry resolvedRows through to the composition phase so collectMatchAwareClosure
300
+ // can expand each tag's subtree before applying the match operator (per R7 §I1).
301
+ // The previous flow computed a literal per-type intersection up-front, which
302
+ // missed hierarchically-related tag pairs.
303
+ let resolvedTagRows;
304
+ // Defaults to [] for the no-tag-filter path; only the live tag-resolution
305
+ // branch reads from `resolution.unknownNames` (passed directly into
306
+ // buildTopologyResponse on early-return) so we don't keep an alias here.
307
+ // Typed as `TagName[]` to preserve the brand from the resolver through
308
+ // to the response surface. `ListTopologyResponse.unknown_tags` declares
309
+ // ReadonlyArray<string> so the brand erodes at the boundary today (a
310
+ // separate type-design item), but at least the in-flight binding doesn't
311
+ // launder it any earlier than necessary.
312
+ let unknownTagNames = [];
313
+ let tagWarning;
314
+ let malformedTagRowCount = 0;
315
+ let malformedTagSampleNames = [];
316
+ if (tagNames.length > 0) {
317
+ let tagsRaw = [];
318
+ try {
319
+ const tagsResult = await apolloClient.query({
320
+ query: GET_TAGS_WITH_USAGE,
321
+ fetchPolicy: "network-only",
322
+ });
323
+ throwIfGraphQLErrors(tagsResult);
324
+ // Same null-is-violation rule as butlr-list-tags — see comment there.
325
+ const tags = tagsResult.data?.tags;
326
+ if (!Array.isArray(tags)) {
327
+ throwInternalError("Unexpected response shape from tags query (expected array, got " +
328
+ `${tags === null ? "null" : typeof tags}). Please retry; if persistent, the upstream API contract may have changed.`);
329
+ }
330
+ tagsRaw = tags;
331
+ }
332
+ catch (error) {
333
+ rethrowIfGraphQLError(error);
334
+ throw error;
335
+ }
336
+ const resolution = resolveTagNames({
337
+ allTags: tagsRaw,
338
+ requestedNames: tagNames,
339
+ match: tagMatch,
340
+ });
341
+ const baseQueryParams = {
342
+ starting_depth: startingDepth,
343
+ traversal_depth: traversalDepth,
344
+ asset_filter: assetIds.length > 0 ? assetIds : "all",
345
+ tag_filter: { names: tagNames, match: tagMatch },
346
+ };
347
+ // Surface upstream contract violations (rows missing a usable id/name)
348
+ // alongside whichever primary diagnostic fires. Threading it through a
349
+ // shared starting list ensures it cannot be silently dropped on any
350
+ // early-return branch.
351
+ const earlyDiagnostics = [];
352
+ if (resolution.droppedRowCount > 0) {
353
+ earlyDiagnostics.push({
354
+ kind: "malformed_tag_rows",
355
+ count: resolution.droppedRowCount,
356
+ ...(resolution.droppedSampleNames.length > 0
357
+ ? { sample_names: resolution.droppedSampleNames }
358
+ : {}),
359
+ });
360
+ }
361
+ malformedTagRowCount = resolution.droppedRowCount;
362
+ malformedTagSampleNames = resolution.droppedSampleNames;
363
+ if (resolution.kind === "no_match") {
364
+ const diagnostics = [
365
+ { kind: "tag_no_match", unknown_names: resolution.unknownNames },
366
+ ];
367
+ // When asset_ids was also supplied, opportunistically validate it
368
+ // against a warm topology cache so the user sees both diagnostics in
369
+ // one round-trip. The lookup reads only from the merged-devices cache
370
+ // (the key `butlr_list_topology` writes) so it is authoritative for
371
+ // device ids; `butlr_search_assets` writes a separate device-incomplete
372
+ // shape under a different key. Cache miss → emit `asset_ids_unverified`
373
+ // so the caller knows the asset typo (if any) wasn't checked. Paying
374
+ // for a full topology fetch here would dwarf the actual short-circuit.
375
+ //
376
+ // Skip the probe entirely when BUTLR_ORG_ID is unset: the cache key
377
+ // would fall back to "default", which is shared across every org a
378
+ // multi-tenant process talks to. Reading authoritative asset-scope
379
+ // diagnostics against a tree from a different org would emit
380
+ // `asset_scope_empty` for IDs that exist in the caller's actual org.
381
+ if (assetIds.length > 0) {
382
+ const orgId = process.env.BUTLR_ORG_ID;
383
+ if (!orgId) {
384
+ diagnostics.push({ kind: "asset_ids_unverified" });
385
+ }
386
+ else {
387
+ const cached = getCachedTopology(generateTopologyCacheKey(orgId, true, true, true, undefined));
388
+ const cachedSites = cached?.data?.sites;
389
+ if (cachedSites) {
390
+ if (expandToSubtreeClosure(cachedSites, assetIds).size === 0) {
391
+ diagnostics.push({ kind: "asset_scope_empty", asset_ids: assetIds });
392
+ }
393
+ }
394
+ else {
395
+ diagnostics.push({ kind: "asset_ids_unverified" });
396
+ }
397
+ }
398
+ }
399
+ diagnostics.push(...earlyDiagnostics);
400
+ return buildTopologyResponse({
401
+ tree: [],
402
+ queryParams: baseQueryParams,
403
+ diagnostics,
404
+ unknownTagNames: resolution.unknownNames,
405
+ });
406
+ }
407
+ if (resolution.kind === "unsatisfiable") {
408
+ return buildTopologyResponse({
409
+ tree: [],
410
+ queryParams: baseQueryParams,
411
+ diagnostics: [
412
+ {
413
+ kind: "tag_match_all_unsatisfiable",
414
+ unknown_names: resolution.unknownNames,
415
+ partial_resolved_count: resolution.partialResolvedCount,
416
+ },
417
+ ...earlyDiagnostics,
418
+ ],
419
+ unknownTagNames: resolution.unknownNames,
420
+ });
421
+ }
422
+ // resolution.kind === "ok" — safe to read resolvedRows / resolvedIds.
423
+ const { resolvedRows, unknownNames } = resolution;
424
+ unknownTagNames = unknownNames;
425
+ resolvedTagRows = resolvedRows;
426
+ taggedEntityIds = collectDirectTaggedIds(resolvedRows);
427
+ if (taggedEntityIds.size === 0) {
428
+ return buildTopologyResponse({
429
+ tree: [],
430
+ queryParams: baseQueryParams,
431
+ diagnostics: [
432
+ { kind: "tag_no_associations", tag_match: tagMatch, tag_names: tagNames },
433
+ ...earlyDiagnostics,
434
+ ],
435
+ unknownTagNames: unknownNames,
436
+ });
437
+ }
438
+ if (unknownNames.length > 0) {
439
+ tagWarning = { kind: "unknown_tags", names: unknownNames };
440
+ }
441
+ }
442
+ // tag_filter (tag_names / tag_match) and asset_ids are intentionally
443
+ // excluded from the cache key. The cache stores raw org-scoped topology
444
+ // only; both filters are applied client-side post-fetch via
445
+ // filterTopologyByAssets, so different filter shapes share one cached
446
+ // tree. Do not extend this key with filter inputs without first
447
+ // separating the cache layers.
448
+ //
449
+ // `devicesMerged: true` because this read path requires every floor to
450
+ // carry its `sensors`/`hives` arrays (post-mergeSensorsAndHivesIntoTopology).
451
+ // `butlr_search_assets` writes a separate device-incomplete shape under a
452
+ // distinct key — the two consumers can never collide.
44
453
  const cacheKey = generateTopologyCacheKey(process.env.BUTLR_ORG_ID || "default", true, // include devices
45
454
  true, // include zones
455
+ true, // devicesMerged: list-topology requires merged sensors/hives
46
456
  undefined);
47
457
  // Try to get cached topology
48
458
  let sites = [];
@@ -60,13 +470,14 @@ export async function executeListTopology(args = {}) {
60
470
  query: GET_FULL_TOPOLOGY,
61
471
  fetchPolicy: "network-only",
62
472
  });
63
- // Apollo can return both data and errors - only fail if we have no data
473
+ // Apollo can return both data and errors - only fail if we have no
474
+ // data. Route the error through `throwIfGraphQLErrors` so it lands
475
+ // as a translated MCP error (AUTH_EXPIRED, RATE_LIMITED, etc.)
476
+ // rather than as a raw `result.error` shape that downstream
477
+ // duck-typing might or might not recognise.
64
478
  if (!result.data || !result.data.sites || !result.data.sites.data) {
65
- // If we have error but no data, throw
66
- if (result.error) {
67
- throw result.error;
68
- }
69
- throw new Error("Invalid response structure from API");
479
+ throwIfGraphQLErrors(result);
480
+ throwInternalError("Invalid response structure from API (missing data.sites.data)");
70
481
  }
71
482
  // Track whether the topology data is partial (errors alongside data)
72
483
  partialData = !!result.error;
@@ -86,10 +497,26 @@ export async function executeListTopology(args = {}) {
86
497
  fetchPolicy: "network-only",
87
498
  }),
88
499
  ]);
500
+ throwIfGraphQLErrors(sensorsResult);
501
+ throwIfGraphQLErrors(hivesResult);
502
+ // Mirror the tags-fetch shape contract: only an explicit array survives;
503
+ // `null` / `undefined` / non-array shapes throw as upstream contract
504
+ // violations. `|| []` here would silently launder a serialisation
505
+ // regression into an empty-devices topology.
506
+ const rawSensors = sensorsResult.data?.sensors?.data;
507
+ if (!Array.isArray(rawSensors)) {
508
+ throwInternalError("Unexpected response shape from sensors query (expected array, got " +
509
+ `${rawSensors === null ? "null" : typeof rawSensors}). Please retry; if persistent, the upstream API contract may have changed.`);
510
+ }
511
+ const rawHives = hivesResult.data?.hives?.data;
512
+ if (!Array.isArray(rawHives)) {
513
+ throwInternalError("Unexpected response shape from hives query (expected array, got " +
514
+ `${rawHives === null ? "null" : typeof rawHives}). Please retry; if persistent, the upstream API contract may have changed.`);
515
+ }
89
516
  // Filter out test/placeholder devices from topology listing
90
517
  // (Note: These filters are ONLY for topology display, not occupancy queries)
91
- const allSensors = (sensorsResult.data?.sensors?.data || []).filter(isProductionSensor);
92
- const allHives = (hivesResult.data?.hives?.data || []).filter(isProductionHive);
518
+ const allSensors = rawSensors.filter(isProductionSensor);
519
+ const allHives = rawHives.filter(isProductionHive);
93
520
  debug("butlr-list-topology", `Got ${allSensors.length} production sensors, ${allHives.length} production hives (test/placeholder devices filtered)`);
94
521
  // Merge sensors and hives into topology by floor_id
95
522
  sites = mergeSensorsAndHivesIntoTopology(sites, allSensors, allHives);
@@ -107,31 +534,347 @@ export async function executeListTopology(args = {}) {
107
534
  throw error;
108
535
  }
109
536
  }
110
- // Filter topology by asset_ids if provided
111
- let filteredSites = sites;
537
+ // Compose asset_ids and tag_names as a true AND via subtree-overlap
538
+ // intersection. Both sides are expanded to their full descendant closure
539
+ // (including sensors/hives, plus devices bound to a targeted room via
540
+ // room_id). The two surviving closures are intersected — this catches
541
+ // the case where a tag sits on an ancestor of an asset (e.g. tag on a
542
+ // floor, `asset_ids=[room_001]` within that floor) which a raw-ID
543
+ // intersection would miss.
544
+ //
545
+ // `assetScopeEmpty` and `assetTagDisjoint` are tracked separately so
546
+ // the empty-tree warning can distinguish "asset_ids didn't resolve in
547
+ // this org" from "filters scope disjoint subtrees" — the former is a
548
+ // typo, the latter is a legitimate disagreement.
549
+ let filterIds;
550
+ let assetScopeEmpty = false;
551
+ let assetTagDisjoint = false;
552
+ // Both single-filter paths use the closure too — `pruneFloorToMatches`
553
+ // strict-prunes by raw IDs, so passing only `room_001` would return the
554
+ // room with empty zones/hives/sensors. Closure-expand to include the
555
+ // descendants the formatter would render (zones bound to the room,
556
+ // sensors via room_id, sensors via the hive_serial chain, etc.).
557
+ //
558
+ // Tag-side closure uses `collectMatchAwareClosure` — for tag_match='all'
559
+ // it intersects per-tag subtree closures (R7 §I1), which makes
560
+ // hierarchical AND work correctly. For 'any' it unions them.
112
561
  if (assetIds.length > 0) {
113
- filteredSites = filterTopologyByAssets(sites, assetIds);
562
+ const assetClosure = expandToSubtreeClosure(sites, assetIds);
563
+ assetScopeEmpty = assetClosure.size === 0;
564
+ if (resolvedTagRows && resolvedTagRows.length > 0) {
565
+ const tagClosure = collectMatchAwareClosure(sites, resolvedTagRows, tagMatch);
566
+ const intersection = [];
567
+ for (const id of assetClosure)
568
+ if (tagClosure.has(id))
569
+ intersection.push(id);
570
+ if (!assetScopeEmpty && intersection.length === 0) {
571
+ assetTagDisjoint = true;
572
+ }
573
+ filterIds = intersection;
574
+ }
575
+ else {
576
+ filterIds = [...assetClosure];
577
+ }
578
+ }
579
+ else if (resolvedTagRows && resolvedTagRows.length > 0) {
580
+ filterIds = [...collectMatchAwareClosure(sites, resolvedTagRows, tagMatch)];
581
+ }
582
+ let filteredSites = sites;
583
+ if (filterIds !== undefined) {
584
+ filteredSites = filterTopologyByAssets(filteredSites, filterIds);
114
585
  }
115
586
  // Format as tree with depth controls
116
587
  const tree = formatTopologyTree(filteredSites, startingDepth, traversalDepth);
117
- const response = {
588
+ const diagnostics = [];
589
+ if (partialData)
590
+ diagnostics.push({ kind: "partial_topology" });
591
+ if (tagWarning)
592
+ diagnostics.push(tagWarning);
593
+ if (malformedTagRowCount > 0) {
594
+ diagnostics.push({
595
+ kind: "malformed_tag_rows",
596
+ count: malformedTagRowCount,
597
+ ...(malformedTagSampleNames.length > 0 ? { sample_names: malformedTagSampleNames } : {}),
598
+ });
599
+ }
600
+ // Compute how many tagged-entity IDs aren't present in the active
601
+ // topology. Used both for the all-ghost diagnostic (tree empty, every
602
+ // association dangling) and for the partial-ghost diagnostic (tree
603
+ // non-empty, some associations dangling). Without this a partially
604
+ // dangling tag silently includes the real entries and hides the ghosts.
605
+ let ghostTagCount = 0;
606
+ if (taggedEntityIds && taggedEntityIds.size > 0) {
607
+ const presentIds = collectAllTopologyIds(sites);
608
+ for (const id of taggedEntityIds) {
609
+ if (!presentIds.has(id))
610
+ ghostTagCount++;
611
+ }
612
+ }
613
+ // Tag-side ghost diagnostic — evaluated independently of the asset-side
614
+ // branch so a dual-cause empty tree (bad asset_ids AND ghost tag) doesn't
615
+ // mask the underlying ghost-association problem behind a misleading
616
+ // "disjoint subtrees" warning.
617
+ //
618
+ // Gate emission on `!partialData`: when the topology fetch returned
619
+ // partial results, `presentIds` is built off a truncated tree, so an
620
+ // entity could appear "ghost" purely because its enclosing site/floor
621
+ // was missing from this response. Surfacing tag_associations_*_ghost
622
+ // in that state would be a false positive — the partial_topology
623
+ // diagnostic already alerts the caller that the data is incomplete.
624
+ // Hoist into a single narrowed binding so neither the gate nor the
625
+ // emission needs `!` non-null assertions. TS narrows `taggedSize`
626
+ // structurally — a future refactor that drops the early return on
627
+ // `taggedEntityIds.size === 0` cannot accidentally re-introduce the
628
+ // unsound `!.size` reads.
629
+ const taggedSize = taggedEntityIds?.size ?? 0;
630
+ const ghostKind = taggedSize > 0
631
+ ? ghostTagCount === taggedSize
632
+ ? "all"
633
+ : ghostTagCount > 0
634
+ ? "partial"
635
+ : "none"
636
+ : "none";
637
+ if (!partialData) {
638
+ if (ghostKind === "all") {
639
+ diagnostics.push({
640
+ kind: "tag_associations_all_ghost",
641
+ total: taggedSize,
642
+ });
643
+ }
644
+ else if (ghostKind === "partial") {
645
+ diagnostics.push({
646
+ kind: "tag_associations_partial_ghost",
647
+ ghost: ghostTagCount,
648
+ total: taggedSize,
649
+ });
650
+ }
651
+ }
652
+ // Asset-side diagnostics — `asset_scope_empty` and `asset_tag_disjoint`
653
+ // are mutually exclusive within `assetIds.length>0`.
654
+ //
655
+ // `asset_scope_empty` is an INDEPENDENT root cause: a typo'd asset_id
656
+ // is still wrong even if the tag is also dangling. Surface both so the
657
+ // user can fix them in one round-trip.
658
+ //
659
+ // `asset_tag_disjoint` IS a downstream symptom of an all-ghost tag —
660
+ // the disjointness only exists because the tag's closure has nothing
661
+ // to intersect with. Suppress it under all-ghost so the user gets the
662
+ // actionable root cause ("your tag is dangling"), not the symptom.
663
+ //
664
+ // Suppress disjoint also under `partialData`: the disjointness was
665
+ // computed against a truncated topology, so it's a false signal — the
666
+ // tag's subtree might exist outside this partial fetch. The
667
+ // `partial_topology` diagnostic already alerts the caller that the
668
+ // data is incomplete; routing them to "remove a filter" would be
669
+ // misdirection.
670
+ const allGhostEmitted = !partialData && ghostKind === "all";
671
+ if (tree.length === 0 && assetIds.length > 0) {
672
+ if (assetScopeEmpty) {
673
+ diagnostics.push({ kind: "asset_scope_empty", asset_ids: assetIds });
674
+ }
675
+ else if (assetTagDisjoint && !allGhostEmitted && !partialData) {
676
+ diagnostics.push({ kind: "asset_tag_disjoint" });
677
+ }
678
+ }
679
+ // Tag-match='all' no-overlap diagnostic: every requested tag resolved
680
+ // and each has associations, but the per-tag SUBTREE intersection is
681
+ // empty (e.g. tag A on Room X, tag B on Room Y in different floors).
682
+ // Without this branch the user gets `tree: []` and no diagnostic when
683
+ // no asset_ids filter is in play. Gate on the absence of higher-
684
+ // priority diagnostics so we don't double-report.
685
+ if (tagMatch === "all" &&
686
+ resolvedTagRows &&
687
+ resolvedTagRows.length > 1 &&
688
+ filterIds !== undefined &&
689
+ filterIds.length === 0 &&
690
+ !partialData &&
691
+ !allGhostEmitted &&
692
+ diagnostics.length === 0) {
693
+ diagnostics.push({
694
+ kind: "tag_match_all_no_overlap",
695
+ resolved_names: resolvedTagRows.map((r) => asTagName(r.name)),
696
+ });
697
+ }
698
+ // Depth-slicing diagnostic: filter resolved to entities (filterIds and
699
+ // filteredSites both non-empty) but the formatter's depth window
700
+ // sliced them out. Without this branch the user gets `tree: []` and no
701
+ // signal — a real correctness gap pointed out by the silent-failure
702
+ // review. Gate on the absence of the higher-priority diagnostics so
703
+ // we don't double-report.
704
+ const haveOtherEmptyTreeDiagnostic = diagnostics.some((d) => d.kind === "asset_scope_empty" ||
705
+ d.kind === "asset_tag_disjoint" ||
706
+ d.kind === "tag_associations_all_ghost" ||
707
+ d.kind === "tag_no_associations" ||
708
+ d.kind === "tag_no_match" ||
709
+ d.kind === "tag_match_all_unsatisfiable" ||
710
+ d.kind === "tag_match_all_no_overlap");
711
+ if (tree.length === 0 &&
712
+ !haveOtherEmptyTreeDiagnostic &&
713
+ !partialData &&
714
+ filterIds !== undefined &&
715
+ filterIds.length > 0 &&
716
+ filteredSites.length > 0) {
717
+ diagnostics.push({
718
+ kind: "depth_excludes_matches",
719
+ starting_depth: startingDepth,
720
+ traversal_depth: traversalDepth,
721
+ });
722
+ }
723
+ return buildTopologyResponse({
118
724
  tree,
119
- query_params: {
725
+ queryParams: {
120
726
  starting_depth: startingDepth,
121
727
  traversal_depth: traversalDepth,
122
728
  asset_filter: assetIds.length > 0 ? assetIds : "all",
729
+ ...(tagNames.length > 0 ? { tag_filter: { names: tagNames, match: tagMatch } } : {}),
123
730
  },
731
+ diagnostics,
732
+ unknownTagNames,
733
+ });
734
+ }
735
+ /**
736
+ * Collect every entity id present in the active topology so we can detect
737
+ * tag associations pointing at deleted (or otherwise filtered-out) entities.
738
+ */
739
+ function collectAllTopologyIds(sites) {
740
+ const ids = new Set();
741
+ for (const site of sites) {
742
+ ids.add(site.id);
743
+ for (const building of site.buildings ?? []) {
744
+ ids.add(building.id);
745
+ for (const floor of building.floors ?? []) {
746
+ ids.add(floor.id);
747
+ for (const room of floor.rooms ?? [])
748
+ ids.add(room.id);
749
+ for (const zone of floor.zones ?? [])
750
+ ids.add(zone.id);
751
+ for (const hive of floor.hives ?? [])
752
+ ids.add(hive.id);
753
+ for (const sensor of floor.sensors ?? [])
754
+ ids.add(sensor.id);
755
+ }
756
+ }
757
+ }
758
+ return ids;
759
+ }
760
+ /** Render a structured diagnostic as the human-readable prose used in `warning`. */
761
+ function renderDiagnostic(d) {
762
+ switch (d.kind) {
763
+ case "partial_topology":
764
+ return "Topology data may be incomplete — the API returned partial results due to upstream errors.";
765
+ case "tag_no_match":
766
+ return (`No matching tags found in this org for: ${d.unknown_names.join(", ")}. ` +
767
+ "Use butlr_list_tags to see available tag names.");
768
+ case "unknown_tags":
769
+ return (`Unknown tag(s) ignored: ${d.names.join(", ")}. ` +
770
+ "Use butlr_list_tags to see available tag names.");
771
+ case "tag_match_all_unsatisfiable":
772
+ return (`Cannot satisfy tag_match='all': ${d.partial_resolved_count} tag(s) ` +
773
+ `resolved but unknown tag(s) ${d.unknown_names.join(", ")} prevent the AND. ` +
774
+ "Use butlr_list_tags to see available tag names, or pass tag_match='any' to match entities tagged with any of the supplied tags.");
775
+ case "tag_no_associations": {
776
+ // Single-tag case reads cleanly without the all/any preamble.
777
+ const tagList = d.tag_names.length === 1
778
+ ? `"${d.tag_names[0]}"`
779
+ : `${d.tag_match === "all" ? "all of" : "any of"} [${d.tag_names.join(", ")}]`;
780
+ return (`No rooms, zones, or floors are currently tagged with ${tagList}. ` +
781
+ "Use butlr_list_tags { include_entities: true } to see what is tagged.");
782
+ }
783
+ case "asset_scope_empty":
784
+ return ("asset_ids matched no entities in the org — verify the IDs exist " +
785
+ "(use butlr_search_assets if unsure).");
786
+ case "asset_tag_disjoint":
787
+ return ("No tree node satisfies both asset_ids and tag_names — the two filters scope disjoint subtrees. " +
788
+ "Try removing one filter or use butlr_list_tags { include_entities: true } to see where the tags live.");
789
+ case "tag_associations_all_ghost":
790
+ return (`Tag matched ${d.total} entit${d.total === 1 ? "y" : "ies"} ` +
791
+ "in tag associations, but none are present in the active topology — " +
792
+ "they may have been deleted. " +
793
+ "Use butlr_list_tags { include_entities: true } to inspect the raw associations.");
794
+ case "tag_associations_partial_ghost":
795
+ return (`${d.ghost} of ${d.total} tag associations point at ` +
796
+ "entities outside the active topology (likely deleted) and were skipped. " +
797
+ "Use butlr_list_tags { include_entities: true } to inspect the raw associations.");
798
+ case "asset_ids_unverified":
799
+ return ("asset_ids were not validated (topology not yet cached) — " +
800
+ "re-run after correcting the tag names to confirm they exist.");
801
+ case "malformed_tag_rows": {
802
+ const sample = d.sample_names && d.sample_names.length > 0
803
+ ? ` (sample: ${d.sample_names.join(", ")})`
804
+ : "";
805
+ return (`${d.count} tag row(s) skipped — upstream returned entries with ` +
806
+ `missing or empty id/name fields, or duplicate canonical names${sample}. ` +
807
+ "If unexpected, contact support.");
808
+ }
809
+ case "depth_excludes_matches":
810
+ return ("Filter matched entities, but every match sits outside the " +
811
+ `current depth window (starting_depth=${d.starting_depth}, ` +
812
+ `traversal_depth=${d.traversal_depth}). Lower starting_depth or ` +
813
+ "raise traversal_depth to surface the matches.");
814
+ case "tag_match_all_no_overlap":
815
+ return (`tag_match='all' but the subtree intersection of ` +
816
+ `[${d.resolved_names.join(", ")}] is empty — the tags apply to ` +
817
+ "entities under different parents. Try tag_match='any', drop a " +
818
+ "tag, or use butlr_list_tags { include_entities: true } to inspect " +
819
+ "where each tag lives.");
820
+ default: {
821
+ // Exhaustiveness guard — adding a new TopologyDiagnostic arm without
822
+ // a matching case here fails to compile because `assertNever` only
823
+ // accepts `never`. At runtime we degrade gracefully with a fallback
824
+ // string rather than throw, so version skew (e.g. an older bundle
825
+ // reads a diagnostic kind written by a newer one over a shared
826
+ // cache or transport) doesn't crash the response renderer.
827
+ assertNever(d);
828
+ const unknownKind = d.kind ?? "unspecified";
829
+ // Surface version-skew explicitly in logs so an operator notices
830
+ // the drift without needing to spelunk the response shape.
831
+ debug("topology-diagnostic-unknown-kind", unknownKind);
832
+ return `Unknown diagnostic kind: ${unknownKind}`;
833
+ }
834
+ }
835
+ }
836
+ /**
837
+ * Compile-time exhaustiveness assertion. The argument's type must be
838
+ * `never`; if any caller's narrowing leaves an unhandled case, TS rejects
839
+ * the call. Returns nothing so the surrounding switch can still fall
840
+ * through to a runtime fallback (see `renderDiagnostic`).
841
+ */
842
+ function assertNever(_value) {
843
+ /* compile-time guard only */
844
+ }
845
+ /**
846
+ * Assemble the response, deriving the legacy `warning` string from the
847
+ * structured diagnostics so the two stay in lock-step. Either field on its
848
+ * own carries the full diagnostic surface; consumers can safely branch on
849
+ * `warnings[].kind` and ignore `warning`.
850
+ */
851
+ function buildTopologyResponse(args) {
852
+ const response = {
853
+ tree: args.tree,
854
+ query_params: args.queryParams,
124
855
  timestamp: new Date().toISOString(),
125
856
  };
126
- if (partialData) {
127
- response.warning =
128
- "Topology data may be incomplete — the API returned partial results due to upstream errors.";
857
+ if (args.diagnostics.length > 0) {
858
+ response.warnings = args.diagnostics;
859
+ response.warning = args.diagnostics.map(renderDiagnostic).join(" ");
860
+ }
861
+ if (args.unknownTagNames.length > 0) {
862
+ response.unknown_tags = args.unknownTagNames;
129
863
  }
130
864
  return response;
131
865
  }
132
866
  /**
133
- * Merge sensors and hives into topology structure
134
- * Groups by floor_id and nests under appropriate floors
867
+ * Merge sensors and hives into topology structure.
868
+ * Groups by floor_id and nests under appropriate floors.
869
+ *
870
+ * IN-PLACE MUTATION: assigns `floor.sensors` and `floor.hives` directly on
871
+ * the input site tree (returned for chaining; no new array is allocated).
872
+ * `setCachedTopology` reads from the same `sites` reference that this
873
+ * function mutates — any caller that captures the pre-merge `sites` (via
874
+ * Apollo cache-restore, structural clone, or fork-then-await) would
875
+ * observe the un-merged shape and could write a device-incomplete tree
876
+ * to the cache. If you change this to immutable assignment, audit the
877
+ * cache write site to confirm it sees the merged result.
135
878
  */
136
879
  function mergeSensorsAndHivesIntoTopology(sites, allSensors, allHives) {
137
880
  // Group sensors by floor_id
@@ -169,8 +912,24 @@ function mergeSensorsAndHivesIntoTopology(sites, allSensors, allHives) {
169
912
  return sites;
170
913
  }
171
914
  /**
172
- * Filter topology to only include assets matching the provided IDs
173
- * Returns a subset of the topology tree
915
+ * Filter topology to only include assets matching the provided IDs.
916
+ *
917
+ * Pruning is strict at every level so untargeted siblings do not leak
918
+ * through (a regression vector for `asset_ids` ∩ `tag_names` composition,
919
+ * where `expandToSubtreeClosure` precomputes a leaf-level intersection):
920
+ * - If a site/building/floor id is itself in `assetIds`, the whole
921
+ * subtree is preserved (caller asked for that branch as a whole).
922
+ * - Otherwise the floor is shallow-cloned with each child collection
923
+ * filtered to ids in `assetIds`.
924
+ *
925
+ * Rendering ancestors are pulled back in after pruning so the tree
926
+ * formatter can place each matched leaf at its expected position. Without
927
+ * this, a matched zone/sensor whose parent room wasn't itself targeted
928
+ * would silently fall out of the tree (the formatter renders zones and
929
+ * room-bound sensors under their parent room, and hive-bound sensors
930
+ * under their hive — none of which appear if the parent isn't present).
931
+ * The added ancestors are always parents of an already-matched node, so
932
+ * sibling leakage cannot occur.
174
933
  */
175
934
  function filterTopologyByAssets(sites, assetIds) {
176
935
  const idSet = new Set(assetIds);
@@ -192,13 +951,9 @@ function filterTopologyByAssets(sites, assetIds) {
192
951
  matchedFloors.push(floor);
193
952
  continue;
194
953
  }
195
- const hasMatchedRoom = floor.rooms?.some((r) => idSet.has(r.id));
196
- const hasMatchedZone = floor.zones?.some((z) => idSet.has(z.id));
197
- const hasMatchedHive = floor.hives?.some((h) => idSet.has(h.id));
198
- const hasMatchedSensor = floor.sensors?.some((s) => idSet.has(s.id));
199
- if (hasMatchedRoom || hasMatchedZone || hasMatchedHive || hasMatchedSensor) {
200
- matchedFloors.push(floor);
201
- }
954
+ const prunedFloor = pruneFloorToMatches(floor, idSet);
955
+ if (prunedFloor)
956
+ matchedFloors.push(prunedFloor);
202
957
  }
203
958
  if (matchedFloors.length > 0) {
204
959
  matchedBuildings.push({ ...building, floors: matchedFloors });
@@ -210,6 +965,61 @@ function filterTopologyByAssets(sites, assetIds) {
210
965
  }
211
966
  return filtered;
212
967
  }
968
+ /**
969
+ * Build a shallow-cloned floor containing only matched leaves and their
970
+ * rendering ancestors. Returns `undefined` when nothing on the floor matched.
971
+ */
972
+ function pruneFloorToMatches(floor, idSet) {
973
+ const matchedRooms = (floor.rooms ?? []).filter((r) => idSet.has(r.id));
974
+ const matchedZones = (floor.zones ?? []).filter((z) => idSet.has(z.id));
975
+ const matchedHives = (floor.hives ?? []).filter((h) => idSet.has(h.id));
976
+ const matchedSensors = (floor.sensors ?? []).filter((s) => idSet.has(s.id));
977
+ if (matchedRooms.length === 0 &&
978
+ matchedZones.length === 0 &&
979
+ matchedHives.length === 0 &&
980
+ matchedSensors.length === 0) {
981
+ return undefined;
982
+ }
983
+ const ancestorRoomIds = new Set();
984
+ const collectRoomAncestor = (entity) => {
985
+ const roomId = entity.roomID ?? entity.room_id;
986
+ if (roomId)
987
+ ancestorRoomIds.add(roomId);
988
+ };
989
+ matchedZones.forEach(collectRoomAncestor);
990
+ matchedHives.forEach(collectRoomAncestor);
991
+ matchedSensors.forEach(collectRoomAncestor);
992
+ const matchedHiveIds = new Set(matchedHives.map((h) => h.id));
993
+ const ancestorHiveSerials = new Set();
994
+ for (const sensor of matchedSensors) {
995
+ if (sensor.hive_serial)
996
+ ancestorHiveSerials.add(sensor.hive_serial);
997
+ }
998
+ const ancestorHives = [];
999
+ for (const hive of floor.hives ?? []) {
1000
+ if (matchedHiveIds.has(hive.id))
1001
+ continue;
1002
+ if (hive.serialNumber && ancestorHiveSerials.has(hive.serialNumber)) {
1003
+ ancestorHives.push(hive);
1004
+ collectRoomAncestor(hive);
1005
+ }
1006
+ }
1007
+ const matchedRoomIds = new Set(matchedRooms.map((r) => r.id));
1008
+ const ancestorRooms = [];
1009
+ for (const room of floor.rooms ?? []) {
1010
+ if (matchedRoomIds.has(room.id))
1011
+ continue;
1012
+ if (ancestorRoomIds.has(room.id))
1013
+ ancestorRooms.push(room);
1014
+ }
1015
+ return {
1016
+ ...floor,
1017
+ rooms: [...matchedRooms, ...ancestorRooms],
1018
+ zones: matchedZones,
1019
+ hives: [...matchedHives, ...ancestorHives],
1020
+ sensors: matchedSensors,
1021
+ };
1022
+ }
213
1023
  /**
214
1024
  * Register butlr_list_topology with an McpServer instance
215
1025
  */