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.
- 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 +8 -4
- package/dist/tools/get-entity-tool.js +46 -21
- package/dist/tools/get-org-information-tool.js +36 -3
- package/dist/tools/time-conversion-tool.js +65 -37
- package/dist/tools/time-tool.js +18 -4
- package/dist/tools/user-tool.js +101 -1
- package/dist/tools-console/access-anomaly-tool.js +4 -19
- package/dist/tools-console/access-control-tool.js +330 -2
- package/dist/tools-console/alarm-monitoring-tool.js +73 -1
- package/dist/tools-console/analytics-tool.js +4 -2
- package/dist/tools-console/automated-prompts-tool.js +2 -0
- package/dist/tools-console/badge-timeline-tool.js +4 -21
- package/dist/tools-console/camera-policy-tool.js +252 -0
- package/dist/tools-console/camera-tool.js +113 -89
- package/dist/tools-console/camera-uptime-tool.js +2 -0
- package/dist/tools-console/clips-tool.js +23 -31
- package/dist/tools-console/count-tool.js +19 -6
- package/dist/tools-console/create-camera-policy-tool.js +304 -122
- package/dist/tools-console/door-schedule-exception-tool.js +4 -33
- package/dist/tools-console/door-tool.js +131 -1
- package/dist/tools-console/elements-access-anomaly-tool.js +4 -19
- package/dist/tools-console/elements-badge-timeline-tool.js +4 -21
- package/dist/tools-console/elements-lost-badge-tool.js +4 -17
- package/dist/tools-console/elements-tool.js +4 -29
- package/dist/tools-console/events-tool.js +24 -101
- package/dist/tools-console/faces-tool.js +255 -27
- package/dist/tools-console/guest-management-tool.js +128 -1
- package/dist/tools-console/location-tool.js +161 -35
- package/dist/tools-console/lost-badge-tool.js +4 -17
- package/dist/tools-console/lpr-tool.js +90 -22
- package/dist/tools-console/netbox-access-anomaly-tool.js +4 -19
- package/dist/tools-console/netbox-badge-timeline-tool.js +4 -21
- package/dist/tools-console/netbox-lost-badge-tool.js +4 -17
- package/dist/tools-console/netbox-tool.js +4 -29
- package/dist/tools-console/onguard-tool.js +4 -30
- package/dist/tools-console/person-tracking-tool.js +4 -2
- package/dist/tools-console/policy-alerts-tool.js +6 -28
- package/dist/tools-console/reboot-cameras-tool.js +23 -3
- package/dist/tools-console/report-tool.js +12 -40
- package/dist/tools-console/rules-tool.js +2 -0
- package/dist/tools-console/schedule-tool.js +190 -0
- package/dist/tools-console/search-tool.js +2 -0
- package/dist/tools-console/update-tool.js +300 -168
- package/dist/tools-console/user-access-trail-tool.js +2 -0
- package/dist/tools-console/user-audit-tool.js +2 -0
- package/dist/tools-console/video-walls-tool.js +94 -20
- 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/network/network.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 - ${
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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 {
|
|
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.**
|
|
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
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
response[
|
|
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
|
|
101
|
+
return createToolStructuredContent(ret);
|
|
83
102
|
};
|
|
84
103
|
export function createTool(server) {
|
|
85
|
-
server.
|
|
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
|
|
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(
|
|
23
|
+
text: JSON.stringify(response),
|
|
12
24
|
},
|
|
13
25
|
],
|
|
26
|
+
structuredContent: response,
|
|
14
27
|
};
|
|
15
28
|
};
|
|
16
29
|
export function createTool(server) {
|
|
17
|
-
server.
|
|
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 =
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
|
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
|
}
|
package/dist/tools/time-tool.js
CHANGED
|
@@ -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
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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.
|
|
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
|
}
|