rhombus-node-mcp 0.1.56 → 0.1.58

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 (102) hide show
  1. package/dist/api/access-control-tool-api.js +231 -32
  2. package/dist/api/alarm-monitoring-tool-api.js +50 -1
  3. package/dist/api/automated-prompts-tool-api.js +9 -17
  4. package/dist/api/camera-policy-tool-api.js +122 -0
  5. package/dist/api/camera-tool-api.js +3 -5
  6. package/dist/api/camera-uptime-tool-api.js +62 -25
  7. package/dist/api/clips-tool-api.js +45 -9
  8. package/dist/api/create-tool-api.js +55 -7
  9. package/dist/api/door-tool-api.js +74 -13
  10. package/dist/api/entity-lookup-tool-api.js +8 -1
  11. package/dist/api/events-tool-api.js +79 -6
  12. package/dist/api/faces-tool-api.js +112 -7
  13. package/dist/api/get-accessible-apps.js +68 -6
  14. package/dist/api/get-entity-tool-api.js +12 -12
  15. package/dist/api/get-org-information-tool-api.js +2 -2
  16. package/dist/api/guest-management-tool-api.js +75 -1
  17. package/dist/api/location-tool-api.js +120 -28
  18. package/dist/api/lpr-tool-api.js +71 -16
  19. package/dist/api/policy-alerts-tool-api.js +7 -3
  20. package/dist/api/report-tool-api.js +4 -1
  21. package/dist/api/rules-tool-api.js +6 -16
  22. package/dist/api/schedule-tool-api.js +107 -0
  23. package/dist/api/search-tool-api.js +5 -13
  24. package/dist/api/update-tool-api.js +96 -1
  25. package/dist/api/user-tool-api.js +58 -13
  26. package/dist/createServer.js +18 -6
  27. package/dist/filtering-utils.js +421 -29
  28. package/dist/index.js +1 -1
  29. package/dist/network/network.js +122 -10
  30. package/dist/network/org-reference-cache.js +74 -0
  31. package/dist/telemetry/tracingProxy.js +4 -0
  32. package/dist/tools/entity-lookup-tool.js +6 -4
  33. package/dist/tools/get-entity-tool.js +40 -20
  34. package/dist/tools/get-org-information-tool.js +30 -2
  35. package/dist/tools/time-conversion-tool.js +59 -36
  36. package/dist/tools/time-tool.js +12 -3
  37. package/dist/tools/user-tool.js +100 -2
  38. package/dist/tools-console/access-anomaly-tool.js +2 -19
  39. package/dist/tools-console/access-control-tool.js +328 -2
  40. package/dist/tools-console/alarm-monitoring-tool.js +72 -2
  41. package/dist/tools-console/analytics-tool.js +2 -2
  42. package/dist/tools-console/badge-timeline-tool.js +2 -21
  43. package/dist/tools-console/camera-policy-tool.js +252 -0
  44. package/dist/tools-console/camera-tool.js +111 -89
  45. package/dist/tools-console/clips-tool.js +21 -31
  46. package/dist/tools-console/count-tool.js +8 -0
  47. package/dist/tools-console/create-camera-policy-tool.js +302 -122
  48. package/dist/tools-console/door-schedule-exception-tool.js +2 -33
  49. package/dist/tools-console/door-tool.js +130 -2
  50. package/dist/tools-console/elements-access-anomaly-tool.js +2 -19
  51. package/dist/tools-console/elements-badge-timeline-tool.js +2 -21
  52. package/dist/tools-console/elements-lost-badge-tool.js +2 -17
  53. package/dist/tools-console/elements-tool.js +2 -29
  54. package/dist/tools-console/events-tool.js +22 -101
  55. package/dist/tools-console/faces-tool.js +254 -28
  56. package/dist/tools-console/guest-management-tool.js +127 -2
  57. package/dist/tools-console/location-tool.js +156 -35
  58. package/dist/tools-console/lost-badge-tool.js +2 -17
  59. package/dist/tools-console/lpr-tool.js +89 -23
  60. package/dist/tools-console/netbox-access-anomaly-tool.js +2 -19
  61. package/dist/tools-console/netbox-badge-timeline-tool.js +2 -21
  62. package/dist/tools-console/netbox-lost-badge-tool.js +2 -17
  63. package/dist/tools-console/netbox-tool.js +2 -29
  64. package/dist/tools-console/onguard-tool.js +2 -30
  65. package/dist/tools-console/person-tracking-tool.js +2 -2
  66. package/dist/tools-console/policy-alerts-tool.js +5 -29
  67. package/dist/tools-console/reboot-cameras-tool.js +17 -2
  68. package/dist/tools-console/report-tool.js +11 -41
  69. package/dist/tools-console/schedule-tool.js +190 -0
  70. package/dist/tools-console/update-tool.js +298 -168
  71. package/dist/tools-console/video-walls-tool.js +93 -21
  72. package/dist/types/access-anomaly-tool-types.js +22 -2
  73. package/dist/types/access-control-tool-types.js +116 -1
  74. package/dist/types/alarm-monitoring-tool-types.js +42 -1
  75. package/dist/types/badge-timeline-tool-types.js +18 -3
  76. package/dist/types/camera-policy-tool-types.js +104 -0
  77. package/dist/types/camera-tool-types.js +80 -1
  78. package/dist/types/clips-tool-types.js +27 -3
  79. package/dist/types/create-camera-policy-tool-types.js +275 -26
  80. package/dist/types/door-schedule-exception-tool-types.js +20 -6
  81. package/dist/types/door-tool-types.js +36 -4
  82. package/dist/types/entity-lookup-tool-types.js +6 -1
  83. package/dist/types/events-tools-types.js +24 -16
  84. package/dist/types/faces-tools-types.js +106 -3
  85. package/dist/types/get-entity-tool-types.js +76 -12
  86. package/dist/types/guest-management-tool-types.js +103 -1
  87. package/dist/types/location-tool-types.js +100 -14
  88. package/dist/types/lost-badge-tool-types.js +17 -2
  89. package/dist/types/lpr-tool-types.js +47 -8
  90. package/dist/types/onguard-tool-types.js +21 -1
  91. package/dist/types/policy-alerts-tool-types.js +1 -1
  92. package/dist/types/report-tool-types.js +29 -1
  93. package/dist/types/schedule-tool-types.js +370 -0
  94. package/dist/types/schema.js +479 -9
  95. package/dist/types/time-conversion-tool-types.js +3 -4
  96. package/dist/types/update-tool-types.js +40 -18
  97. package/dist/types/user-tool-types.js +44 -1
  98. package/dist/types/video-walls-tool-types.js +30 -2
  99. package/dist/types/zod-schemas.js +5128 -996
  100. package/dist/util.js +62 -3
  101. package/dist/utils/entity-name-match.js +11 -0
  102. package/package.json +3 -1
