rhombus-node-mcp 0.1.55 → 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.
Files changed (108) 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 +8 -4
  33. package/dist/tools/get-entity-tool.js +46 -21
  34. package/dist/tools/get-org-information-tool.js +36 -3
  35. package/dist/tools/time-conversion-tool.js +65 -37
  36. package/dist/tools/time-tool.js +18 -4
  37. package/dist/tools/user-tool.js +101 -1
  38. package/dist/tools-console/access-anomaly-tool.js +4 -19
  39. package/dist/tools-console/access-control-tool.js +330 -2
  40. package/dist/tools-console/alarm-monitoring-tool.js +73 -1
  41. package/dist/tools-console/analytics-tool.js +4 -2
  42. package/dist/tools-console/automated-prompts-tool.js +2 -0
  43. package/dist/tools-console/badge-timeline-tool.js +4 -21
  44. package/dist/tools-console/camera-policy-tool.js +252 -0
  45. package/dist/tools-console/camera-tool.js +113 -89
  46. package/dist/tools-console/camera-uptime-tool.js +2 -0
  47. package/dist/tools-console/clips-tool.js +23 -31
  48. package/dist/tools-console/count-tool.js +19 -6
  49. package/dist/tools-console/create-camera-policy-tool.js +304 -122
  50. package/dist/tools-console/door-schedule-exception-tool.js +4 -33
  51. package/dist/tools-console/door-tool.js +131 -1
  52. package/dist/tools-console/elements-access-anomaly-tool.js +4 -19
  53. package/dist/tools-console/elements-badge-timeline-tool.js +4 -21
  54. package/dist/tools-console/elements-lost-badge-tool.js +4 -17
  55. package/dist/tools-console/elements-tool.js +4 -29
  56. package/dist/tools-console/events-tool.js +24 -101
  57. package/dist/tools-console/faces-tool.js +255 -27
  58. package/dist/tools-console/guest-management-tool.js +128 -1
  59. package/dist/tools-console/location-tool.js +161 -35
  60. package/dist/tools-console/lost-badge-tool.js +4 -17
  61. package/dist/tools-console/lpr-tool.js +90 -22
  62. package/dist/tools-console/netbox-access-anomaly-tool.js +4 -19
  63. package/dist/tools-console/netbox-badge-timeline-tool.js +4 -21
  64. package/dist/tools-console/netbox-lost-badge-tool.js +4 -17
  65. package/dist/tools-console/netbox-tool.js +4 -29
  66. package/dist/tools-console/onguard-tool.js +4 -30
  67. package/dist/tools-console/person-tracking-tool.js +4 -2
  68. package/dist/tools-console/policy-alerts-tool.js +6 -28
  69. package/dist/tools-console/reboot-cameras-tool.js +23 -3
  70. package/dist/tools-console/report-tool.js +12 -40
  71. package/dist/tools-console/rules-tool.js +2 -0
  72. package/dist/tools-console/schedule-tool.js +190 -0
  73. package/dist/tools-console/search-tool.js +2 -0
  74. package/dist/tools-console/update-tool.js +300 -168
  75. package/dist/tools-console/user-access-trail-tool.js +2 -0
  76. package/dist/tools-console/user-audit-tool.js +2 -0
  77. package/dist/tools-console/video-walls-tool.js +94 -20
  78. package/dist/types/access-anomaly-tool-types.js +22 -2
  79. package/dist/types/access-control-tool-types.js +116 -1
  80. package/dist/types/alarm-monitoring-tool-types.js +42 -1
  81. package/dist/types/badge-timeline-tool-types.js +18 -3
  82. package/dist/types/camera-policy-tool-types.js +104 -0
  83. package/dist/types/camera-tool-types.js +80 -1
  84. package/dist/types/clips-tool-types.js +27 -3
  85. package/dist/types/create-camera-policy-tool-types.js +275 -26
  86. package/dist/types/door-schedule-exception-tool-types.js +20 -6
  87. package/dist/types/door-tool-types.js +36 -4
  88. package/dist/types/entity-lookup-tool-types.js +6 -1
  89. package/dist/types/events-tools-types.js +24 -16
  90. package/dist/types/faces-tools-types.js +106 -3
  91. package/dist/types/get-entity-tool-types.js +76 -12
  92. package/dist/types/guest-management-tool-types.js +103 -1
  93. package/dist/types/location-tool-types.js +100 -14
  94. package/dist/types/lost-badge-tool-types.js +17 -2
  95. package/dist/types/lpr-tool-types.js +47 -8
  96. package/dist/types/onguard-tool-types.js +21 -1
  97. package/dist/types/policy-alerts-tool-types.js +1 -1
  98. package/dist/types/report-tool-types.js +29 -1
  99. package/dist/types/schedule-tool-types.js +370 -0
  100. package/dist/types/schema.js +479 -9
  101. package/dist/types/time-conversion-tool-types.js +3 -4
  102. package/dist/types/update-tool-types.js +40 -18
  103. package/dist/types/user-tool-types.js +44 -1
  104. package/dist/types/video-walls-tool-types.js +30 -2
  105. package/dist/types/zod-schemas.js +5128 -996
  106. package/dist/util.js +62 -3
  107. package/dist/utils/entity-name-match.js +11 -0
  108. package/package.json +3 -1
