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
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { changePersonLabel, createPerson, deleteFaceMatchmaker, deletePerson, findPerson, getFaceEvents, getFaceEventsByPerson, getFaceMatchmakers, getPersonLabels, getRegisteredFaces, searchSimilarFaces, updatePerson, } from "../api/faces-tool-api.js";
|
|
2
3
|
import { logger } from "../logger.js";
|
|
3
4
|
import { OUTPUT_SCHEMA, RequestType, TOOL_ARGS, } from "../types/faces-tools-types.js";
|
|
5
|
+
import { protectFields } from "../filtering-utils.js";
|
|
4
6
|
import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
|
|
5
7
|
const TOOL_NAME = "faces-tool";
|
|
6
8
|
/**
|
|
@@ -39,35 +41,90 @@ function resolveNamesToRegisteredFaces(userNames, registeredPeople) {
|
|
|
39
41
|
};
|
|
40
42
|
});
|
|
41
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Roll a page of face events up into a distinct-person roster.
|
|
46
|
+
*
|
|
47
|
+
* Enumerating "who was seen" from a 200-event page is exactly the operation the
|
|
48
|
+
* response model does badly: the raw page is well over the chatbot's compaction
|
|
49
|
+
* threshold, so the model never sees it directly, and when it reads the compacted
|
|
50
|
+
* form it tends to answer from whatever chunk arrived last. Doing the dedupe here
|
|
51
|
+
* means the answer is a field lookup rather than a 200-row scan.
|
|
52
|
+
*/
|
|
53
|
+
function summarizeFaceEvents(faceEvents, morePagesAvailable) {
|
|
54
|
+
const byName = new Map();
|
|
55
|
+
let unnamedEvents = 0;
|
|
56
|
+
for (const event of faceEvents) {
|
|
57
|
+
const name = event.faceName?.trim();
|
|
58
|
+
if (!name) {
|
|
59
|
+
unnamedEvents++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const ms = event.eventTimestampMs ?? 0;
|
|
63
|
+
const label = event.eventTimestamp ?? String(ms);
|
|
64
|
+
const existing = byName.get(name);
|
|
65
|
+
if (!existing) {
|
|
66
|
+
byName.set(name, { name, eventCount: 1, firstMs: ms, lastMs: ms, firstSeen: label, lastSeen: label });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
existing.eventCount++;
|
|
70
|
+
if (ms < existing.firstMs) {
|
|
71
|
+
existing.firstMs = ms;
|
|
72
|
+
existing.firstSeen = label;
|
|
73
|
+
}
|
|
74
|
+
if (ms > existing.lastMs) {
|
|
75
|
+
existing.lastMs = ms;
|
|
76
|
+
existing.lastSeen = label;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const identifiedPeople = [...byName.values()]
|
|
80
|
+
// Most-seen first, then alphabetical so the ordering is stable across pages.
|
|
81
|
+
.sort((a, b) => b.eventCount - a.eventCount || a.name.localeCompare(b.name))
|
|
82
|
+
.map(({ name, eventCount, firstSeen, lastSeen }) => ({ name, eventCount, firstSeen, lastSeen }));
|
|
83
|
+
return {
|
|
84
|
+
totalEventsThisPage: faceEvents.length,
|
|
85
|
+
namedEvents: faceEvents.length - unnamedEvents,
|
|
86
|
+
unnamedEvents,
|
|
87
|
+
identifiedPeople,
|
|
88
|
+
morePagesAvailable,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
42
91
|
const TOOL_DESCRIPTION = `
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
If the user is asking about how many people were seen (head count / occupancy), use the report-tool with GET_OCCUPANCY_ENABLED_CAMERAS and GET_OCCUPANCY_COUNT_REPORT instead. This tool (faces-tool) is best for identifying *who* was seen (unique individuals by name), and its face count data is also automatically included in report-tool people-counting responses via the faceCountEnrichment field.
|
|
92
|
+
Rhombus **face recognition**: face sightings and registered faces. Use it to identify *who* was seen (unique individuals by name) — "who was in the office", "who was seen today" — to list the people registered in the org, or to work with person label groups (e.g. "Engineering", "Visitors").
|
|
46
93
|
|
|
47
|
-
**
|
|
94
|
+
**Head counts are a different tool:** if the user asks how MANY people were seen (head count / occupancy), use report-tool with GET_OCCUPANCY_ENABLED_CAMERAS and GET_OCCUPANCY_COUNT_REPORT instead. Its people-counting responses already carry this tool's unique-face data in the faceCountEnrichment field.
|
|
48
95
|
|
|
49
|
-
|
|
50
|
-
- Use this tool to answer questions about face sightings, including questions like "who was in the office" or "who was seen today". Can be used for reporting, to generate a report on who was seen by the camera system.
|
|
51
|
-
- **Automatic name resolution:** You can pass partial or first-name-only names in faceNames (e.g., "Brandon", "Omar"). The tool automatically looks up the registered faces directory and resolves them to exact names and person UUIDs before searching. Check the "resolvedNames" field in the response to see what each queried name was matched to (null means no match found).
|
|
52
|
-
- You can filter face events using parameters like 'faceNames', 'hasEmbedding', 'hasName', 'labels', 'locationUuids', 'personUuids', and a time range using 'rangeStart' and 'rangeEnd' (timestamps in milliseconds).
|
|
53
|
-
- If you'd like to know about all face events at a location, pass in a location UUID and no device UUIDs. This will correctly return all face events at that location.
|
|
54
|
-
- When the user asks about a specific person at a location (e.g. "Jane Doe at Main Office"), call get-registered-faces first to get the list of registered names, find the best match, then call get-face-events with that precise name. The tool expects precise names as stored in the system.
|
|
55
|
-
- When querying faces at a location, pass only the location UUID in searchFilter; do not pass device UUIDs in searchFilter.deviceUuids, so the API returns all faces detected at that location.
|
|
96
|
+
**Important for person-presence questions:** when asked whether specific people were seen or are present, you should ALSO check badge-in records for the same time range — the vendor badge tools (onguard-events-tool / elements-events-tool / netbox-events-tool; call all three in parallel, each returns empty when not configured) and events-tool with eventType "access-control" (native Rhombus doors) or "brivo-access-control" (Brivo). Face recognition and access control are complementary: someone may badge in without face recognition triggering, or be seen by a camera without badging in.
|
|
56
97
|
|
|
57
|
-
|
|
58
|
-
- This tool retrieves a list of all people (registered faces) currently known to the Rhombus system for your organization. This list includes information about each registered person, including their assigned labels.
|
|
59
|
-
- This returns ALL people registered in the system, regardless of the provided timestampFilter.
|
|
60
|
-
- Each person in the response includes a "labels" array showing which label groups they belong to (e.g., "Engineering", "Visitors"). Use these labels to answer questions about groups of people.
|
|
61
|
-
|
|
62
|
-
If the requestType is "get-person-labels":
|
|
63
|
-
- This retrieves a mapping of all person UUIDs to their assigned labels across the organization.
|
|
64
|
-
- Use this to discover what label groups exist and which registered faces belong to each group.
|
|
65
|
-
- Useful when the user asks about a group (e.g., "was anyone from Engineering seen today?") — get the labels first, find the person UUIDs for that label, then query face events filtered by those personUuids or labels.
|
|
98
|
+
Per-requestType behaviour — automatic name resolution, the available filters, and how to query a whole location — is documented on the requestType parameter.
|
|
66
99
|
`;
|
|
67
100
|
const TOOL_HANDLER = async (args, extra) => {
|
|
68
101
|
const { requestModifiers, sessionId } = extractFromToolExtra(extra);
|
|
69
102
|
if (args.requestType === RequestType.GET_FACE_EVENTS) {
|
|
70
|
-
|
|
103
|
+
// Accept the misplaced top-level filter seen in production without ever
|
|
104
|
+
// dropping its person/location/time constraints or silently broadening a query.
|
|
105
|
+
const nestedFilter = args.faceEventFilter?.searchFilter;
|
|
106
|
+
if (nestedFilter && args.searchFilter && !isDeepStrictEqual(nestedFilter, args.searchFilter)) {
|
|
107
|
+
return {
|
|
108
|
+
isError: true,
|
|
109
|
+
...createToolStructuredContent({
|
|
110
|
+
requestType: args.requestType,
|
|
111
|
+
error: "Conflicting searchFilter and faceEventFilter.searchFilter. No search was performed. Retry once with the intended filter inside faceEventFilter.searchFilter only.",
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (nestedFilter === undefined && args.searchFilter == null) {
|
|
116
|
+
return {
|
|
117
|
+
isError: true,
|
|
118
|
+
...createToolStructuredContent({
|
|
119
|
+
requestType: args.requestType,
|
|
120
|
+
error: "get-face-events requires faceEventFilter.searchFilter. No search was performed. Retry once with {faceEventFilter: {pageRequest: null, searchFilter: {...}}}; use an explicit null searchFilter only for an unfiltered search.",
|
|
121
|
+
}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const faceEventArgs = {
|
|
125
|
+
pageRequest: args.faceEventFilter?.pageRequest ?? null,
|
|
126
|
+
searchFilter: nestedFilter ?? args.searchFilter ?? null,
|
|
127
|
+
};
|
|
71
128
|
let resolvedNamesOutput;
|
|
72
129
|
const providedNames = faceEventArgs.searchFilter?.faceNames ?? [];
|
|
73
130
|
if (providedNames.length > 0 && faceEventArgs.searchFilter) {
|
|
@@ -103,10 +160,11 @@ const TOOL_HANDLER = async (args, extra) => {
|
|
|
103
160
|
}
|
|
104
161
|
}
|
|
105
162
|
let { faceEvents, lastEvaluatedKey } = await getFaceEvents(faceEventArgs, args.timeZone, requestModifiers, sessionId);
|
|
106
|
-
const
|
|
107
|
-
|
|
163
|
+
const requestedLocationUuids = faceEventArgs.searchFilter?.locationUuids ?? [];
|
|
164
|
+
const hadLocationFilter = requestedLocationUuids.length > 0;
|
|
165
|
+
let note;
|
|
108
166
|
if (faceEvents.length === 0 && hadLocationFilter) {
|
|
109
|
-
logger.info(`[faces-tool] Empty results with locationUuids filter ${JSON.stringify(
|
|
167
|
+
logger.info(`[faces-tool] Empty results with locationUuids filter ${JSON.stringify(requestedLocationUuids)}, retrying without location filter`);
|
|
110
168
|
const retryArgs = {
|
|
111
169
|
...faceEventArgs,
|
|
112
170
|
searchFilter: { ...faceEventArgs.searchFilter, locationUuids: [] },
|
|
@@ -114,12 +172,29 @@ const TOOL_HANDLER = async (args, extra) => {
|
|
|
114
172
|
const retry = await getFaceEvents(retryArgs, args.timeZone, requestModifiers, sessionId);
|
|
115
173
|
faceEvents = retry.faceEvents;
|
|
116
174
|
lastEvaluatedKey = retry.lastEvaluatedKey;
|
|
175
|
+
// Without this the caller cannot tell a location-scoped result from an
|
|
176
|
+
// org-wide one, and reports org-wide sightings as having happened at the
|
|
177
|
+
// requested location (observed in prod, 2026-08-03).
|
|
178
|
+
if (faceEvents.length > 0) {
|
|
179
|
+
note =
|
|
180
|
+
`SCOPE CHANGED: no face events were found at the requested location(s) ` +
|
|
181
|
+
`[${requestedLocationUuids.join(", ")}], so this search was re-run ACROSS THE WHOLE ORG. ` +
|
|
182
|
+
`The events below are NOT limited to those locations — check each event's locationUuid before ` +
|
|
183
|
+
`attributing a sighting to the location the user asked about, and tell the user the scope was widened.`;
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
note =
|
|
187
|
+
`No face events at the requested location(s) [${requestedLocationUuids.join(", ")}], ` +
|
|
188
|
+
`and none org-wide for this time range either.`;
|
|
189
|
+
}
|
|
117
190
|
}
|
|
118
191
|
return createToolStructuredContent({
|
|
119
192
|
requestType: RequestType.GET_FACE_EVENTS,
|
|
120
193
|
getFaceEventsResponse: faceEvents,
|
|
194
|
+
faceEventSummary: summarizeFaceEvents(faceEvents, Boolean(lastEvaluatedKey)),
|
|
121
195
|
lastEvaluatedKey: lastEvaluatedKey ?? undefined,
|
|
122
196
|
resolvedNames: resolvedNamesOutput ?? undefined,
|
|
197
|
+
note,
|
|
123
198
|
});
|
|
124
199
|
}
|
|
125
200
|
if (args.requestType === RequestType.GET_REGISTERED_FACES) {
|
|
@@ -134,6 +209,10 @@ const TOOL_HANDLER = async (args, extra) => {
|
|
|
134
209
|
getSavedFacesResponse: peopleResponse.people.map(p => ({
|
|
135
210
|
createdOn: p.createdOn ? parseInt(p.createdOn, 10) : undefined,
|
|
136
211
|
name: p.name ?? undefined,
|
|
212
|
+
// As of 2026-08-05 api2 never serializes Person.email, so this is
|
|
213
|
+
// always undefined today — mapped anyway so it surfaces for free if
|
|
214
|
+
// the API starts returning it.
|
|
215
|
+
email: p.email ?? undefined,
|
|
137
216
|
orgUuid: p.orgUuid ?? undefined,
|
|
138
217
|
updatedOn: p.updatedOn ? parseInt(p.updatedOn, 10) : undefined,
|
|
139
218
|
uuid: p.uuid ?? undefined,
|
|
@@ -161,6 +240,11 @@ const TOOL_HANDLER = async (args, extra) => {
|
|
|
161
240
|
return createToolStructuredContent({
|
|
162
241
|
requestType: RequestType.GET_PERSON_LABELS,
|
|
163
242
|
getPersonLabelsResponse: cleaned,
|
|
243
|
+
// A bare {} reads as "the lookup returned nothing" — say what the empty
|
|
244
|
+
// result actually means so the model doesn't have to interpret it.
|
|
245
|
+
note: Object.keys(cleaned).length === 0
|
|
246
|
+
? "This organization has no person labels at all: the query succeeded and every registered person currently has zero labels."
|
|
247
|
+
: undefined,
|
|
164
248
|
});
|
|
165
249
|
}
|
|
166
250
|
if (args.requestType === RequestType.SEARCH_SIMILAR_FACES) {
|
|
@@ -196,14 +280,158 @@ const TOOL_HANDLER = async (args, extra) => {
|
|
|
196
280
|
personFaceEvents: personEvents,
|
|
197
281
|
});
|
|
198
282
|
}
|
|
283
|
+
// -------------------------------------------------------------------------
|
|
284
|
+
// Person write paths
|
|
285
|
+
//
|
|
286
|
+
// These act on enrolled biometric records, so every branch verifies the person
|
|
287
|
+
// exists first: a plausible-but-wrong personUuid would otherwise rename or
|
|
288
|
+
// delete someone else's face record with a success response either way.
|
|
289
|
+
// -------------------------------------------------------------------------
|
|
290
|
+
if (args.requestType === RequestType.CREATE_PERSON) {
|
|
291
|
+
if (!args.personName?.trim()) {
|
|
292
|
+
return createToolStructuredContent({
|
|
293
|
+
requestType: args.requestType,
|
|
294
|
+
error: "personName is required for create-person",
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
const created = await createPerson(args.personName.trim(), requestModifiers, sessionId);
|
|
298
|
+
return createToolStructuredContent({
|
|
299
|
+
requestType: args.requestType,
|
|
300
|
+
created: { success: created.success, uuid: created.uuid },
|
|
301
|
+
warningMsg: created.warningMsg,
|
|
302
|
+
note: `Created the person "${args.personName.trim()}". They have no enrolled face images yet, so face recognition will NOT identify them until a photo is enrolled — that has to be done in the Rhombus Console.`,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
if (args.requestType === RequestType.UPDATE_PERSON) {
|
|
306
|
+
if (!args.personUuid) {
|
|
307
|
+
return createToolStructuredContent({
|
|
308
|
+
requestType: args.requestType,
|
|
309
|
+
error: "personUuid is required for update-person",
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (!args.personName?.trim() && !args.personEmail?.trim()) {
|
|
313
|
+
return createToolStructuredContent({
|
|
314
|
+
requestType: args.requestType,
|
|
315
|
+
error: "update-person needs at least one of personName or personEmail — both were empty, so there is nothing to change",
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
const existing = await findPerson(args.personUuid, requestModifiers, sessionId);
|
|
319
|
+
if (!existing) {
|
|
320
|
+
return createToolStructuredContent({
|
|
321
|
+
requestType: args.requestType,
|
|
322
|
+
error: `No registered person has the uuid "${args.personUuid}". Use get-registered-faces to see who is registered — do not guess a uuid.`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
const updated = await updatePerson(args.personUuid, {
|
|
326
|
+
name: args.personName?.trim() || undefined,
|
|
327
|
+
email: args.personEmail?.trim() || undefined,
|
|
328
|
+
}, requestModifiers, sessionId);
|
|
329
|
+
return createToolStructuredContent({
|
|
330
|
+
requestType: args.requestType,
|
|
331
|
+
updated: { success: updated.success, uuid: updated.uuid },
|
|
332
|
+
warningMsg: updated.warningMsg,
|
|
333
|
+
note: `Updated ${existing.name ?? args.personUuid}${args.personName?.trim() ? ` — now named "${args.personName.trim()}"` : ""}. Their enrolled face images are unchanged, so recognition still matches the same face to this record.` +
|
|
334
|
+
// api2 accepts the email write but no read path — findPeopleByOrg,
|
|
335
|
+
// getPerson, or even the update response's own echo — ever returns it
|
|
336
|
+
// (verified against api2.itg 2026-08-05). Without this caveat the model
|
|
337
|
+
// would tell the user the email is saved and then fail to show it.
|
|
338
|
+
(args.personEmail?.trim()
|
|
339
|
+
? " NOTE: the API does not return a person's email on any read path, so the stored email cannot be displayed or verified later."
|
|
340
|
+
: ""),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
if (args.requestType === RequestType.DELETE_PERSON) {
|
|
344
|
+
if (!args.personUuid) {
|
|
345
|
+
return createToolStructuredContent({
|
|
346
|
+
requestType: args.requestType,
|
|
347
|
+
error: "personUuid is required for delete-person",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
const existing = await findPerson(args.personUuid, requestModifiers, sessionId);
|
|
351
|
+
if (!existing) {
|
|
352
|
+
return createToolStructuredContent({
|
|
353
|
+
requestType: args.requestType,
|
|
354
|
+
error: `No registered person has the uuid "${args.personUuid}". Use get-registered-faces to see who is registered.`,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
if (!args.confirmDelete) {
|
|
358
|
+
return createToolStructuredContent({
|
|
359
|
+
requestType: args.requestType,
|
|
360
|
+
note: `NOT DELETED — nothing was changed. Deleting "${existing.name ?? args.personUuid}" removes their enrolled face images and ` +
|
|
361
|
+
`cannot be undone: re-registering them needs new photos, and face alerts naming this person stop working. Past face events ` +
|
|
362
|
+
`stay in the history but will no longer be attributed to them. Confirm with the user, then call again with confirmDelete: true.`,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const deleted = await deletePerson(args.personUuid, requestModifiers, sessionId);
|
|
366
|
+
return createToolStructuredContent({
|
|
367
|
+
requestType: args.requestType,
|
|
368
|
+
deleted: { success: deleted.success, uuid: deleted.uuid },
|
|
369
|
+
warningMsg: deleted.warningMsg,
|
|
370
|
+
note: `Deleted the registered person "${existing.name ?? args.personUuid}" and their enrolled faces. Face recognition will no longer identify them.`,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
if (args.requestType === RequestType.ADD_PERSON_LABEL ||
|
|
374
|
+
args.requestType === RequestType.REMOVE_PERSON_LABEL) {
|
|
375
|
+
const removing = args.requestType === RequestType.REMOVE_PERSON_LABEL;
|
|
376
|
+
if (!args.personUuid || !args.personLabel?.trim()) {
|
|
377
|
+
return createToolStructuredContent({
|
|
378
|
+
requestType: args.requestType,
|
|
379
|
+
error: `personUuid and personLabel are both required for ${args.requestType}`,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
const existing = await findPerson(args.personUuid, requestModifiers, sessionId);
|
|
383
|
+
if (!existing) {
|
|
384
|
+
return createToolStructuredContent({
|
|
385
|
+
requestType: args.requestType,
|
|
386
|
+
error: `No registered person has the uuid "${args.personUuid}". Use get-registered-faces to see who is registered.`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
const label = args.personLabel.trim();
|
|
390
|
+
const updated = await changePersonLabel(args.personUuid, label, removing ? "remove" : "add", requestModifiers, sessionId);
|
|
391
|
+
return createToolStructuredContent({
|
|
392
|
+
requestType: args.requestType,
|
|
393
|
+
updated: { success: updated.success, uuid: updated.uuid, label: updated.label },
|
|
394
|
+
warningMsg: updated.warningMsg,
|
|
395
|
+
note: `${removing ? "Removed" : "Added"} the label "${label}" ${removing ? "from" : "for"} ${existing.name ?? args.personUuid}. Camera policies that alert on person labels use these, so this can change which alerts fire.`,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
if (args.requestType === RequestType.DELETE_FACE_MATCHMAKER) {
|
|
399
|
+
if (!args.faceId?.trim()) {
|
|
400
|
+
return createToolStructuredContent({
|
|
401
|
+
requestType: args.requestType,
|
|
402
|
+
error: "faceId is required for delete-face-matchmaker",
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
if (!args.confirmDelete) {
|
|
406
|
+
return createToolStructuredContent({
|
|
407
|
+
requestType: args.requestType,
|
|
408
|
+
note: `NOT DELETED — nothing was changed. Deleting enrolled face ${args.faceId.trim()} removes that face image from recognition and ` +
|
|
409
|
+
`cannot be undone; re-enrolling needs a new photo. The person record and any other enrolled faces they have are kept. ` +
|
|
410
|
+
`Confirm with the user, then call again with confirmDelete: true.`,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
const deleted = await deleteFaceMatchmaker(args.faceId.trim(), requestModifiers, sessionId);
|
|
414
|
+
return createToolStructuredContent({
|
|
415
|
+
requestType: args.requestType,
|
|
416
|
+
deleted: { success: deleted.success, faceId: deleted.faceId },
|
|
417
|
+
warningMsg: deleted.warningMsg,
|
|
418
|
+
note: "Deleted that enrolled face image. The person record and their other enrolled faces are unchanged — recognition may still identify them from those.",
|
|
419
|
+
});
|
|
420
|
+
}
|
|
199
421
|
return createToolStructuredContent({
|
|
200
422
|
requestType: args.requestType,
|
|
423
|
+
error: `Invalid requestType "${args.requestType}". Valid values are: ${Object.values(RequestType).join(", ")}.`,
|
|
201
424
|
});
|
|
202
425
|
};
|
|
203
426
|
export function createTool(server) {
|
|
204
|
-
server.registerTool(TOOL_NAME,
|
|
427
|
+
server.registerTool(TOOL_NAME,
|
|
428
|
+
// faceEventSummary is the whole point of the roster: a caller that projects
|
|
429
|
+
// down to the raw event rows must not lose the deduplicated person list.
|
|
430
|
+
protectFields({
|
|
431
|
+
title: "Faces",
|
|
205
432
|
description: TOOL_DESCRIPTION,
|
|
206
433
|
inputSchema: TOOL_ARGS,
|
|
207
434
|
outputSchema: OUTPUT_SCHEMA.shape,
|
|
208
|
-
|
|
435
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
436
|
+
}, ["faceEventSummary"]), TOOL_HANDLER);
|
|
209
437
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getAllGuestsByOrg, getGuestActivityLogs, getActivitiesForLocation, } from "../api/guest-management-tool-api.js";
|
|
1
|
+
import { getAllGuestsByOrg, getGuestActivityLogs, getActivitiesForLocation, createGuest, deleteGuest, getGuestPasses, changeGuestPassState, } from "../api/guest-management-tool-api.js";
|
|
2
2
|
import { GuestManagementRequestType, OUTPUT_SCHEMA, TOOL_ARGS, } from "../types/guest-management-tool-types.js";
|
|
3
3
|
import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
|
|
4
4
|
const TOOL_NAME = "guest-management-tool";
|
|
@@ -9,6 +9,14 @@ It has the following modes of operation, determined by the "requestType" paramet
|
|
|
9
9
|
- ${GuestManagementRequestType.GET_ALL_GUESTS}: List all guests across the organization with their check-in status, host, company, and contact info.
|
|
10
10
|
- ${GuestManagementRequestType.GET_ACTIVITY_LOGS}: Get org-wide guest activity logs (sign-ins, sign-outs). Supports optional time range filtering via startTimeMs/endTimeMs.
|
|
11
11
|
- ${GuestManagementRequestType.GET_ACTIVITIES_FOR_LOCATION}: Get guest activity logs for a specific location. Requires locationUuid. Supports optional time range filtering.
|
|
12
|
+
- ${GuestManagementRequestType.CREATE_GUEST}: Register a guest. Requires guestFirstName, guestLastName, guestEmail and locationUuid.
|
|
13
|
+
- ${GuestManagementRequestType.DELETE_GUEST}: Remove a guest record. Requires guestEmail and confirmDelete.
|
|
14
|
+
- ${GuestManagementRequestType.GET_GUEST_PASSES}: List guest passes (the door-access credentials issued to guests), optionally filtered by time range.
|
|
15
|
+
- ${GuestManagementRequestType.EXPIRE_GUEST_PASS}: End a guest pass's access while keeping its record. Requires guestPassUuid.
|
|
16
|
+
- ${GuestManagementRequestType.SUSPEND_GUEST_PASS} / ${GuestManagementRequestType.UNSUSPEND_GUEST_PASS}: Temporarily disable / re-enable a guest pass. Requires guestPassUuid.
|
|
17
|
+
- ${GuestManagementRequestType.DELETE_GUEST_PASS}: Destroy a guest pass record and its history. Requires guestPassUuid and confirmDelete.
|
|
18
|
+
|
|
19
|
+
A guest is identified by EMAIL, not a UUID, so creating a guest whose email already exists overwrites that record rather than adding a second one. Guest passes control physical door access — confirm the guest by name before changing one.
|
|
12
20
|
`;
|
|
13
21
|
const TOOL_HANDLER = async (args, _extra) => {
|
|
14
22
|
const { requestModifiers, sessionId } = extractFromToolExtra(_extra);
|
|
@@ -31,6 +39,123 @@ const TOOL_HANDLER = async (args, _extra) => {
|
|
|
31
39
|
const activities = await getActivitiesForLocation(args.locationUuid, args.startTimeMs ?? undefined, args.endTimeMs ?? undefined, requestModifiers, sessionId);
|
|
32
40
|
return createToolStructuredContent({ activities });
|
|
33
41
|
}
|
|
42
|
+
case GuestManagementRequestType.CREATE_GUEST: {
|
|
43
|
+
if (!args.guestFirstName?.trim() ||
|
|
44
|
+
!args.guestLastName?.trim() ||
|
|
45
|
+
!args.guestEmail?.trim() ||
|
|
46
|
+
!args.locationUuid) {
|
|
47
|
+
return createToolStructuredContent({
|
|
48
|
+
error: "guestFirstName, guestLastName, guestEmail and locationUuid are all required for create-guest.",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const email = args.guestEmail.trim();
|
|
52
|
+
// A guest is keyed on email, so creating one that already exists would
|
|
53
|
+
// overwrite the existing visit rather than adding a second guest.
|
|
54
|
+
const guests = await getAllGuestsByOrg(requestModifiers, sessionId);
|
|
55
|
+
const existing = guests.find(guest => guest.email?.trim().toLowerCase() === email.toLowerCase());
|
|
56
|
+
if (existing) {
|
|
57
|
+
return createToolStructuredContent({
|
|
58
|
+
guests: [existing],
|
|
59
|
+
note: `Nothing was created — ${email} is already registered as a guest (${[existing.firstName, existing.lastName].filter(Boolean).join(" ") || "no name"}). Guests are identified by email, so creating again would overwrite that record. Confirm with the user whether they meant to replace it.`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const created = await createGuest({
|
|
63
|
+
firstName: args.guestFirstName.trim(),
|
|
64
|
+
lastName: args.guestLastName.trim(),
|
|
65
|
+
email,
|
|
66
|
+
locationUuid: args.locationUuid,
|
|
67
|
+
hostUserUuid: args.hostUserUuid ?? undefined,
|
|
68
|
+
companyName: args.guestCompanyName?.trim() || undefined,
|
|
69
|
+
guestType: args.guestType ?? "GENERAL_VISITOR",
|
|
70
|
+
accessStartTimeMs: args.accessStartTimeMs ?? undefined,
|
|
71
|
+
accessEndTimeMs: args.accessEndTimeMs ?? undefined,
|
|
72
|
+
}, requestModifiers, sessionId);
|
|
73
|
+
return createToolStructuredContent({
|
|
74
|
+
created,
|
|
75
|
+
warningMsg: created.warningMsg,
|
|
76
|
+
note: args.accessEndTimeMs
|
|
77
|
+
? `Registered ${args.guestFirstName.trim()} ${args.guestLastName.trim()} (${email}) as a guest.`
|
|
78
|
+
: `Registered ${args.guestFirstName.trim()} ${args.guestLastName.trim()} (${email}) as a guest with NO access end time, so their access does not expire on its own. Offer to set accessEndTimeMs.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
case GuestManagementRequestType.DELETE_GUEST: {
|
|
82
|
+
if (!args.guestEmail?.trim()) {
|
|
83
|
+
return createToolStructuredContent({
|
|
84
|
+
error: "guestEmail is required for delete-guest — guests are identified by email, not a UUID.",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const email = args.guestEmail.trim();
|
|
88
|
+
const guests = await getAllGuestsByOrg(requestModifiers, sessionId);
|
|
89
|
+
const existing = guests.find(guest => guest.email?.trim().toLowerCase() === email.toLowerCase());
|
|
90
|
+
if (!existing) {
|
|
91
|
+
return createToolStructuredContent({
|
|
92
|
+
error: `No guest in this organization has the email ${email}, so there is nothing to delete. Use get-all-guests to see the registered guests.`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (!args.confirmDelete) {
|
|
96
|
+
return createToolStructuredContent({
|
|
97
|
+
guests: [existing],
|
|
98
|
+
note: `NOT DELETED — nothing was changed. Deleting the guest ${[existing.firstName, existing.lastName].filter(Boolean).join(" ") || email} ` +
|
|
99
|
+
`removes their record and their site access, and cannot be undone. Confirm with the user, then call again with confirmDelete: true.`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const deleted = await deleteGuest(email, requestModifiers, sessionId);
|
|
103
|
+
return createToolStructuredContent({
|
|
104
|
+
deleted,
|
|
105
|
+
warningMsg: deleted.warningMsg,
|
|
106
|
+
note: `Deleted the guest record for ${email}. Their past check-in activity remains in the activity log.`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
case GuestManagementRequestType.GET_GUEST_PASSES: {
|
|
110
|
+
const { guestPasses, lastEvaluatedKey } = await getGuestPasses({
|
|
111
|
+
startAfterMs: args.startTimeMs ?? undefined,
|
|
112
|
+
startBeforeMs: args.endTimeMs ?? undefined,
|
|
113
|
+
}, requestModifiers, sessionId);
|
|
114
|
+
const active = guestPasses.filter(pass => pass.lifecycle === "ACTIVE").length;
|
|
115
|
+
return createToolStructuredContent({
|
|
116
|
+
guestPasses,
|
|
117
|
+
lastEvaluatedKey,
|
|
118
|
+
note: guestPasses.length === 0
|
|
119
|
+
? "No guest passes match this time range. That is not the same as the organization having none — widen startTimeMs / endTimeMs to check."
|
|
120
|
+
: `${active} of ${guestPasses.length} pass(es) in this range are ACTIVE; the rest are suspended, expired or archived.`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
case GuestManagementRequestType.EXPIRE_GUEST_PASS:
|
|
124
|
+
case GuestManagementRequestType.SUSPEND_GUEST_PASS:
|
|
125
|
+
case GuestManagementRequestType.UNSUSPEND_GUEST_PASS:
|
|
126
|
+
case GuestManagementRequestType.DELETE_GUEST_PASS: {
|
|
127
|
+
const action = args.requestType === GuestManagementRequestType.EXPIRE_GUEST_PASS
|
|
128
|
+
? "expire"
|
|
129
|
+
: args.requestType === GuestManagementRequestType.SUSPEND_GUEST_PASS
|
|
130
|
+
? "suspend"
|
|
131
|
+
: args.requestType === GuestManagementRequestType.UNSUSPEND_GUEST_PASS
|
|
132
|
+
? "unsuspend"
|
|
133
|
+
: "delete";
|
|
134
|
+
if (!args.guestPassUuid) {
|
|
135
|
+
return createToolStructuredContent({
|
|
136
|
+
error: `guestPassUuid is required for ${args.requestType}. Use get-guest-passes to find it.`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (action === "delete" && !args.confirmDelete) {
|
|
140
|
+
return createToolStructuredContent({
|
|
141
|
+
note: `NOT DELETED — nothing was changed. Deleting guest pass ${args.guestPassUuid} destroys the record and its history. ` +
|
|
142
|
+
`To end the access while keeping the record, use expire-guest-pass instead. To delete anyway, confirm with the user and ` +
|
|
143
|
+
`call again with confirmDelete: true.`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const guestPassChange = await changeGuestPassState(args.guestPassUuid, action, requestModifiers, sessionId);
|
|
147
|
+
const notes = {
|
|
148
|
+
expire: "Expired the guest pass. It no longer opens any door; the record and its history are kept.",
|
|
149
|
+
suspend: "Suspended the guest pass. It will not open any door until unsuspend-guest-pass is used.",
|
|
150
|
+
unsuspend: "Un-suspended the guest pass. It opens doors again.",
|
|
151
|
+
delete: "Deleted the guest pass record permanently, along with its history.",
|
|
152
|
+
};
|
|
153
|
+
return createToolStructuredContent({
|
|
154
|
+
guestPassChange,
|
|
155
|
+
warningMsg: guestPassChange.warningMsg,
|
|
156
|
+
note: notes[action],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
34
159
|
}
|
|
35
160
|
}
|
|
36
161
|
catch (error) {
|
|
@@ -43,8 +168,10 @@ const TOOL_HANDLER = async (args, _extra) => {
|
|
|
43
168
|
};
|
|
44
169
|
export function createTool(server) {
|
|
45
170
|
server.registerTool(TOOL_NAME, {
|
|
171
|
+
title: "Guest Management",
|
|
46
172
|
description: TOOL_DESCRIPTION,
|
|
47
173
|
inputSchema: TOOL_ARGS,
|
|
48
174
|
outputSchema: OUTPUT_SCHEMA.shape,
|
|
175
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
49
176
|
}, TOOL_HANDLER);
|
|
50
177
|
}
|