@@ -1,13 +1,17 @@
1
1
  import { z } from "zod";
2
+ import { matchesEntityName } from "./utils/entity-name-match.js";
2
3
  // ---------------------------------------------------------------------------
3
4
  // Shared tool arg schemas
4
5
  // ---------------------------------------------------------------------------
6
+ const INCLUDE_FIELDS_DESCRIPTION = `Dot-notation field paths to include in the response (e.g. "vehicleEvents.vehicleLicensePlate").
7
+ Pass null to return all fields. WARNING: some responses can exceed 400k characters — use includeFields
8
+ to request only the data you need. For high-volume tools this may be required to get a complete answer.
9
+ Core device-status fields (connected, connectionStatus, healthStatus, healthStatusDetails, batteryStatus)
10
+ are always retained where present, even if not listed here.`;
5
11
  export const INCLUDE_FIELDS_ARG = z
6
12
  .array(z.string())
7
13
  .nullable()
8
- .describe(`Dot-notation field paths to include in the response (e.g. "vehicleEvents.vehicleLicensePlate").
9
- Pass null to return all fields. WARNING: some responses can exceed 400k characters — use includeFields
10
- to request only the data you need. For high-volume tools this may be required to get a complete answer.`);
14
+ .describe(INCLUDE_FIELDS_DESCRIPTION);
11
15
  export const FILTER_BY_ARG = z
