rhombus-node-mcp 0.1.56 → 0.1.57
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.
- package/dist/api/access-control-tool-api.js +231 -32
- package/dist/api/alarm-monitoring-tool-api.js +50 -1
- package/dist/api/automated-prompts-tool-api.js +9 -17
- package/dist/api/camera-policy-tool-api.js +122 -0
- package/dist/api/camera-tool-api.js +3 -5
- package/dist/api/camera-uptime-tool-api.js +62 -25
- package/dist/api/clips-tool-api.js +45 -9
- package/dist/api/create-tool-api.js +55 -7
- package/dist/api/door-tool-api.js +74 -13
- package/dist/api/entity-lookup-tool-api.js +8 -1
- package/dist/api/events-tool-api.js +79 -6
- package/dist/api/faces-tool-api.js +112 -7
- package/dist/api/get-accessible-apps.js +68 -6
- package/dist/api/get-entity-tool-api.js +12 -12
- package/dist/api/get-org-information-tool-api.js +2 -2
- package/dist/api/guest-management-tool-api.js +75 -1
- package/dist/api/location-tool-api.js +120 -28
- package/dist/api/lpr-tool-api.js +71 -16
- package/dist/api/policy-alerts-tool-api.js +7 -3
- package/dist/api/report-tool-api.js +4 -1
- package/dist/api/rules-tool-api.js +6 -16
- package/dist/api/schedule-tool-api.js +107 -0
- package/dist/api/search-tool-api.js +5 -13
- package/dist/api/update-tool-api.js +96 -1
- package/dist/api/user-tool-api.js +58 -13
- package/dist/createServer.js +18 -6
- package/dist/filtering-utils.js +421 -29
- package/dist/index.js +1 -1
- package/dist/network/network.js +122 -10
- package/dist/network/org-reference-cache.js +74 -0
- package/dist/telemetry/tracingProxy.js +4 -0
- package/dist/tools/entity-lookup-tool.js +6 -4
- package/dist/tools/get-entity-tool.js +40 -20
- package/dist/tools/get-org-information-tool.js +30 -2
- package/dist/tools/time-conversion-tool.js +59 -36
- package/dist/tools/time-tool.js +12 -3
- package/dist/tools/user-tool.js +100 -2
- package/dist/tools-console/access-anomaly-tool.js +2 -19
- package/dist/tools-console/access-control-tool.js +328 -2
- package/dist/tools-console/alarm-monitoring-tool.js +72 -2
- package/dist/tools-console/analytics-tool.js +2 -2
- package/dist/tools-console/badge-timeline-tool.js +2 -21
- package/dist/tools-console/camera-policy-tool.js +252 -0
- package/dist/tools-console/camera-tool.js +111 -89
- package/dist/tools-console/clips-tool.js +21 -31
- package/dist/tools-console/count-tool.js +8 -0
- package/dist/tools-console/create-camera-policy-tool.js +302 -122
- package/dist/tools-console/door-schedule-exception-tool.js +2 -33
- package/dist/tools-console/door-tool.js +130 -2
- package/dist/tools-console/elements-access-anomaly-tool.js +2 -19
- package/dist/tools-console/elements-badge-timeline-tool.js +2 -21
- package/dist/tools-console/elements-lost-badge-tool.js +2 -17
- package/dist/tools-console/elements-tool.js +2 -29
- package/dist/tools-console/events-tool.js +22 -101
- package/dist/tools-console/faces-tool.js +254 -28
- package/dist/tools-console/guest-management-tool.js +127 -2
- package/dist/tools-console/location-tool.js +156 -35
- package/dist/tools-console/lost-badge-tool.js +2 -17
- package/dist/tools-console/lpr-tool.js +89 -23
- package/dist/tools-console/netbox-access-anomaly-tool.js +2 -19
- package/dist/tools-console/netbox-badge-timeline-tool.js +2 -21
- package/dist/tools-console/netbox-lost-badge-tool.js +2 -17
- package/dist/tools-console/netbox-tool.js +2 -29
- package/dist/tools-console/onguard-tool.js +2 -30
- package/dist/tools-console/person-tracking-tool.js +2 -2
- package/dist/tools-console/policy-alerts-tool.js +5 -29
- package/dist/tools-console/reboot-cameras-tool.js +17 -2
- package/dist/tools-console/report-tool.js +10 -40
- package/dist/tools-console/schedule-tool.js +190 -0
- package/dist/tools-console/update-tool.js +298 -168
- package/dist/tools-console/video-walls-tool.js +93 -21
- package/dist/types/access-anomaly-tool-types.js +22 -2
- package/dist/types/access-control-tool-types.js +116 -1
- package/dist/types/alarm-monitoring-tool-types.js +42 -1
- package/dist/types/badge-timeline-tool-types.js +18 -3
- package/dist/types/camera-policy-tool-types.js +104 -0
- package/dist/types/camera-tool-types.js +80 -1
- package/dist/types/clips-tool-types.js +27 -3
- package/dist/types/create-camera-policy-tool-types.js +275 -26
- package/dist/types/door-schedule-exception-tool-types.js +20 -6
- package/dist/types/door-tool-types.js +36 -4
- package/dist/types/entity-lookup-tool-types.js +6 -1
- package/dist/types/events-tools-types.js +24 -16
- package/dist/types/faces-tools-types.js +106 -3
- package/dist/types/get-entity-tool-types.js +76 -12
- package/dist/types/guest-management-tool-types.js +103 -1
- package/dist/types/location-tool-types.js +100 -14
- package/dist/types/lost-badge-tool-types.js +17 -2
- package/dist/types/lpr-tool-types.js +47 -8
- package/dist/types/onguard-tool-types.js +21 -1
- package/dist/types/policy-alerts-tool-types.js +1 -1
- package/dist/types/report-tool-types.js +29 -1
- package/dist/types/schedule-tool-types.js +370 -0
- package/dist/types/schema.js +479 -9
- package/dist/types/time-conversion-tool-types.js +3 -4
- package/dist/types/update-tool-types.js +40 -18
- package/dist/types/user-tool-types.js +44 -1
- package/dist/types/video-walls-tool-types.js +30 -2
- package/dist/types/zod-schemas.js +5128 -996
- package/dist/util.js +62 -3
- package/dist/utils/entity-name-match.js +11 -0
- package/package.json +3 -1
package/dist/filtering-utils.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
175
|
-
//
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
//
|
|
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
|
-
? [...
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
|
703
|
+
// Fall back to the base includeFields description on parsing failure
|
|
322
704
|
}
|
|
323
705
|
}
|
|
324
706
|
const augmentedConfig = {
|
|
325
707
|
...config,
|
|
326
|
-
|
|
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:
|
|
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();
|