@@ -79,6 +79,81 @@ export function constructRequestHeaders(url, modifiers, sessionId // kept for AP
79
79
  }
80
80
  return { url, requestHeaders };
81
81
  }
82
+ // Credential-bearing headers must never reach the log store (they were being
83
+ // written verbatim to OpenSearch on every outbound call, prod included). The
84
+ // session token also rides in the URL as `_rs`, so redact that too.
85
+ const SENSITIVE_HEADERS = new Set([
86
+ "x-auth-apikey",
87
+ "x-auth-access-token",
88
+ "x-auth-session",
89
+ "cookie",
90
+ ]);
91
+ function redactHeadersForLog(headers) {
92
+ const out = {};
93
+ for (const [key, value] of Object.entries(headers)) {
94
+ out[key] = SENSITIVE_HEADERS.has(key.toLowerCase()) ? `<set, ${value.length} chars>` : value;
95
+ }
96
+ return out;
97
+ }
98
+ function redactUrlForLog(url) {
99
+ return url.replace(/([?&]_rs=)[^&]+/g, "$1<redacted>");
100
+ }
101
+ // The `status` string on a failed result is read by tools and relayed to the
102
+ // model almost verbatim, so it has to be a short, readable sentence. It used to
103
+ // be `JSON.stringify({body: <the entire request payload>, error: <raw text>})`,
104
+ // which buried the one useful line in a copy of the request — and collapsed to
105
+ // the literally useless "Request Error: {}" whenever the thrown value was an
106
+ // Error (JSON.stringify of an Error yields "{}").
107
+ const API_ERROR_DETAIL_LIMIT = 400;
108
+ function truncateDetail(text) {
109
+ return text.length > API_ERROR_DETAIL_LIMIT
110
+ ? `${text.slice(0, API_ERROR_DETAIL_LIMIT)}…`
111
+ : text;
112
+ }
113
+ /** Pull the human-readable message out of an api2 error body (JSON or plain text). */
114
+ function extractApiErrorDetail(responseText) {
115
+ const text = responseText.trim();
116
+ if (!text)
117
+ return "";
118
+ try {
119
+ const parsed = JSON.parse(text);
120
+ if (parsed && typeof parsed === "object") {
121
+ // `msg` is what api2's request deserializer returns on a malformed body
122
+ // (e.g. {"msg":"JSON doesn't match expected object structure"}); without
123
+ // it here the whole JSON blob got dumped into the model-facing message.
124
+ for (const key of ["errorMsg", "message", "msg", "error", "status", "detail"]) {
125
+ const value = parsed[key];
126
+ if (typeof value === "string" && value.trim())
127
+ return truncateDetail(value.trim());
128
+ }
129
+ }
130
+ }
131
+ catch {
132
+ // not JSON — fall through to the raw text
133
+ }
134
+ return truncateDetail(text);
135
+ }
136
+ function describeHttpFailure(status, responseText) {
137
+ const detail = extractApiErrorDetail(responseText);
138
+ return detail
139
+ ? `HTTP ${status}: ${detail}`
140
+ : `HTTP ${status} from the Rhombus API (the response body carried no error message)`;
141
+ }
142
+ /** JSON.stringify(new Error(...)) === "{}", so unwrap thrown values by hand. */
143
+ function describeThrown(error) {
144
+ if (error instanceof Error) {
145
+ const cause = error.cause instanceof Error ? ` (cause: ${error.cause.message})` : "";
146
+ return `${error.name}: ${error.message}${cause}`;
147
+ }
148
+ if (typeof error === "string")
149
+ return error;
150
+ if (error && typeof error === "object") {
151
+ const serialized = JSON.stringify(error);
152
+ if (serialized && serialized !== "{}")
153
+ return truncateDetail(serialized);
154
+ }
155
+ return String(error ?? "unknown error");
156
+ }
82
157
  export async function postApi({ route, body, modifiers, sessionId, }) {
83
158
  let url = BASE_URL + route;
84
159
  const { url: newUrl, requestHeaders } = constructRequestHeaders(BASE_URL + route, modifiers, sessionId);
@@ -88,25 +163,26 @@ export async function postApi({ route, body, modifiers, sessionId, }) {
88
163
  body = JSON.stringify(body);
89
164
  }
90
165
  try {
91
- logger.info(`[POSTAPI] REQUEST - ${url} - ${body} - ${JSON.stringify(requestHeaders)}`);
166
+ logger.info(`[POSTAPI] REQUEST - ${redactUrlForLog(url)} - ${body} - ${JSON.stringify(redactHeadersForLog(requestHeaders))}`);
92
167
  const response = await fetch(url, {
93
168
  method: "POST",
94
169
  headers: requestHeaders,
95
170
  body,
96
171
  });
97
172
  if (!response.ok) {
98
- logger.debug(`❌ RESPONSE - ${response.ok} - ${response.status}`);
173
+ const responseText = await response.text().catch(() => "");
174
+ // The request body belongs in the log, not in the message the model reads.
175
+ logger.error(`[POSTAPI] HTTP ${response.status} - ${redactUrlForLog(url)} - request: ${body} - response: ${truncateDetail(responseText)}`);
99
176
  if (response.status === 401 || response.status === 403) {
100
177
  return {
101
178
  error: true,
102
179
  status: "Sorry, I don't have permission to help with this request. Consider upgrading my permissions by changing the role of the API Key I am using.",
103
180
  };
104
181
  }
105
- throw {
106
- body: JSON.parse(body),
107
- error: await response.text(),
182
+ return {
183
+ error: true,
184
+ status: describeHttpFailure(response.status, responseText),
108
185
  };
109
- // throw new Error(`HTTP error! status: ${response.status}`);
110
186
  }
111
187
  const ret = await response.json();
112
188
  const jsonStr = JSON.stringify(ret);
@@ -115,15 +191,51 @@ export async function postApi({ route, body, modifiers, sessionId, }) {
115
191
  return ret;
116
192
  }
117
193
  catch (error) {
118
- logger.error(`[POSTAPI] ERROR - ${JSON.stringify(error || {}, null, 4)}`);
194
+ logger.error(`[POSTAPI] ERROR - ${redactUrlForLog(url)} - ${error instanceof Error ? (error.stack ?? error.message) : describeThrown(error)}`);
119
195
  return {
120
196
  error: true,
121
- status: `Request Error: ${JSON.stringify(error)}`,
197
+ status: `Request failed before a response was received: ${describeThrown(error)}`,
122
198
  };
123
199
  }
124
200
  }
201
+ /**
202
+ * api2 signals failure on TWO channels, and reading either one alone loses the
203
+ * message on the other:
204
+ *
205
+ * - **Transport / HTTP** failures are synthesised by `postApi` above as
206
+ * `{error: true, status: "<sentence>"}`. `status` is our field, not api2's.
207
+ * - **Domain** failures come back on HTTP **200** with `{error: true,
208
+ * errorMsg: "..."}` — api2's own in-band contract (734 response types in
209
+ * `types/schema.ts` declare `error`, 732 declare `errorMsg`).
210
+ *
211
+ * So a handler that checks only `status` reports "API request failed." for every
212
+ * real api2 rejection ("that plate is already saved", "schedule not found"),
213
+ * leaving the model nothing to correct. Returns undefined when the call
214
+ * succeeded.
215
+ */
216
+ export function apiFailureMessage(res) {
217
+ if (!res.error)
218
+ return undefined;
219
+ const detail = res.status?.trim() || res.errorMsg?.trim();
220
+ return detail || "The Rhombus API rejected the request without giving a reason.";
221
+ }
222
+ /**
223
+ * api2's `warningMsg` — the "it worked, but" channel. A call can succeed while
224
+ * reporting that part of it did not apply, and that caveat has to reach the
225
+ * user.
226
+ *
227
+ * Takes `unknown` so any response shape can be checked without a cast at the
228
+ * call site — the caveat should never be dropped just to satisfy the compiler.
229
+ */
230
+ export function apiWarning(res) {
231
+ if (!res || typeof res !== "object")
232
+ return undefined;
233
+ const warning = res.warningMsg;
234
+ return typeof warning === "string" && warning.trim() ? warning.trim() : undefined;
235
+ }
125
236
  export function throwIfApiError(res) {
126
- if (res.error) {
127
- throw new Error(res.status ?? "API request failed.");
237
+ const message = apiFailureMessage(res);
238
+ if (message) {
239
+ throw new Error(message);
128
240
  }
129
241
  }
@@ -0,0 +1,74 @@
1
+ import { createHash } from "node:crypto";
2
+ import { requestAuthContext } from "../auth-context.js";
3
+ import { postApi } from "./network.js";
4
+ /**
5
+ * Short-TTL cache for slow-changing org-reference fetches (device state
6
+ * lists, locations, org info). Within a single chatbot query the model
7
+ * routinely calls several tools that each re-fetch the same org-wide lists
8
+ * (e.g. entity-lookup's 11-way fan-out plus get-entity-tool plus a camera
9
+ * tool); this collapses those into one upstream call per route.
10
+ *
11
+ * - Keyed on hash(auth identity + route + body + modifiers). The auth
12
+ * payload comes from requestAuthContext (AsyncLocalStorage) because
13
+ * `extra.sessionId` is undefined in the stateless HTTP transport; when no
14
+ * auth context is present we bypass the cache entirely rather than share
15
+ * entries across unknown callers.
16
+ * - The IN-FLIGHT promise is cached, so concurrent callers (Promise.all
17
+ * fan-outs) share one request. Failures are never cached: both a thrown
18
+ * rejection AND an api2 in-band failure (`{error: true}` on HTTP 200 — see
19
+ * apiFailureMessage) evict the entry. postApi resolves rather than throws on
20
+ * those, so without the second check a single transient upstream error would
21
+ * be replayed to every caller for the whole TTL.
22
+ * - TTL is deliberately short: staleness after a mutation (e.g. a camera
23
+ * rename) is bounded at ORG_REFERENCE_TTL_MS.
24
+ */
25
+ export const ORG_REFERENCE_TTL_MS = 60_000;
26
+ const MAX_ENTRIES = 500;
27
+ const cache = new Map();
28
+ function evictIfNeeded(now) {
29
+ if (cache.size <= MAX_ENTRIES)
30
+ return;
31
+ for (const [key, entry] of cache) {
32
+ if (entry.expiresAt <= now)
33
+ cache.delete(key);
34
+ }
35
+ // Still over cap (many live sessions): drop oldest-inserted first.
36
+ while (cache.size > MAX_ENTRIES) {
37
+ const oldest = cache.keys().next().value;
38
+ if (oldest === undefined)
39
+ break;
40
+ cache.delete(oldest);
41
+ }
42
+ }
43
+ /** Test hook / mutation hook: drop every cached entry. */
44
+ export function clearOrgReferenceCache() {
45
+ cache.clear();
46
+ }
47
+ /**
48
+ * Drop-in replacement for postApi on org-reference routes. Do NOT use for
49
+ * mutations or fast-changing data.
50
+ */
51
+ export function cachedPostApi(params, ttlMs = ORG_REFERENCE_TTL_MS) {
52
+ const auth = requestAuthContext.getStore();
53
+ if (!auth)
54
+ return postApi(params);
55
+ const key = createHash("sha256")
56
+ .update(JSON.stringify([auth, params.route, params.body ?? null, params.modifiers ?? null]))
57
+ .digest("hex");
58
+ const now = Date.now();
59
+ const hit = cache.get(key);
60
+ if (hit && hit.expiresAt > now)
61
+ return hit.promise;
62
+ const promise = postApi(params);
63
+ cache.set(key, { expiresAt: now + ttlMs, promise });
64
+ const evict = () => {
65
+ if (cache.get(key)?.promise === promise)
66
+ cache.delete(key);
67
+ };
68
+ promise.then(result => {
69
+ if (result && typeof result === "object" && result.error)
70
+ evict();
71
+ }, evict);
72
+ evictIfNeeded(now);
73
+ return promise;
74
+ }
@@ -36,6 +36,10 @@ async function attachSessionIdentity(span, extra) {
36
36
  span.setAttribute("enduser.id", identity.userId);
37
37
  if (identity?.orgUuid)
38
38
  span.setAttribute("mcp.org.uuid", identity.orgUuid);
39
+ // "SUPPORT" for support-authority sessions (enduser.id is then the
40
+ // authorityUuid, or the fixed "support-session" marker).
41
+ if (identity?.sessionType)
42
+ span.setAttribute("mcp.session.type", identity.sessionType);
39
43
  }
40
44
  catch (error) {
41
45
  logger.debug(`tracing: attachSessionIdentity failed: ${String(error)}`);
@@ -3,11 +3,13 @@ import { OUTPUT_SCHEMA, TOOL_ARGS } from "../types/entity-lookup-tool-types.js";
3
3
  import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
4
4
  const TOOL_NAME = "entity-lookup-tool";
5
5
  const TOOL_DESCRIPTION = `
6
- Retrieves specific entities (or devices) by their UUIDs.
7
- Takes a list of device UUIDs and returns the device information for those specific devices.
8
- Use this tool when the user asks for details on devices' states and details about their licenses and features.
6
+ Retrieves specific entities (or devices) by their UUIDs, including license and feature details.
7
+ Use this tool ONLY when you already have exact device UUIDs (e.g. from get-entity-tool, get-org-information, or an earlier tool result).
8
+
9
+ To look up a device by NAME, do NOT use this tool — use get-entity-tool with a filterBy name predicate instead. NEVER invent or guess placeholder UUIDs; an unknown UUID matches nothing and returns empty results.
10
+
11
+ Pass deviceUuids: null to return ALL entities of every type (no UUID filter) and narrow with filterBy/includeFields — note that license/feature details are only included when explicit UUIDs are provided.
9
12
  The return structure is a JSON object that contains the states of the requested entities.
10
- This data is exact. Only devices with matching UUIDs will be returned.
11
13
  `;
12
14
  const TOOL_HANDLER = async (args, extra) => {
13
15
  const { deviceUuids, timeZone, tempUnit } = args;
@@ -29,8 +31,10 @@ const TOOL_HANDLER = async (args, extra) => {
29
31
  };
30
32
  export function createTool(server) {
31
33
  server.registerTool(TOOL_NAME, {
34
+ title: "Entity Lookup",
32
35
  description: TOOL_DESCRIPTION,
33
36
  inputSchema: TOOL_ARGS,
34
37
  outputSchema: OUTPUT_SCHEMA.shape,
38
+ annotations: { readOnlyHint: true },
35
39
  }, TOOL_HANDLER);
36
40
  }
@@ -1,21 +1,39 @@
1
1
  import { getAccessControlledDoors, getAudioGateways, getBadgeReaders, getButtons, getCameraList, getDoorbellCameras, getDoorSensors, getEnvironmentalGateways, getEnvironmentalSensors, getKeypads, getMotionSensors, } from "../api/get-entity-tool-api.js";
2
2
  import DeviceType from "../types/deviceType.js";
3
- import { TOOL_ARGS } from "../types/get-entity-tool-types.js";
4
- import { createToolTextContent, extractFromToolExtra } from "../util.js";
3
+ import { OUTPUT_SCHEMA, TOOL_ARGS } from "../types/get-entity-tool-types.js";
4
+ import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
5
5
  const TOOL_NAME = "get-entity-tool";
6
6
  const TOOL_DESCRIPTION = `
7
7
  Retrieves entities (or devices) of certain types — cameras, doorbell cameras, badge readers, access-controlled doors, audio gateways, door sensors, environmental sensors, motion sensors, buttons, keypads, environmental gateways. Can request multiple entity types at once. The return structure is a JSON string that contains the states (including names, UUIDs, location, model, firmware, connection status) of the requested entities. This data is exact.
8
8
 
9
9
  **Primary use cases:**
10
- 1. **Looking up a device by name.** When the user mentions a specific camera, door, sensor, etc. by name (e.g. "describe camera 1919 Front Door Entrance", "what's the status of HW Lab door"), call this tool with the matching entityType, scan the returned list, and **fuzzy/case-insensitive substring match** the user's reference against the \`name\` field. Don't ask the user to clarify — try this lookup first, and only ask if there are genuinely multiple plausible matches in the results.
11
- 2. **Listing all devices of a type** (cameras, doors, sensors, etc.) for a location or org-wide.
12
- 3. **Checking device health and connectivity.** Each device includes a \`connected\` boolean (true = online, false = offline). For "which devices are offline?" / "is X online?" / health questions, fetch the relevant entityTypes and inspect \`connected\`.
10
+ 1. **Looking up a device by name.** When the user mentions a specific camera, door, sensor, etc. by name (e.g. "describe camera 1919 Front Door Entrance", "what's the status of HW Lab door"), call this tool with the matching entityType, scan the returned list, and **fuzzy/case-insensitive substring match** the user's reference against the \`name\` field. Don't ask the user to clarify — try this lookup first, and only ask if there are genuinely multiple plausible matches in the results. To describe one device in depth (model, firmware, serial, network), pass \`detail: "full"\` together with a \`filterBy\` name predicate so only that device comes back at full size.
11
+ 2. **Listing all devices of a type** (cameras, doors, sensors, etc.) for a location or org-wide. The default \`detail: "core"\` keeps lists compact (uuid, name, connection/health status, location, associations).
12
+ 3. **Checking device health and connectivity.** Devices with a connection state include a \`connected\` boolean (true = online, false = offline). For "how many devices are offline?" / "which are offline?", pass \`filterBy: [{"field": "connected", "op": "=", "value": false}]\` — the returned list and its \`<type>Count\` sibling then reflect exactly the offline devices, so the count needs no manual tallying. For per-group questions ("per location", "which location has the most offline"), add \`groupBy: "locationUuid"\` to get exact server-computed counts per group — never tally rows yourself.
13
13
 
14
14
  When the user asks to "describe", "look up", "find", "show me", or "tell me about" a named device, this is almost always the right starting tool — call it before asking the user for more specifics.`;
15
+ // Fields kept per device when detail is "core" (the default) — the union of
16
+ // the identifying/health/association fields across every entity type. Fields a
17
+ // type doesn't have are simply absent. "full" skips the projection entirely.
18
+ const CORE_FIELDS = new Set([
19
+ "uuid",
20
+ "name",
21
+ "connectionStatus",
22
+ "connected",
23
+ "healthStatus",
24
+ "healthStatusDetails",
25
+ "locationUuid",
26
+ "floorNumber",
27
+ "temperature",
28
+ "humidity",
29
+ "batteryStatus",
30
+ "associatedCameras",
31
+ "policyUuid",
32
+ "remoteUnlockEnabled",
33
+ "geofenceEnabled",
34
+ ]);
15
35
  const TOOL_HANDLER = async (args, extra) => {
16
- const { entityTypes, timeZone, tempUnit } = args;
17
- const filterBy = args.filterBy ?? { locationUuids: null };
18
- const locationFilter = filterBy.locationUuids;
36
+ const { entityTypes, timeZone, tempUnit, detail } = args;
19
37
  const { requestModifiers, sessionId } = extractFromToolExtra(extra);
20
38
  const promises = [];
21
39
  if (entityTypes.includes(DeviceType.CAMERA)) {
@@ -59,17 +77,18 @@ const TOOL_HANDLER = async (args, extra) => {
59
77
  for (const key of Object.keys(response)) {
60
78
  const value = response[key];
61
79
  if (Array.isArray(value)) {
62
- // then filter
63
- response[key] = value.filter((item) => {
64
- let pass = true;
65
- if (item.locationUuid &&
66
- locationFilter &&
67
- locationFilter.length > 0) {
68
- pass = pass && locationFilter.includes(item.locationUuid);
69
- }
70
- return pass;
71
- });
72
- response[`${key}Count`] = response[key].length;
80
+ // The upstream states only carry a 4-color connectionStatus
81
+ // (RED = disconnected). Derive the uniform `connected` boolean the
82
+ // description promises so "offline" questions are a filterBy, not a
83
+ // model-side tally over the raw statuses.
84
+ let items = value.map((item) => item && typeof item === "object" && "connectionStatus" in item && !("connected" in item)
85
+ ? { ...item, connected: item.connectionStatus !== "RED" }
86
+ : item);
87
+ if (detail !== "full") {
88
+ items = items.map((item) => Object.fromEntries(Object.entries(item).filter(([field]) => CORE_FIELDS.has(field))));
89
+ }
90
+ response[key] = items;
91
+ response[`${key}Count`] = items.length;
73
92
  }
74
93
  }
75
94
  }
@@ -79,8 +98,14 @@ const TOOL_HANDLER = async (args, extra) => {
79
98
  ...curr,
80
99
  }), {}),
81
100
  };
82
- return createToolTextContent(JSON.stringify(ret));
101
+ return createToolStructuredContent(ret);
83
102
  };
84
103
  export function createTool(server) {
85
- server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_ARGS, TOOL_HANDLER);
104
+ server.registerTool(TOOL_NAME, {
105
+ title: "Get Entities",
106
+ description: TOOL_DESCRIPTION,
107
+ inputSchema: TOOL_ARGS,
108
+ outputSchema: OUTPUT_SCHEMA.shape,
109
+ annotations: { readOnlyHint: true },
110
+ }, TOOL_HANDLER);
86
111
  }
@@ -1,18 +1,51 @@
1
+ import { z } from "zod";
1
2
  import { getOrg } from "../api/get-org-information-tool-api.js";
2
3
  import { TOOL_ARGS } from "../types/get-org-information-tool-types.js";
3
4
  const TOOL_NAME = "get-org-information";
4
5
  const TOOL_DESCRIPTION = "Get general information about the organization including org name, camera configuration defaults, contact information, and org settings.";
5
6
  const TOOL_HANDLER = async (_, extra) => {
6
- const org = await getOrg(extra._meta?.requestModifiers, extra.sessionId);
7
+ const response = await getOrg(extra._meta?.requestModifiers, extra.sessionId);
8
+ if (response?.error) {
9
+ return {
10
+ isError: true,
11
+ content: [
12
+ {
13
+ type: "text",
14
+ text: `Failed to fetch organization information: ${response.errorMsg ?? response.status ?? "unknown error"}`,
15
+ },
16
+ ],
17
+ };
18
+ }
7
19
  return {
8
20
  content: [
9
21
  {
10
22
  type: "text",
11
- text: JSON.stringify(org),
23
+ text: JSON.stringify(response),
12
24
  },
13
25
  ],
26
+ structuredContent: response,
14
27
  };
15
28
  };
16
29
  export function createTool(server) {
17
- server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_ARGS, TOOL_HANDLER);
30
+ server.registerTool(TOOL_NAME, {
31
+ title: "Organization Information",
32
+ description: TOOL_DESCRIPTION,
33
+ inputSchema: TOOL_ARGS,
34
+ // /org/getOrgV2's org record is free-form (contact info, defaults,
35
+ // settings), so it is declared as an open record rather than enumerated —
36
+ // field paths like "org.name" / "org.uuid" still project fine.
37
+ outputSchema: {
38
+ org: z
39
+ .record(z.string(), z.unknown())
40
+ .optional()
41
+ .describe("The organization record: name, uuid, contact information, camera configuration defaults, org settings. Project with paths like \"org.name\", \"org.uuid\"."),
42
+ cameraConfigDefaults: z.record(z.string(), z.unknown()).nullable().optional(),
43
+ cameraConfigOptions: z.record(z.string(), z.unknown()).nullable().optional(),
44
+ featureFlags: z.record(z.string(), z.string().nullable()).nullable().optional(),
45
+ error: z.boolean().nullable().optional(),
46
+ errorMsg: z.string().nullable().optional(),
47
+ warningMsg: z.string().nullable().optional(),
48
+ },
49
+ annotations: { readOnlyHint: true },
50
+ }, TOOL_HANDLER);
18
51
  }
@@ -1,43 +1,71 @@
1
+ import { z } from "zod";
1
2
  import { TOOL_ARGS } from "../types/time-conversion-tool-types.js";
2
3
  const TOOL_NAME = "time-conversion-tool";
3
- const TOOL_DESCRIPTION = "This tool is capable of converting to and from epoch and ISO 8601 timestamps. Use this tool when you have one time format and need to convert to the other format. The tool will automatically detect the input format and convert to the other format.";
4
- const TOOL_HANDLER = async (args, extra) => {
5
- const { time_to_convert } = args;
6
- if (typeof time_to_convert === "string") {
7
- // Input is ISO 8601, convert to epoch
8
- const date = new Date(time_to_convert);
9
- return {
10
- content: [
11
- {
12
- type: "text",
13
- text: JSON.stringify({ epoch: date.getTime() }),
14
- },
15
- ],
16
- };
17
- }
18
- else if (typeof time_to_convert === "number") {
19
- // Input is epoch, convert to ISO 8601
20
- const date = new Date(time_to_convert);
21
- return {
22
- content: [
23
- {
24
- type: "text",
25
- text: JSON.stringify({ iso: date.toISOString() }),
26
- },
27
- ],
28
- };
29
- }
30
- else {
31
- return {
32
- content: [
33
- {
34
- type: "text",
35
- text: JSON.stringify({ error: "Invalid input type for time_to_convert" }),
36
- },
37
- ],
38
- };
4
+ const TOOL_DESCRIPTION = `This tool converts between epoch (Unix milliseconds) and ISO 8601 timestamps, auto-detecting the input format: an ISO string returns epoch ms, a number returns the ISO string.
5
+
6
+ This is the most accurate way to convert timestamps — do NOT convert timestamps yourself.
7
+
8
+ Accepts a single value or a comma-separated list (results returned in the same order). If a timestamp is timezoned, include the original UTC offset (e.g. '2025-03-05T09:00:00-08:00') rather than converting to UTC yourself.
9
+
10
+ Example: "2025-03-05T09:00:00-08:00,1741190400000" returns the epoch ms of the first and the ISO string of the second.`;
11
+ /** Convert one trimmed value, auto-detecting ISO vs epoch. */
12
+ function convertOne(value) {
13
+ // Numbers (or all-digit strings from a comma-separated batch) are epochs.
14
+ const asNumber = typeof value === "number" ? value : /^-?\d+$/.test(value) ? Number(value) : null;
15
+ if (asNumber !== null) {
16
+ const date = new Date(asNumber);
17
+ if (Number.isNaN(date.getTime()))
18
+ return { input: value, error: "Invalid epoch timestamp" };
19
+ return { input: value, iso: date.toISOString() };
39
20
  }
21
+ const date = new Date(value);
22
+ if (Number.isNaN(date.getTime()))
23
+ return { input: value, error: "Unparseable ISO 8601 timestamp" };
24
+ return { input: value, epoch: date.getTime() };
25
+ }
26
+ const TOOL_HANDLER = async (args, _extra) => {
27
+ const { time_to_convert } = args;
28
+ const values = typeof time_to_convert === "string" && time_to_convert.includes(",")
29
+ ? time_to_convert.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
30
+ : [time_to_convert];
31
+ const results = values.map(convertOne);
32
+ // Preserve the original single-value shapes ({epoch} / {iso}) for compatibility.
33
+ const payload = results.length === 1
34
+ ? results[0].error
35
+ ? { error: results[0].error }
36
+ : results[0].epoch !== undefined
37
+ ? { epoch: results[0].epoch }
38
+ : { iso: results[0].iso }
39
+ : { results };
40
+ return {
41
+ content: [
42
+ {
43
+ type: "text",
44
+ text: JSON.stringify(payload),
45
+ },
46
+ ],
47
+ structuredContent: payload,
48
+ };
40
49
  };
50
+ const CONVERSION_RESULT = z.object({
51
+ input: z.union([z.string(), z.number()]).optional().describe("The value as passed in"),
52
+ epoch: z.number().optional().describe("Epoch milliseconds (present when the input was ISO)"),
53
+ iso: z.string().optional().describe("ISO 8601 string (present when the input was an epoch)"),
54
+ error: z.string().optional().describe("Why this value could not be converted"),
55
+ });
41
56
  export function createTool(server) {
42
- server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_ARGS, TOOL_HANDLER);
57
+ server.registerTool(TOOL_NAME, {
58
+ title: "Time Conversion",
59
+ description: TOOL_DESCRIPTION,
60
+ inputSchema: TOOL_ARGS,
61
+ // Single-value calls keep their legacy flat shapes ({epoch} / {iso} /
62
+ // {error}); comma-separated batches return {results: [...]}.
63
+ outputSchema: {
64
+ epoch: z.number().optional(),
65
+ iso: z.string().optional(),
66
+ error: z.string().optional(),
67
+ results: z.array(CONVERSION_RESULT).optional(),
68
+ },
69
+ annotations: { readOnlyHint: true },
70
+ }, TOOL_HANDLER);
43
71
  }
@@ -1,13 +1,16 @@
1
+ import { z } from "zod";
1
2
  import { parseTimeDescription } from "../api/time-tool-api.js";
2
3
  import { TOOL_ARGS } from "../types/time-tool-types.js";
3
4
  const TOOL_NAME = "time-tool";
4
- const TOOL_DESCRIPTION = `This tool returns timestamps from natural-language descriptions of time. If the user asks about the "current time", use this tool. Keep time_description as close to the user's original phrasing as possible e.g. for "was X person seen today?" use time_description "today".
5
+ const TOOL_DESCRIPTION = `This tool converts a natural-language description of time ("today", "5 days ago", "last week", "this morning") into concrete timestamps. Use it when you need a timestamp as input to another tool, or when the user explicitly asks for the current time. Keep time_description as close to the user's original phrasing as possible.
5
6
 
6
- **When to call:** Whenever the user provides a natural-language time description ("today", "5 days ago", "last week", "this morning"), call time-tool to get accurate timestamps. Do not invent timestamps yourself. If you will need a timestamp as input to another tool, call time-tool first; multiple parallel calls are fine.
7
+ **Call it at most once per distinct time reference.** The current time does not change during this conversation once you have retrieved "now"/"today", reuse that result and reason about other relative dates from it. Do NOT call time-tool again for a time reference you already resolved, and do not call it at all for questions that have no time component.
7
8
 
8
- **Default timezone:** Assume "America/Los_Angeles" unless the user specifies otherwise or device/location context indicates a different one.`;
9
+ **Timezone:** Pass the timezone of the relevant device/location when known; otherwise a timezone the user stated; otherwise UTC.`;
9
10
  const TOOL_HANDLER = async (args, extra) => {
10
11
  const { time_description, timezone } = args;
12
+ // parseTimeDescription throws on an unparseable description; the SDK turns a
13
+ // thrown handler error into an isError result, which skips output validation.
11
14
  const result = parseTimeDescription(time_description ?? undefined, timezone ?? undefined, extra);
12
15
  return {
13
16
  content: [
@@ -16,8 +19,19 @@ const TOOL_HANDLER = async (args, extra) => {
16
19
  text: JSON.stringify(result),
17
20
  },
18
21
  ],
22
+ structuredContent: result,
19
23
  };
20
24
  };
21
25
  export function createTool(server) {
22
- server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_ARGS, TOOL_HANDLER);
26
+ server.registerTool(TOOL_NAME, {
27
+ title: "Current Time",
28
+ description: TOOL_DESCRIPTION,
29
+ inputSchema: TOOL_ARGS,
30
+ outputSchema: {
31
+ timestamp: z.number().optional().describe("Epoch milliseconds of the resolved time"),
32
+ iso: z.string().nullable().optional().describe("ISO 8601 form of the resolved time"),
33
+ timezone: z.string().nullable().optional().describe("IANA timezone the time was resolved in"),
34
+ },
35
+ annotations: { readOnlyHint: true },
36
+ }, TOOL_HANDLER);
23
37
  }