12
16
  .array(z.object({
13
17
  field: z
@@ -23,9 +27,70 @@ export const FILTER_BY_ARG = z
23
27
  .nullable()
24
28
  .describe(`Filter array items in the response by field values. All conditions are ANDed.
25
29
  Example: [{field: "vehicleLicensePlate", op: "=", value: "ABC123"}, {field: "confidence", op: ">", value: 0.8}]
30
+ For name, locationName, deviceName, cameraName and doorName fields, 'contains' ignores capitalization, spacing and punctuation (e.g. 'iceblocks' matches 'Ice Blocks - Headquarters'). All matching candidates are returned; use '=' for exact comparisons.
26
31
  Use alongside includeFields to get only the specific records and fields you need.`);
27
- /** These keys will always be included in processed output schemas */
28
- const INCLUDE_WHITELIST = ["requestType"];
32
+ // nullish, not nullable: pre-existing callers (evals, specs, cached tool-call
33
+ // replays) omit the key entirely and must stay valid.
34
+ export const GROUP_BY_ARG = z
35
+ .string()
36
+ .nullish()
37
+ .describe(`Group array items by a field and return EXACT per-group counts computed server-side, instead of the rows.
38
+ Use this for "how many per X" / "which X has the most Y" questions — never tally rows yourself.
39
+ Example: groupBy "locationUuid" on a camera list returns {camerasGrouped: {by: "locationUuid", total: 63, groups: {"<uuid>": 37, ...}}} (groups sorted by count, descending).
40
+ Applied after filterBy, so e.g. filterBy [{field: "connected", op: "=", value: false}] + groupBy "locationUuid" = offline devices per location.
41
+ Accepts the same dot-notation field paths as filterBy (optionally prefixed with the array key, e.g. "cameras.locationUuid").`);
42
+ // Grouping by a high-cardinality field (e.g. a uuid) must not bloat the
43
+ // response: keep the top N groups and report how many were omitted.
44
+ const GROUP_BY_MAX_GROUPS = 50;
45
+ /**
46
+ * Cross-cutting keys every tool emits (or may emit), always kept regardless of
47
+ * what the caller projected. `note` carries tool-emitted diagnostics (e.g.
48
+ * "this location has no doors, so the empty result is not an absence of
49
+ * activity") — projecting it away would hide exactly the caveat the model needs.
50
+ *
51
+ * Tool-SPECIFIC fields do not belong here. Declare those at the tool's own
52
+ * `registerTool` call with `protectFields` below, so the protection lives next
53
+ * to the thing being protected instead of in a list nobody reads.
54
+ */
55
+ const ALWAYS_PROTECTED_FIELDS = [
56
+ "requestType",
57
+ "note",
58
+ "error",
59
+ // api2 can succeed *with a caveat* (`warningMsg`), and a mutation that half
60
+ // applied says so only there. Projecting it away turns a qualified success
61
+ // into an unqualified one, which is worse than losing the field.
62
+ "warningMsg",
63
+ "filterByWarnings",
64
+ "groupByWarnings",
65
+ ];
66
+ /**
67
+ * Carries a tool's protected field names on its registerTool config. A symbol
68
+ * so it cannot collide with an SDK config key and never survives into JSON;
69
+ * the proxy strips it before forwarding the config on regardless.
70
+ */
71
+ const PROTECTED_FIELDS = Symbol.for("rhombus.protectedFields");
72
+ /**
73
+ * Marks top-level output fields that must survive an `includeFields`
74
+ * projection even when the caller didn't ask for them.
75
+ *
76
+ * Use it for anything the model needs in order to read the rest of the result
77
+ * correctly — pre-computed rosters/aggregates it would otherwise have to derive
78
+ * from a long list, scope/caveat markers, and the like:
79
+ *
80
+ * ```ts
81
+ * server.registerTool(
82
+ * TOOL_NAME,
83
+ * protectFields({ title: "Faces", ... }, ["faceEventSummary"]),
84
+ * TOOL_HANDLER,
85
+ * );
86
+ * ```
87
+ *
88
+ * Names are matched at the TOP LEVEL of the result only, and a bare name keeps
89
+ * the field's entire subtree.
90
+ */
91
+ export function protectFields(config, fields) {
92
+ return { ...config, [PROTECTED_FIELDS]: fields };
93
+ }
29
94
  function buildTrie(paths) {
30
95
  const trie = {};
31
96
  for (const p of paths) {
@@ -39,6 +104,30 @@ function buildTrie(paths) {
39
104
  }
40
105
  return trie;
41
106
  }
107
+ // Device-status fields the model must never be able to project away: a status
108
+ // query where includeFields omits these returns data with no status in it, and
109
+ // the model then truthfully answers "status not included" (observed in prod,
110
+ // 2026-07-22). Grafted onto every internal trie node — fields a payload
111
+ // doesn't have are simply absent, so this is a no-op outside device lists.
112
+ const PROTECTED_STATUS_FIELDS = [
113
+ "connected",
114
+ "connectionStatus",
115
+ "healthStatus",
116
+ "healthStatusDetails",
117
+ "batteryStatus",
118
+ ];
119
+ function addProtectedStatusFields(trie) {
120
+ const children = Object.keys(trie);
121
+ // An empty node means "include everything below" — already keeps them.
122
+ if (children.length === 0)
123
+ return;
124
+ for (const key of children)
125
+ addProtectedStatusFields(trie[key]);
126
+ for (const field of PROTECTED_STATUS_FIELDS) {
127
+ if (!(field in trie))
128
+ trie[field] = {};
129
+ }
130
+ }
42
131
  function filterByTrie(obj, trie) {
43
132
  // Empty trie at this level means "include everything"
44
133
  if (Object.keys(trie).length === 0) {
@@ -48,7 +137,7 @@ function filterByTrie(obj, trie) {
48
137
  const mapped = obj
49
138
  .map((item) => filterByTrie(item, trie))
50
139
  .filter((item) => item !== undefined);
51
- return mapped.length > 0 ? mapped : undefined;
140
+ return obj.length === 0 || mapped.length > 0 ? mapped : undefined;
52
141
  }
53
142
  if (typeof obj === "object" && obj !== null) {
54
143
  const result = {};
@@ -59,6 +148,21 @@ function filterByTrie(obj, trie) {
59
148
  result[key] = child;
60
149
  }
61
150
  }
151
+ for (const key of Object.keys(obj)) {
152
+ if (key.endsWith("Grouped") ||
153
+ key === "groupByWarnings" ||
154
+ key === "filterByWarnings" ||
155
+ key === "error" ||
156
+ key === "errorMsg" ||
157
+ key === "warningMsg") {
158
+ if (obj[key] !== undefined)
159
+ result[key] = obj[key];
160
+ const countKey = `${key.slice(0, -"Grouped".length)}Count`;
161
+ if (key.endsWith("Grouped") && typeof obj[countKey] === "number") {
162
+ result[countKey] = obj[countKey];
163
+ }
164
+ }
165
+ }
62
166
  return Object.keys(result).length > 0 ? result : undefined;
63
167
  }
64
168
  // Primitive reached with a non-empty trie — return as-is (e.g. the trie
@@ -77,7 +181,42 @@ export function filterIncludedFields(obj, fieldsToInclude) {
77
181
  return obj;
78
182
  }
79
183
  const trie = buildTrie(fieldsToInclude);
80
- return filterByTrie(obj, trie);
184
+ addProtectedStatusFields(trie);
185
+ // When groupBy replaced an array with its summary, no trie path can match
186
+ // anything — start from an empty object so the re-attach below still runs
187
+ // (an empty final result falls back to `undefined`, the old behavior).
188
+ const projected = filterByTrie(obj, trie) ??
189
+ (obj && typeof obj === "object" && !Array.isArray(obj) ? {} : undefined);
190
+ // A `<key>Count` sibling is authoritative metadata for its array (post-
191
+ // filterBy it is the match count) — keep it whenever its array survives the
192
+ // projection, even if the caller didn't list it explicitly. `<key>Grouped`
193
+ // summaries (from groupBy) replace their array entirely, so no trie path
194
+ // can match them — always re-attach, along with their Count sibling.
195
+ if (projected &&
196
+ typeof projected === "object" &&
197
+ !Array.isArray(projected) &&
198
+ typeof obj === "object" &&
199
+ !Array.isArray(obj)) {
200
+ for (const key of Object.keys(projected)) {
201
+ if (Array.isArray(projected[key]) &&
202
+ typeof obj[`${key}Count`] === "number" &&
203
+ !(`${key}Count` in projected)) {
204
+ projected[`${key}Count`] = obj[`${key}Count`];
205
+ }
206
+ }
207
+ for (const key of Object.keys(obj)) {
208
+ if (key.endsWith("Grouped")) {
209
+ projected[key] = obj[key];
210
+ const countKey = `${key.slice(0, -"Grouped".length)}Count`;
211
+ if (typeof obj[countKey] === "number" && !(countKey in projected)) {
212
+ projected[countKey] = obj[countKey];
213
+ }
214
+ }
215
+ }
216
+ if (Object.keys(projected).length === 0)
217
+ return undefined;
218
+ }
219
+ return projected;
81
220
  }
82
221
  // ---------------------------------------------------------------------------
83
222
  // applyFilterBy — predicate filtering of array items
@@ -115,6 +254,9 @@ function matchesCondition(item, condition) {
115
254
  case "<=":
116
255
  return Number(actual) <= Number(value);
117
256
  case "contains":
257
+ if (["name", "locationName", "deviceName", "cameraName", "doorName"].includes(condition.field.split(".").at(-1) ?? "")) {
258
+ return matchesEntityName(String(actual), String(value));
259
+ }
118
260
  return String(actual).toLowerCase().includes(String(value).toLowerCase());
119
261
  default:
120
262
  return false;
@@ -155,33 +297,144 @@ export function applyFilterBy(obj, conditions) {
155
297
  conditionsByTopKey[topKey].push({ ...cond, field: subField });
156
298
  }
157
299
  const result = { ...obj };
300
+ const warnings = [];
301
+ // A condition whose field is absent from EVERY item of the target array
302
+ // would silently drop all rows (undefined never matches) — the model then
303
+ // reads {items: [], count: N} as "0 matches" when really it filtered on a
304
+ // phantom field. Skip such conditions and warn loudly instead.
305
+ const partitionConds = (items, conds, arrayKey) => {
306
+ if (items.length === 0)
307
+ return conds;
308
+ const applicable = [];
309
+ for (const c of conds) {
310
+ if (items.some((item) => getNestedValue(item, c.field) !== undefined)) {
311
+ applicable.push(c);
312
+ }
313
+ else {
314
+ const available = [...new Set(items.slice(0, 50).flatMap((item) => item && typeof item === "object" ? Object.keys(item) : []))].sort();
315
+ warnings.push(`filterBy condition on field "${c.field}" was IGNORED — no item in "${arrayKey}" has that field (it would have matched nothing). Available fields: ${available.join(", ")}`);
316
+ }
317
+ }
318
+ return applicable;
319
+ };
158
320
  for (const [topKey, keyConds] of Object.entries(conditionsByTopKey)) {
159
321
  if (topKey === "*") {
160
322
  // Apply conditions to every top-level array in the object
161
323
  for (const k of Object.keys(result)) {
162
324
  if (Array.isArray(result[k])) {
163
- result[k] = result[k].filter((item) => keyConds.every((c) => matchesCondition(item, c)));
325
+ const conds = partitionConds(result[k], keyConds, k);
326
+ result[k] = result[k].filter((item) => conds.every((c) => matchesCondition(item, c)));
164
327
  }
165
328
  }
166
329
  }
167
330
  else if (topKey in result && Array.isArray(result[topKey])) {
168
- result[topKey] = result[topKey].filter((item) => keyConds.every((c) => matchesCondition(item, c)));
331
+ const conds = partitionConds(result[topKey], keyConds, topKey);
332
+ result[topKey] = result[topKey].filter((item) => conds.every((c) => matchesCondition(item, c)));
169
333
  }
170
334
  }
171
- // If the result has a sibling `count: number` and exactly one top-level
172
- // array, sync count to the (now-filtered) array length. This makes `count`
335
+ // Sync sibling counts to the (now-filtered) array lengths so counts always
173
336
  // reflect what the model is looking at — pre-filter total when no filterBy
174
- // was applied (handler computed it), post-filter total when one was. Any
175
- // tool returning {count, items[]} gets this for free.
337
+ // was applied (handler computed it), post-filter total when one was.
338
+ // Covers both the bare `{count, items[]}` convention and the
339
+ // `{<key>Count, <key>[]}` convention (e.g. camerasCount next to cameras).
176
340
  if (typeof result.count === "number") {
177
341
  const arrayKeys = Object.keys(result).filter((k) => Array.isArray(result[k]));
178
342
  if (arrayKeys.length === 1) {
179
343
  result.count = result[arrayKeys[0]].length;
180
344
  }
181
345
  }
346
+ for (const k of Object.keys(result)) {
347
+ if (Array.isArray(result[k]) && typeof result[`${k}Count`] === "number") {
348
+ result[`${k}Count`] = result[k].length;
349
+ }
350
+ }
351
+ if (warnings.length > 0) {
352
+ result.filterByWarnings = warnings;
353
+ }
182
354
  return result;
183
355
  }
184
356
  // ---------------------------------------------------------------------------
357
+ // applyGroupBy — server-side per-group counts
358
+ // ---------------------------------------------------------------------------
359
+ /**
360
+ * Replaces each targeted array with a `<key>Grouped` summary of EXACT counts
361
+ * per distinct value of `groupByField` — so "how many per location" is
362
+ * computed in code, never tallied by the model (LLM tallies over long lists
363
+ * are reliably off by small amounts).
364
+ *
365
+ * `groupByField` uses the same addressing as filterBy conditions: a bare
366
+ * field applies to every top-level array, `arrayKey.field` targets one array.
367
+ * A field no item has leaves the rows intact and warns via `groupByWarnings`.
368
+ */
369
+ export function applyGroupBy(obj, groupByField) {
370
+ if (!groupByField || typeof obj !== "object" || obj === null) {
371
+ return obj;
372
+ }
373
+ const dotIdx = groupByField.indexOf(".");
374
+ const topKey = dotIdx === -1 ? "*" : groupByField.substring(0, dotIdx);
375
+ const field = dotIdx === -1 ? groupByField : groupByField.substring(dotIdx + 1);
376
+ const result = Array.isArray(obj) ? { items: obj } : { ...obj };
377
+ const warnings = [];
378
+ const groupArray = (items, arrayKey) => {
379
+ if (items.length > 0 && !items.some((item) => getNestedValue(item, field) !== undefined)) {
380
+ const available = [...new Set(items.slice(0, 50).flatMap((item) => item && typeof item === "object" ? Object.keys(item) : []))].sort();
381
+ warnings.push(`groupBy field "${field}" was IGNORED — no item in "${arrayKey}" has that field; rows returned ungrouped. Available fields: ${available.join(", ")}`);
382
+ return false;
383
+ }
384
+ const tally = new Map();
385
+ for (const item of items) {
386
+ const value = getNestedValue(item, field);
387
+ const key = value === undefined || value === null
388
+ ? "(none)"
389
+ : typeof value === "object"
390
+ ? "(non-scalar)"
391
+ : String(value);
392
+ tally.set(key, (tally.get(key) ?? 0) + 1);
393
+ }
394
+ const sorted = [...tally.entries()].sort((a, b) => b[1] - a[1]);
395
+ const kept = sorted.slice(0, GROUP_BY_MAX_GROUPS);
396
+ const grouped = {
397
+ by: field,
398
+ total: items.length,
399
+ groups: Object.fromEntries(kept),
400
+ };
401
+ if (sorted.length > kept.length) {
402
+ grouped.omittedGroups = sorted.length - kept.length;
403
+ grouped.note = `${sorted.length} distinct values; showing the top ${kept.length} by count.`;
404
+ }
405
+ result[`${arrayKey}Grouped`] = grouped;
406
+ delete result[arrayKey];
407
+ if (typeof result[`${arrayKey}Count`] === "number") {
408
+ result[`${arrayKey}Count`] = items.length;
409
+ }
410
+ return true;
411
+ };
412
+ let groupedAny = false;
413
+ if (topKey === "*") {
414
+ for (const k of Object.keys(result)) {
415
+ if (Array.isArray(result[k])) {
416
+ groupedAny = groupArray(result[k], k) || groupedAny;
417
+ }
418
+ }
419
+ }
420
+ else if (topKey in result && Array.isArray(result[topKey])) {
421
+ groupedAny = groupArray(result[topKey], topKey);
422
+ }
423
+ else if (result[topKey] && typeof result[topKey] === "object" && field.includes(".")) {
424
+ result[topKey] = applyGroupBy(result[topKey], field);
425
+ groupedAny = true;
426
+ }
427
+ else {
428
+ warnings.push(`groupBy "${groupByField}" was IGNORED — the response has no array at "${topKey}".`);
429
+ }
430
+ if (warnings.length > 0) {
431
+ result.groupByWarnings = [...(result.groupByWarnings ?? []), ...warnings];
432
+ }
433
+ return Array.isArray(obj) && !groupedAny && warnings.length === 0
434
+ ? obj
435
+ : result;
436
+ }
437
+ // ---------------------------------------------------------------------------
185
438
  // zodToDotNotationPaths — derive available field paths from an OUTPUT_SCHEMA
186
439
  // ---------------------------------------------------------------------------
187
440
  /**
@@ -225,20 +478,135 @@ export function zodToDotNotationPaths(schema, prefix = "") {
225
478
  return [];
226
479
  }
227
480
  // ---------------------------------------------------------------------------
481
+ // deepOptionalizeSchema — relax an outputSchema for post-projection validation
482
+ // ---------------------------------------------------------------------------
483
+ /** Carry a rebuilt schema's description over from the schema it replaces. */
484
+ function withDescription(next, prev) {
485
+ return prev.description ? next.describe(prev.description) : next;
486
+ }
487
+ /**
488
+ * Returns a copy of `schema` with every object field made optional, recursively.
489
+ *
490
+ * The MCP SDK validates a tool's `structuredContent` against its registered
491
+ * `outputSchema` AFTER the handler returns — which is after this proxy has
492
+ * already projected the payload down to the caller's `includeFields`. Any field
493
+ * the schema marks required but the projection dropped then fails validation,
494
+ * and a working tool call comes back to the model as
495
+ * `MCP error -32602: Output validation error`. (Hit in prod by
496
+ * events-tool/brivo-access-control: includeFields asked for
497
+ * `brivoDoors.doorName` + `.locationUuid` but not the required
498
+ * `brivoDoors.brivoDoorId`, so the model reported Brivo data as unreadable.)
499
+ *
500
+ * Projection inherently invalidates required-ness, so the schema we register
501
+ * has to be all-optional. Nothing model-facing changes: `outputSchema` is never
502
+ * forwarded to the LLM, and the includeFields path catalog is derived from the
503
+ * ORIGINAL schema before this runs. Descriptions are preserved.
504
+ */
505
+ export function deepOptionalizeSchema(schema) {
506
+ // Wrappers: unwrap, recurse, re-wrap so nested objects are relaxed too.
507
+ if (schema instanceof z.ZodOptional) {
508
+ return deepOptionalizeSchema(schema.unwrap()).optional();
509
+ }
510
+ if (schema instanceof z.ZodNullable) {
511
+ return deepOptionalizeSchema(schema.unwrap()).nullable();
512
+ }
513
+ // The default is dropped: this schema is only ever used for validation, and
514
+ // a defaulted field is already satisfied by `undefined`.
515
+ if (schema instanceof z.ZodDefault) {
516
+ return deepOptionalizeSchema(schema._def.innerType).optional();
517
+ }
518
+ if (schema instanceof z.ZodObject) {
519
+ const relaxed = {
520
+ filterByWarnings: z.array(z.string()).optional(),
521
+ groupByWarnings: z.array(z.string()).optional(),
522
+ };
523
+ for (const [key, value] of Object.entries(schema.shape)) {
524
+ relaxed[key] = optionalize(deepOptionalizeSchema(value));
525
+ let unwrapped = value;
526
+ while (unwrapped instanceof z.ZodOptional ||
527
+ unwrapped instanceof z.ZodNullable ||
528
+ unwrapped instanceof z.ZodDefault) {
529
+ unwrapped = unwrapped.unwrap();
530
+ }
531
+ if (unwrapped instanceof z.ZodArray) {
532
+ relaxed[`${key}Grouped`] = z.object({
533
+ by: z.string(),
534
+ total: z.number().int().nonnegative(),
535
+ groups: z.record(z.string(), z.number().int().nonnegative()),
536
+ omittedGroups: z.number().int().nonnegative().optional(),
537
+ note: z.string().optional(),
538
+ }).optional();
539
+ }
540
+ }
541
+ return withDescription(z.object(relaxed), schema);
542
+ }
543
+ if (schema instanceof z.ZodArray) {
544
+ return withDescription(z.array(deepOptionalizeSchema(schema.element)), schema);
545
+ }
546
+ if (schema instanceof z.ZodRecord) {
547
+ const def = schema._def;
548
+ return withDescription(z.record(def.keyType, deepOptionalizeSchema(def.valueType)), schema);
549
+ }
550
+ // Covers discriminated unions too. Rebuilding one as a plain union is
551
+ // deliberate — an optional discriminator is not a legal discriminated union.
552
+ if (schema instanceof z.ZodUnion ||
553
+ schema instanceof z.ZodDiscriminatedUnion) {
554
+ const options = schema._def.options;
555
+ if (Array.isArray(options) && options.length >= 2) {
556
+ return withDescription(z.union(options.map(deepOptionalizeSchema)), schema);
557
+ }
558
+ return schema;
559
+ }
560
+ // Scalars and anything we don't model (effects, pipes, lazy) pass through.
561
+ return schema;
562
+ }
563
+ /** `.optional()` builds a fresh wrapper, which does not inherit the description. */
564
+ function optionalize(schema) {
565
+ return schema instanceof z.ZodOptional
566
+ ? schema
567
+ : withDescription(schema.optional(), schema);
568
+ }
569
+ /**
570
+ * Applies {@link deepOptionalizeSchema} to either form `registerTool` accepts —
571
+ * a `ZodObject` or a raw `{ key: ZodType }` shape — returning the same form.
572
+ * A schema we cannot rewrite is left untouched; strict validation is a better
573
+ * failure mode than a broken registration.
574
+ */
575
+ function relaxOutputSchemaForProjection(outputSchema) {
576
+ try {
577
+ if (outputSchema instanceof z.ZodType) {
578
+ return deepOptionalizeSchema(outputSchema);
579
+ }
580
+ if (outputSchema && typeof outputSchema === "object") {
581
+ const relaxed = deepOptionalizeSchema(z.object(outputSchema));
582
+ return relaxed.shape;
583
+ }
584
+ }
585
+ catch {
586
+ // fall through
587
+ }
588
+ return outputSchema;
589
+ }
590
+ // ---------------------------------------------------------------------------
228
591
  // createFilteringProxy — MCP server proxy that adds includeFields + filterBy
229
592
  // ---------------------------------------------------------------------------
593
+ // Tool DESCRIPTIONS are always in the model's billed context (even for
594
+ // deferred tools behind hosted tool_search — only parameter schemas are
595
+ // deferred). Keep this suffix to one short line and put the usage docs +
596
+ // per-tool field-path catalog in the includeFields/filterBy PARAMETER
597
+ // descriptions instead, which cost nothing until the tool is loaded.
598
+ // (PERF_MASTER_PLAN P2 #4a — the old suffix + path list was 46% of the
599
+ // entire 131KB description surface.)
230
600
  const FILTERING_DESCRIPTION_SUFFIX = `
231
601
 
232
- **Output filtering (all tools):**
233
- - \`includeFields\` (string[]): Dot-notation paths to keep in the response (e.g. \`"vehicleEvents.vehicleLicensePlate"\`). Omit to return all fields.
234
- - \`filterBy\` (array): Predicates to filter array items. Each entry: \`{field, op, value}\` where op is one of \`= != > >= < <= contains\`. All conditions are ANDed. Example: \`[{field:"vehicleLicensePlate", op:"=", value:"ABC123"}]\`
235
- WARNING: some tool responses exceed 400k characters — use these params to request only the data you need.`;
236
- function applyFilteringToResult(result, includeFields, filterBy) {
237
- if (!includeFields?.length && !filterBy?.length)
602
+ Supports \`includeFields\` (field projection) and \`filterBy\` (row predicates) params to shrink large responses — see their parameter descriptions for usage and this tool's available field paths.`;
603
+ function applyFilteringToResult(result, includeFields, filterBy, groupBy, protectedFields = []) {
604
+ if (!includeFields?.length && !filterBy?.length && !groupBy)
238
605
  return result;
239
- // Always include whitelisted fields
606
+ // Re-add the cross-cutting keys plus whatever this tool declared, so a
607
+ // projection can only ever narrow the rows the caller asked about.
240
608
  const effectiveIncludeFields = includeFields?.length
241
- ? [...INCLUDE_WHITELIST, ...includeFields]
609
+ ? [...ALWAYS_PROTECTED_FIELDS, ...protectedFields, ...includeFields]
242
610
  : includeFields;
243
611
  const filteredContent = result.content.map((item) => {
244
612
  if (item.type !== "text")
@@ -248,6 +616,8 @@ function applyFilteringToResult(result, includeFields, filterBy) {
248
616
  let parsed = JSON.parse(item.text);
249
617
  if (filterBy?.length)
250
618
  parsed = applyFilterBy(parsed, filterBy) ?? parsed;
619
+ if (groupBy)
620
+ parsed = applyGroupBy(parsed, groupBy) ?? parsed;
251
621
  if (effectiveIncludeFields?.length)
252
622
  parsed = filterIncludedFields(parsed, effectiveIncludeFields) ?? parsed;
253
623
  return { ...item, text: JSON.stringify(parsed) };
@@ -262,6 +632,9 @@ function applyFilteringToResult(result, includeFields, filterBy) {
262
632
  if (filterBy?.length)
263
633
  filteredStructured =
264
634
  applyFilterBy(filteredStructured, filterBy) ?? filteredStructured;
635
+ if (groupBy)
636
+ filteredStructured =
637
+ applyGroupBy(filteredStructured, groupBy) ?? filteredStructured;
265
638
  if (effectiveIncludeFields?.length)
266
639
  filteredStructured =
267
640
  filterIncludedFields(filteredStructured, effectiveIncludeFields) ??
@@ -278,6 +651,7 @@ function applyFilteringToResult(result, includeFields, filterBy) {
278
651
  * 1. Inject `includeFields` and `filterBy` into the tool's inputSchema
279
652
  * 2. Append a description suffix explaining the filtering params
280
653
  * 3. Wrap the handler to apply filtering to the tool result
654
+ * 4. Backstop results that would otherwise fail SDK output validation
281
655
  *
282
656
  * Tools whose names appear in `blacklist` are registered without modification.
283
657
  *
@@ -295,7 +669,12 @@ export function createFilteringProxy(server, blacklist = new Set()) {
295
669
  if (blacklist.has(name)) {
296
670
  return target.registerTool(name, config, handler);
297
671
  }
298
- let descriptionSuffix = FILTERING_DESCRIPTION_SUFFIX;
672
+ // Declared via protectFields() at the tool's own registration.
673
+ const protectedFields = config?.[PROTECTED_FIELDS] ?? [];
674
+ // Field-path catalog goes on the includeFields PARAM description
675
+ // (deferred/unbilled until the tool is loaded), not the tool
676
+ // description.
677
+ let includeFieldsArg = INCLUDE_FIELDS_ARG;
299
678
  if (config.outputSchema) {
300
679
  try {
301
680
  let schema;
@@ -312,30 +691,43 @@ export function createFilteringProxy(server, blacklist = new Set()) {
312
691
  if (paths.length > 0) {
313
692
  const filteredPaths = [...new Set(paths.filter((p) => p !== "requestType" && p !== "error" && p.trim() !== ""))].sort();
314
693
  if (filteredPaths.length > 0) {
315
- descriptionSuffix += `\n\n**Available output field paths for this tool's \`includeFields\` / \`filterBy\`:**\n` +
316
- filteredPaths.map((p) => `- \`"${p}"\``).join("\n");
694
+ includeFieldsArg = z
695
+ .array(z.string())
696
+ .nullable()
697
+ .describe(`${INCLUDE_FIELDS_DESCRIPTION}\n\nAvailable output field paths for this tool's includeFields / filterBy:\n` +
698
+ filteredPaths.map((p) => `- "${p}"`).join("\n"));
317
699
  }
318
700
  }
319
701
  }
320
702
  catch (error) {
321
- // Fall back to the default description suffix on parsing failure
703
+ // Fall back to the base includeFields description on parsing failure
322
704
  }
323
705
  }
324
706
  const augmentedConfig = {
325
707
  ...config,
326
- description: (config.description ?? "") + descriptionSuffix,
708
+ // Must come after the spread: the registered schema has to
709
+ // tolerate the projection this proxy applies to the result.
710
+ ...(config.outputSchema
711
+ ? {
712
+ outputSchema: relaxOutputSchemaForProjection(config.outputSchema),
713
+ }
714
+ : {}),
715
+ description: (config.description ?? "") + FILTERING_DESCRIPTION_SUFFIX,
327
716
  inputSchema: {
328
717
  ...config.inputSchema,
329
- includeFields: INCLUDE_FIELDS_ARG,
718
+ includeFields: includeFieldsArg,
330
719
  filterBy: FILTER_BY_ARG,
720
+ groupBy: GROUP_BY_ARG,
331
721
  },
332
722
  };
333
723
  // biome-ignore lint/suspicious/noExplicitAny: proxy intercept
334
724
  const wrappedHandler = async (args, extra) => {
335
- const { includeFields, filterBy, ...restArgs } = args;
725
+ const { includeFields, filterBy, groupBy, ...restArgs } = args;
336
726
  const result = await handler(restArgs, extra);
337
- return applyFilteringToResult(result, includeFields, filterBy);
727
+ return applyFilteringToResult(result, includeFields, filterBy, groupBy, protectedFields);
338
728
  };
729
+ // The marker is proxy-internal metadata; don't hand it to the SDK.
730
+ delete augmentedConfig[PROTECTED_FIELDS];
339
731
  return target.registerTool(name, augmentedConfig, wrappedHandler);
340
732
  };
341
733
  },
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ const TRANSPORT_TYPE = process.env.TRANSPORT_TYPE || "stdio";
9
9
  async function main() {
10
10
  const serverUrl = process.env.RHOMBUS_API_SERVER || "api2.rhombussystems.com";
11
11
  if (RHOMBUS_API_KEY) {
12
- logger.info(`🔑 Using API_KEY: ${RHOMBUS_API_KEY}`);
12
+ logger.info(`🔑 Using API_KEY: <set, ${RHOMBUS_API_KEY.length} chars>`);
13
13
  }
14
14
  logger.info("🌐 Using server url", serverUrl);
15
15
  await serverInit();