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
@@ -1,6 +1,8 @@
1
- import { getFaceEvents, getFaceEventsByPerson, getFaceMatchmakers, getPersonLabels, getRegisteredFaces, searchSimilarFaces } from "../api/faces-tool-api.js";
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
- This tool interacts with the Rhombus face recognition system to retrieve information about face sightings and registered faces.
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
- **Important for person-presence questions:** When asked whether specific people were seen or are present, you should ALSO call events-tool with eventType "access-control" to check badge-in records. Face recognition and access control are complementary someone may badge in without face recognition triggering, or be seen by a camera without badging in.
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
- If the requestType is "get-face-events":
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
- If the requestType is "get-registered-faces":
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
- const faceEventArgs = args.faceEventFilter;
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 hadLocationFilter = faceEventArgs.searchFilter?.locationUuids &&
107
- faceEventArgs.searchFilter.locationUuids.length > 0;
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(faceEventArgs.searchFilter.locationUuids)}, retrying without location filter`);
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
- }, TOOL_HANDLER);
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
  }