rhombus-node-mcp 0.1.51 → 0.1.52

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.
@@ -1,4 +1,5 @@
1
1
  import { postApi } from "../network/network.js";
2
+ import { ActivityEnum } from "../types/schema.js";
2
3
  import { formatTimestamp } from "../util.js";
3
4
  /**
4
5
  * Honeywell Elements (LenelS2 Elements) activity enum values. Passing these as `activityTypes` to the
@@ -6,19 +7,15 @@ import { formatTimestamp } from "../util.js";
6
7
  * OnGuard search, which is implicitly scoped to ONGUARD_* types server-side.
7
8
  */
8
9
  export const ELEMENTS_ACTIVITY_TYPES = [
9
- "ELEMENTS_BADGE_AUTHORIZED",
10
- "ELEMENTS_BADGE_ANOMALY",
11
- "ELEMENTS_NO_ENTRY_MADE",
10
+ ActivityEnum.ELEMENTS_BADGE_AUTHORIZED,
11
+ ActivityEnum.ELEMENTS_BADGE_ANOMALY,
12
+ ActivityEnum.ELEMENTS_NO_ENTRY_MADE,
12
13
  ];
13
14
  /**
14
15
  * Calls the generalized webservice access-control event search
15
16
  * (POST /eventSearchV2/searchIntegrationAccessEvents) scoped to Honeywell Elements via `activityTypes`, and
16
- * maps the raw seekpoints to the same agent-friendly shape as searchOnGuardEvents. The request DTO mirrors
17
- * Eventsearch_SearchOnGuardEventsWSRequest plus an `activityTypes` array; the response shape is identical.
18
- *
19
- * The generalized endpoint, its `activityTypes` field, and the ELEMENTS_* enum values predate the generated
20
- * public OpenAPI schema, so the body is built off the OnGuard request DTO and the extra field is added via a
21
- * cast until `assets/openapi.json` is regenerated.
17
+ * maps the raw seekpoints to the same agent-friendly shape as searchOnGuardEvents. Typed against the
18
+ * generated public OpenAPI schema (Eventsearch_SearchIntegrationAccessEventsWSRequest/Response).
22
19
  */
23
20
  export async function searchElementsEvents(args, timeZone, requestModifiers, sessionId) {
24
21
  const body = {
@@ -0,0 +1,105 @@
1
+ import { buildMediaHints } from "./badge-correlation.js";
2
+ import { searchNetboxEvents } from "./netbox-tool-api.js";
3
+ const ALL_RULES = [
4
+ "lost_or_inactive_badge",
5
+ "entry_not_made",
6
+ "off_hours",
7
+ "impossible_travel",
8
+ "area_novelty",
9
+ ];
10
+ const DAY_MS = 86_400_000;
11
+ /**
12
+ * Flags anomalous Lenel S2 NetBox access events over a window: deterministic rules (lost/inactive badge,
13
+ * no-entry, off-hours, impossible travel, first-time-in-area vs a baseline) computed over searchNetboxEvents,
14
+ * each finding carrying the still/clip hints to confirm it. The agent narrates/triages from here.
15
+ */
16
+ export async function getNetboxAccessAnomalies(args, timeZone, requestModifiers, sessionId) {
17
+ const rules = new Set(args.rules && args.rules.length ? args.rules : ALL_RULES);
18
+ const offStart = args.offHoursStartHour ?? 7;
19
+ const offEnd = args.offHoursEndHour ?? 19;
20
+ const maxTravelSec = args.impossibleTravelMaxSeconds ?? 30;
21
+ const baselineDays = args.baselineDays ?? 30;
22
+ const scope = { area: args.area, locationUuids: args.locationUuids, deviceUuids: args.deviceUuids };
23
+ const { events } = await searchNetboxEvents({ ...scope, afterMs: args.afterMs, beforeMs: args.beforeMs, limit: args.limit ?? 500 }, timeZone, requestModifiers, sessionId);
24
+ // Baseline area-set per cardholder (for area_novelty).
25
+ const baselineAreas = new Map();
26
+ if (rules.has("area_novelty") && baselineDays > 0 && args.afterMs != null) {
27
+ const { events: baseEvents } = await searchNetboxEvents({ ...scope, afterMs: args.afterMs - baselineDays * DAY_MS, beforeMs: args.afterMs, limit: 1000 }, timeZone, requestModifiers, sessionId);
28
+ for (const e of baseEvents) {
29
+ if (!e.cardholderName || !e.areaEntering)
30
+ continue;
31
+ const set = baselineAreas.get(e.cardholderName) ?? new Set();
32
+ set.add(e.areaEntering);
33
+ baselineAreas.set(e.cardholderName, set);
34
+ }
35
+ }
36
+ const hourFmt = new Intl.DateTimeFormat("en-US", { timeZone, hour: "2-digit", hourCycle: "h23" });
37
+ const localHour = (ms) => parseInt(hourFmt.format(new Date(ms)), 10);
38
+ const findings = [];
39
+ const seen = new Set();
40
+ const add = (e, rule, severity, rationale) => {
41
+ const key = `${e.cardholderName ?? ""}|${rule}|${e.timestampMs ?? ""}|${e.areaEntering ?? ""}`;
42
+ if (seen.has(key))
43
+ return;
44
+ seen.add(key);
45
+ const { clipHint, stillHint } = buildMediaHints({ deviceUuid: e.deviceUuid, timestampMs: e.timestampMs });
46
+ findings.push({
47
+ cardholderName: e.cardholderName,
48
+ rule,
49
+ severity,
50
+ datetime: e.datetime,
51
+ timestampMs: e.timestampMs,
52
+ deviceUuid: e.deviceUuid,
53
+ area: e.areaEntering ?? e.areaExiting,
54
+ rationale,
55
+ clipHint,
56
+ stillHint,
57
+ });
58
+ };
59
+ for (const e of events) {
60
+ const at = e.areaEntering ? ` at "${e.areaEntering}"` : "";
61
+ if (rules.has("lost_or_inactive_badge") && e.badgeStatus && e.badgeStatus.toLowerCase() !== "active") {
62
+ add(e, "lost_or_inactive_badge", "high", `Badge status "${e.badgeStatus}" used${at}.`);
63
+ }
64
+ if (rules.has("entry_not_made") && e.entryMade === false) {
65
+ add(e, "entry_not_made", "medium", `Access granted but no entry made${at} — possible tailgating or aborted entry.`);
66
+ }
67
+ if (rules.has("off_hours") && e.timestampMs != null) {
68
+ const h = localHour(e.timestampMs);
69
+ if (h < offStart || h >= offEnd) {
70
+ add(e, "off_hours", "medium", `Entry ${e.datetime ?? `${h}:00`} is outside business hours (${offStart}:00–${offEnd}:00)${at}.`);
71
+ }
72
+ }
73
+ if (rules.has("area_novelty") && baselineAreas.size > 0 && e.cardholderName && e.areaEntering) {
74
+ const base = baselineAreas.get(e.cardholderName);
75
+ if (base && !base.has(e.areaEntering)) {
76
+ add(e, "area_novelty", "high", `First entry to "${e.areaEntering}" — not in ${e.cardholderName}'s prior ${baselineDays}-day pattern.`);
77
+ }
78
+ }
79
+ }
80
+ // Impossible travel: per cardholder, consecutive taps in different areas within the window.
81
+ if (rules.has("impossible_travel")) {
82
+ const byPerson = new Map();
83
+ for (const e of events) {
84
+ if (!e.cardholderName || e.timestampMs == null)
85
+ continue;
86
+ const arr = byPerson.get(e.cardholderName) ?? [];
87
+ arr.push(e);
88
+ byPerson.set(e.cardholderName, arr);
89
+ }
90
+ for (const [name, evs] of byPerson) {
91
+ const sorted = evs.slice().sort((a, b) => (a.timestampMs ?? 0) - (b.timestampMs ?? 0));
92
+ for (let i = 1; i < sorted.length; i++) {
93
+ const a = sorted[i - 1];
94
+ const b = sorted[i];
95
+ const gapSec = ((b.timestampMs ?? 0) - (a.timestampMs ?? 0)) / 1000;
96
+ if (a.areaEntering && b.areaEntering && a.areaEntering !== b.areaEntering && gapSec <= maxTravelSec) {
97
+ add(b, "impossible_travel", "high", `${name} at "${a.areaEntering}" then "${b.areaEntering}" ${Math.round(gapSec)}s apart — physically implausible for one person.`);
98
+ }
99
+ }
100
+ }
101
+ }
102
+ const sev = (s) => (s === "high" ? 0 : 1);
103
+ findings.sort((a, b) => sev(a.severity) - sev(b.severity) || (b.timestampMs ?? 0) - (a.timestampMs ?? 0));
104
+ return { findings, eventsAnalyzed: events.length };
105
+ }
@@ -0,0 +1,56 @@
1
+ import { buildMediaHints } from "./badge-correlation.js";
2
+ import { searchNetboxEvents } from "./netbox-tool-api.js";
3
+ /**
4
+ * Reconstructs a single cardholder's movements as a chronological timeline of Lenel S2 NetBox badge taps,
5
+ * each with the still/clip hints needed to show what happened. Pure orchestration over
6
+ * searchNetboxEvents + the shared correlation helper.
7
+ */
8
+ export async function getNetboxBadgeTimeline(args, timeZone, requestModifiers, sessionId) {
9
+ const { events } = await searchNetboxEvents({
10
+ cardholderQuery: args.cardholderQuery,
11
+ locationUuids: args.locationUuids,
12
+ afterMs: args.afterMs,
13
+ beforeMs: args.beforeMs,
14
+ limit: args.limit ?? 200,
15
+ }, timeZone, requestModifiers, sessionId);
16
+ // searchNetboxEvents returns newest-first; a timeline reads chronologically.
17
+ const ordered = events
18
+ .filter((e) => e.timestampMs != null)
19
+ .sort((a, b) => (a.timestampMs ?? 0) - (b.timestampMs ?? 0));
20
+ const stops = ordered.map((e, i) => {
21
+ const next = ordered[i + 1];
22
+ const gapToNextSeconds = next?.timestampMs != null && e.timestampMs != null
23
+ ? Math.round((next.timestampMs - e.timestampMs) / 1000)
24
+ : undefined;
25
+ const { clipHint, stillHint } = buildMediaHints({ deviceUuid: e.deviceUuid, timestampMs: e.timestampMs }, args.clipPaddingSeconds);
26
+ return {
27
+ timestampMs: e.timestampMs,
28
+ datetime: e.datetime,
29
+ deviceUuid: e.deviceUuid,
30
+ area: e.areaEntering ?? e.areaExiting,
31
+ label: e.label,
32
+ isAnomaly: e.isAnomaly,
33
+ clipHint,
34
+ stillHint,
35
+ gapToNextSeconds,
36
+ };
37
+ });
38
+ // The sequence of areas traversed, collapsing consecutive repeats.
39
+ const path = [];
40
+ for (const stop of stops) {
41
+ if (stop.area && stop.area !== path[path.length - 1]) {
42
+ path.push(stop.area);
43
+ }
44
+ }
45
+ // cardholderQuery is full-text, so it can match more than one person — surface that so the agent
46
+ // can disambiguate rather than silently merge two people's movements.
47
+ const distinctCardholders = [
48
+ ...new Set(ordered.map((e) => e.cardholderName).filter((n) => !!n)),
49
+ ];
50
+ return {
51
+ cardholderName: distinctCardholders[0],
52
+ ambiguousCardholders: distinctCardholders.length > 1 ? distinctCardholders : undefined,
53
+ stops,
54
+ path,
55
+ };
56
+ }
@@ -0,0 +1,86 @@
1
+ import { buildMediaHints } from "./badge-correlation.js";
2
+ import { searchNetboxEvents } from "./netbox-tool-api.js";
3
+ import { getFaceEvents, searchSimilarFaces } from "./faces-tool-api.js";
4
+ /**
5
+ * Lost / stolen-badge live response for Lenel S2 NetBox. Finds recent lost/inactive-badge use, and for
6
+ * each: the door clip/still, the face captured at the door (identified or UNIDENTIFIED), and a cross-camera
7
+ * track of that same face (via similar-face search) ordered to a last-known location. Detection + door
8
+ * evidence are exact; the track is best-effort (depends on face capture + recognition coverage).
9
+ */
10
+ export async function getNetboxLostBadgeResponse(args, timeZone, requestModifiers, sessionId) {
11
+ const scope = { area: args.area, locationUuids: args.locationUuids, deviceUuids: args.deviceUuids };
12
+ const { events } = await searchNetboxEvents({ ...scope, anomalyOnly: true, afterMs: args.afterMs, beforeMs: args.beforeMs, limit: args.limit ?? 50 }, timeZone, requestModifiers, sessionId);
13
+ // anomalyOnly returns lost-badge AND no-entry anomalies; keep the lost/inactive-badge ones. NetBox
14
+ // credentialStatus uses values like Lost/Stolen/Terminated — anything that isn't "active".
15
+ const lostEvents = events.filter((e) => e.badgeStatus && e.badgeStatus.toLowerCase() !== "active");
16
+ const winMs = (args.faceWindowSeconds ?? 30) * 1000;
17
+ const incidents = [];
18
+ for (const e of lostEvents) {
19
+ const { clipHint, stillHint } = buildMediaHints({ deviceUuid: e.deviceUuid, timestampMs: e.timestampMs });
20
+ let facesAtDoor = [];
21
+ let sightings = [];
22
+ if (e.deviceUuid && e.timestampMs != null) {
23
+ try {
24
+ // Face(s) at the door: query by time window (the faces tool can't filter by device), then match
25
+ // the door camera client-side.
26
+ const { faceEvents } = await getFaceEvents({
27
+ pageRequest: { lastEvaluatedKey: null, maxPageSize: 75 },
28
+ searchFilter: {
29
+ faceNameContains: null,
30
+ faceNames: [],
31
+ hasEmbedding: null,
32
+ hasName: null,
33
+ labels: [],
34
+ locationUuids: [],
35
+ personUuids: [],
36
+ timestampFilter: {
37
+ rangeStart: new Date(e.timestampMs - winMs).toISOString(),
38
+ rangeEnd: new Date(e.timestampMs + winMs).toISOString(),
39
+ },
40
+ },
41
+ }, timeZone, requestModifiers, sessionId);
42
+ facesAtDoor = faceEvents
43
+ .filter((f) => f.deviceUuid === e.deviceUuid)
44
+ .map((f) => ({
45
+ faceName: f.faceName ?? undefined,
46
+ personUuid: f.personUuid ?? undefined,
47
+ thumbnailS3Key: f.thumbnailS3Key ?? undefined,
48
+ faceEventUuid: f.uuid ?? undefined,
49
+ eventTimestamp: f.eventTimestamp,
50
+ }));
51
+ // Track that face across cameras, ordered in time.
52
+ const seed = facesAtDoor.find((f) => f.faceEventUuid);
53
+ if (seed?.faceEventUuid) {
54
+ const similar = await searchSimilarFaces(seed.faceEventUuid, timeZone, requestModifiers, sessionId);
55
+ sightings = similar
56
+ .map((s) => ({
57
+ deviceUuid: s.deviceUuid,
58
+ datetime: s.eventTimestamp,
59
+ timestampMs: s.eventTimestampMs,
60
+ similarity: s.similarity,
61
+ personUuid: s.personUuid,
62
+ }))
63
+ .sort((a, b) => (a.timestampMs ?? 0) - (b.timestampMs ?? 0));
64
+ }
65
+ }
66
+ catch {
67
+ // Face enrichment is best-effort — never fail the whole response over it.
68
+ }
69
+ }
70
+ const last = sightings.length ? sightings[sightings.length - 1] : undefined;
71
+ incidents.push({
72
+ cardholderOfRecord: e.cardholderName,
73
+ badgeStatus: e.badgeStatus,
74
+ datetime: e.datetime,
75
+ timestampMs: e.timestampMs,
76
+ deviceUuid: e.deviceUuid,
77
+ area: e.areaEntering ?? e.areaExiting,
78
+ clipHint,
79
+ stillHint,
80
+ facesAtDoor,
81
+ sightings,
82
+ lastKnownSighting: last ? { deviceUuid: last.deviceUuid, datetime: last.datetime } : undefined,
83
+ });
84
+ }
85
+ return { incidents, count: incidents.length };
86
+ }
@@ -0,0 +1,61 @@
1
+ import { postApi } from "../network/network.js";
2
+ import { ActivityEnum } from "../types/schema.js";
3
+ import { formatTimestamp } from "../util.js";
4
+ /**
5
+ * Lenel S2 NetBox (Honeywell NetBox) activity enum values. Passing these as `activityTypes` to the
6
+ * generalized access-control event search scopes results to NetBox events only — exactly mirroring the
7
+ * OnGuard search, which is implicitly scoped to ONGUARD_* types server-side.
8
+ */
9
+ export const NETBOX_ACTIVITY_TYPES = [
10
+ ActivityEnum.NETBOX_BADGE_AUTHORIZED,
11
+ ActivityEnum.NETBOX_BADGE_ANOMALY,
12
+ ActivityEnum.NETBOX_NO_ENTRY_MADE,
13
+ ];
14
+ /**
15
+ * Calls the generalized webservice access-control event search
16
+ * (POST /eventSearchV2/searchIntegrationAccessEvents) scoped to Lenel S2 NetBox via `activityTypes`, and
17
+ * maps the raw seekpoints to the same agent-friendly shape as searchOnGuardEvents. Typed against the
18
+ * generated public OpenAPI schema (Eventsearch_SearchIntegrationAccessEventsWSRequest/Response).
19
+ */
20
+ export async function searchNetboxEvents(args, timeZone, requestModifiers, sessionId) {
21
+ const body = {
22
+ deviceUuids: args.deviceUuids,
23
+ locationUuids: args.locationUuids,
24
+ afterMs: args.afterMs,
25
+ beforeMs: args.beforeMs,
26
+ cardholderQuery: args.cardholderQuery,
27
+ badgeStatus: args.badgeStatus,
28
+ badgeType: args.badgeType,
29
+ area: args.area,
30
+ anomalyOnly: args.anomalyOnly,
31
+ entryMade: args.entryMade,
32
+ limit: args.limit ?? 200,
33
+ activityTypes: [...NETBOX_ACTIVITY_TYPES],
34
+ };
35
+ const res = await postApi({
36
+ route: "/eventSearchV2/searchIntegrationAccessEvents",
37
+ body,
38
+ modifiers: requestModifiers,
39
+ sessionId,
40
+ });
41
+ if (res.error) {
42
+ throw new Error(res.status ?? res.errorMsg ?? "NetBox event search failed");
43
+ }
44
+ const events = (res.events ?? []).map((e) => ({
45
+ timestampMs: e.timestampMs ?? undefined,
46
+ datetime: e.timestampMs != null ? formatTimestamp(e.timestampMs, timeZone) : undefined,
47
+ deviceUuid: e.deviceUuid ?? undefined,
48
+ label: e.customDisplayName ?? e.objectType ?? undefined,
49
+ // Dedicated-event-types put the cardholder in its own `cardholderName` field (customDescription
50
+ // is null on those docs); keep customDescription as a legacy fallback. The generated schema
51
+ // predates the field, so read it through a cast until `assets/openapi.json` is regenerated.
52
+ cardholderName: e.cardholderName ?? e.customDescription ?? undefined,
53
+ badgeStatus: e.badgeStatus ?? undefined,
54
+ badgeType: e.badgeType ?? undefined,
55
+ areaEntering: e.areaEntering ?? undefined,
56
+ areaExiting: e.areaExiting ?? undefined,
57
+ entryMade: e.entryMade ?? undefined,
58
+ isAnomaly: e.alert ?? undefined,
59
+ }));
60
+ return { events };
61
+ }
@@ -1,4 +1,5 @@
1
1
  import { postApi } from "../network/network.js";
2
+ import { ActivityEnum } from "../types/schema.js";
2
3
  import { formatTimestamp } from "../util.js";
3
4
  /**
4
5
  * Calls the unified integration access-event search (POST /eventSearchV2/searchIntegrationAccessEvents),
@@ -10,14 +11,13 @@ import { formatTimestamp } from "../util.js";
10
11
  * integration access-event search scopes results to OnGuard events only.
11
12
  */
12
13
  export const ONGUARD_ACTIVITY_TYPES = [
13
- "ONGUARD_BADGE_AUTHORIZED",
14
- "ONGUARD_BADGE_ANOMALY",
15
- "ONGUARD_NO_ENTRY_MADE",
14
+ ActivityEnum.ONGUARD_BADGE_AUTHORIZED,
15
+ ActivityEnum.ONGUARD_BADGE_ANOMALY,
16
+ ActivityEnum.ONGUARD_NO_ENTRY_MADE,
16
17
  ];
17
18
  export async function searchOnGuardEvents(args, timeZone, requestModifiers, sessionId) {
18
- // Unified third-party integration access-event search, scoped to OnGuard via `activityTypes`. The
19
- // generalized endpoint + `activityTypes` predate the generated schema, so the extra field is added via
20
- // a cast off the OnGuard request DTO until `assets/openapi.json` is regenerated.
19
+ // Unified third-party integration access-event search, scoped to OnGuard via `activityTypes`. Typed
20
+ // against the generated public OpenAPI schema (Eventsearch_SearchIntegrationAccessEventsWSRequest/Response).
21
21
  const body = {
22
22
  deviceUuids: args.deviceUuids,
23
23
  locationUuids: args.locationUuids,
@@ -0,0 +1,53 @@
1
+ import { getNetboxAccessAnomalies } from "../api/netbox-access-anomaly-tool-api.js";
2
+ import { OUTPUT_SCHEMA, TOOL_ARGS } from "../types/netbox-access-anomaly-tool-types.js";
3
+ import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
4
+ const TOOL_NAME = "netbox-access-anomaly-tool";
5
+ const TOOL_DESCRIPTION = `
6
+ Scans Lenel S2 NetBox (Honeywell NetBox) access events over a time window and flags anomalous badge activity. Use for
7
+ "find unusual badge activity", "anything suspicious in access this week", or proactive access review.
8
+
9
+ Runs deterministic rules and returns ranked findings (high severity first):
10
+ - lost_or_inactive_badge: a non-active badge (lost/suspended) was used
11
+ - entry_not_made: access granted but no entry made (possible tailgating)
12
+ - off_hours: entry outside business hours (configurable)
13
+ - impossible_travel: one cardholder at two different areas seconds apart
14
+ - area_novelty: a cardholder's first-ever entry to an area vs their prior history (needs a baseline window)
15
+
16
+ Each finding includes cardholderName, the rule, severity, datetime, area, the camera deviceUuid, a plain-language
17
+ rationale, and clip/still hints. Resolve relative times (e.g. "this week") to ISO 8601 first via the timestamp tool.
18
+
19
+ This is a triage aid: present findings grouped by severity, and for the notable ones call the camera-tool
20
+ (requestType "image", cameraUuid = finding.deviceUuid, timestamp = finding.timestampMs) and/or clips-tool
21
+ ("createClip" using finding.clipHint) — in PARALLEL — so a human can confirm. Don't assert wrongdoing; surface the
22
+ evidence.
23
+ `;
24
+ const TOOL_HANDLER = async (args, _extra) => {
25
+ const { requestModifiers, sessionId } = extractFromToolExtra(_extra);
26
+ try {
27
+ const result = await getNetboxAccessAnomalies({
28
+ area: args.area ?? undefined,
29
+ locationUuids: args.locationUuids ?? undefined,
30
+ deviceUuids: args.deviceUuids ?? undefined,
31
+ afterMs: args.startTime ? new Date(args.startTime).getTime() : undefined,
32
+ beforeMs: args.endTime ? new Date(args.endTime).getTime() : undefined,
33
+ rules: args.rules ?? undefined,
34
+ baselineDays: args.baselineDays ?? undefined,
35
+ offHoursStartHour: args.offHoursStartHour ?? undefined,
36
+ offHoursEndHour: args.offHoursEndHour ?? undefined,
37
+ impossibleTravelMaxSeconds: args.impossibleTravelMaxSeconds ?? undefined,
38
+ limit: args.limit ?? undefined,
39
+ }, args.timeZone ?? "UTC", requestModifiers, sessionId);
40
+ return createToolStructuredContent(result);
41
+ }
42
+ catch (error) {
43
+ const message = error instanceof Error ? error.message : "Unknown error";
44
+ return createToolStructuredContent({ error: message });
45
+ }
46
+ };
47
+ export function createTool(server) {
48
+ server.registerTool(TOOL_NAME, {
49
+ description: TOOL_DESCRIPTION,
50
+ inputSchema: TOOL_ARGS,
51
+ outputSchema: OUTPUT_SCHEMA.shape,
52
+ }, TOOL_HANDLER);
53
+ }
@@ -0,0 +1,50 @@
1
+ import { getNetboxBadgeTimeline } from "../api/netbox-badge-timeline-tool-api.js";
2
+ import { OUTPUT_SCHEMA, TOOL_ARGS } from "../types/netbox-badge-timeline-tool-types.js";
3
+ import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
4
+ const TOOL_NAME = "netbox-badge-timeline-tool";
5
+ const TOOL_DESCRIPTION = `
6
+ Reconstructs one person's movements through a building from their Lenel S2 NetBox (Honeywell NetBox) badge taps.
7
+ Use this for incident reconstruction / "follow the badge" requests, e.g. "reconstruct Eve's movements
8
+ yesterday" or "where did this cardholder go".
9
+
10
+ Returns the cardholder's badge taps in CHRONOLOGICAL order (oldest first), each with:
11
+ - datetime / timestampMs and the area entered
12
+ - deviceUuid: the camera at that door
13
+ - clipHint (camera + start/end window) and stillHint (camera + timestamp)
14
+ - gapToNextSeconds: time until the next tap (a large gap = unobserved movement between doors)
15
+ plus a "path" array summarizing the areas traversed in order.
16
+
17
+ Resolve relative times like "yesterday" to ISO 8601 first (use the timestamp tool), then pass
18
+ startTime/endTime. cardholderQuery is a full-text name match; if "ambiguousCardholders" is returned the
19
+ query matched more than one person — ask the user which one before trusting the timeline.
20
+
21
+ IMPORTANT — to show the movement visually: for each stop (or the key transitions), call the camera-tool
22
+ (requestType "image", cameraUuid = stop.deviceUuid, timestamp = stop.timestampMs) for a still you can see,
23
+ and/or the clips-tool (requestType "createClip", using stop.clipHint) for video. Issue those per-stop
24
+ media calls in PARALLEL, then present the timeline as a chronological narrative.
25
+ `;
26
+ const TOOL_HANDLER = async (args, _extra) => {
27
+ const { requestModifiers, sessionId } = extractFromToolExtra(_extra);
28
+ try {
29
+ const result = await getNetboxBadgeTimeline({
30
+ cardholderQuery: args.cardholderQuery,
31
+ locationUuids: args.locationUuids ?? undefined,
32
+ afterMs: args.startTime ? new Date(args.startTime).getTime() : undefined,
33
+ beforeMs: args.endTime ? new Date(args.endTime).getTime() : undefined,
34
+ clipPaddingSeconds: args.clipPaddingSeconds ?? undefined,
35
+ limit: args.limit ?? undefined,
36
+ }, args.timeZone ?? "UTC", requestModifiers, sessionId);
37
+ return createToolStructuredContent(result);
38
+ }
39
+ catch (error) {
40
+ const message = error instanceof Error ? error.message : "Unknown error";
41
+ return createToolStructuredContent({ error: message });
42
+ }
43
+ };
44
+ export function createTool(server) {
45
+ server.registerTool(TOOL_NAME, {
46
+ description: TOOL_DESCRIPTION,
47
+ inputSchema: TOOL_ARGS,
48
+ outputSchema: OUTPUT_SCHEMA.shape,
49
+ }, TOOL_HANDLER);
50
+ }
@@ -0,0 +1,47 @@
1
+ import { getNetboxLostBadgeResponse } from "../api/netbox-lost-badge-tool-api.js";
2
+ import { OUTPUT_SCHEMA, TOOL_ARGS } from "../types/netbox-lost-badge-tool-types.js";
3
+ import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
4
+ const TOOL_NAME = "netbox-lost-badge-tool";
5
+ const TOOL_DESCRIPTION = `
6
+ Lost / stolen-badge live response for Lenel S2 NetBox (Honeywell NetBox) access. Use for "a lost badge was just used —
7
+ who is it and where did they go?", or to review lost/inactive-badge use over a window.
8
+
9
+ For each lost/inactive-badge use it returns:
10
+ - cardholderOfRecord (who the badge belongs to — may NOT be who used it) and badgeStatus
11
+ - the door deviceUuid + time, plus clipHint / stillHint for the door footage
12
+ - facesAtDoor: the face(s) captured at the door at that moment — a recognized name, or UNIDENTIFIED (an unknown
13
+ face on a valid badge is the strongest stolen/shared-badge signal), with a thumbnail
14
+ - sightings: that same face tracked across cameras, ordered in time, ending in lastKnownSighting (last-known location)
15
+
16
+ Resolve relative times (e.g. "in the last hour") to ISO 8601 first via the timestamp tool.
17
+
18
+ To present: show the door still/clip (camera-tool image / clips-tool createClip with the hints), the face at the
19
+ door, and the cross-camera track to last-known location. Detection + door evidence are reliable; the face track
20
+ depends on face-recognition coverage, so treat it as investigative, not proof.
21
+ `;
22
+ const TOOL_HANDLER = async (args, _extra) => {
23
+ const { requestModifiers, sessionId } = extractFromToolExtra(_extra);
24
+ try {
25
+ const result = await getNetboxLostBadgeResponse({
26
+ area: args.area ?? undefined,
27
+ locationUuids: args.locationUuids ?? undefined,
28
+ deviceUuids: args.deviceUuids ?? undefined,
29
+ afterMs: args.startTime ? new Date(args.startTime).getTime() : undefined,
30
+ beforeMs: args.endTime ? new Date(args.endTime).getTime() : undefined,
31
+ faceWindowSeconds: args.faceWindowSeconds ?? undefined,
32
+ limit: args.limit ?? undefined,
33
+ }, args.timeZone ?? "UTC", requestModifiers, sessionId);
34
+ return createToolStructuredContent(result);
35
+ }
36
+ catch (error) {
37
+ const message = error instanceof Error ? error.message : "Unknown error";
38
+ return createToolStructuredContent({ error: message });
39
+ }
40
+ };
41
+ export function createTool(server) {
42
+ server.registerTool(TOOL_NAME, {
43
+ description: TOOL_DESCRIPTION,
44
+ inputSchema: TOOL_ARGS,
45
+ outputSchema: OUTPUT_SCHEMA.shape,
46
+ }, TOOL_HANDLER);
47
+ }
@@ -0,0 +1,55 @@
1
+ import { searchNetboxEvents } from "../api/netbox-tool-api.js";
2
+ import { OUTPUT_SCHEMA, TOOL_ARGS } from "../types/netbox-tool-types.js";
3
+ import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
4
+ const TOOL_NAME = "netbox-events-tool";
5
+ const TOOL_DESCRIPTION = `
6
+ Searches Lenel S2 NetBox (Honeywell NetBox) badge / access-control events for the organization. Use this to answer
7
+ "who entered WHERE and WHEN" questions, e.g. "who entered the back office yesterday".
8
+
9
+ Each returned event includes:
10
+ - cardholderName: the person's name
11
+ - deviceUuid: the camera that saw the event
12
+ - timestampMs / datetime: when it happened
13
+ - label: e.g. "NetBox: Badge Authorized" (a grant) or an anomaly label
14
+ - badgeStatus, badgeType, areaEntering, areaExiting, entryMade, isAnomaly
15
+
16
+ Filters (all optional): area, locationUuids, deviceUuids, cardholderQuery, badgeStatus, badgeType,
17
+ anomalyOnly, entryMade, startTime, endTime, limit. Resolve relative times like "yesterday" to ISO 8601
18
+ first (use the timestamp tool), then pass startTime/endTime.
19
+
20
+ IMPORTANT — to show pictures and video of each person so the user can visually identify them: after this
21
+ returns, for each event (or the most relevant ones) call the camera-tool (requestType "image",
22
+ cameraUuid = the event's deviceUuid, timestamp = the event's time) to get a still you can see, and/or the
23
+ clips-tool (requestType "createClip") with a short window around the timestamp for video. Issue those
24
+ per-event media calls in PARALLEL.
25
+ `;
26
+ const TOOL_HANDLER = async (args, _extra) => {
27
+ const { requestModifiers, sessionId } = extractFromToolExtra(_extra);
28
+ try {
29
+ const result = await searchNetboxEvents({
30
+ area: args.area ?? undefined,
31
+ locationUuids: args.locationUuids ?? undefined,
32
+ deviceUuids: args.deviceUuids ?? undefined,
33
+ cardholderQuery: args.cardholderQuery ?? undefined,
34
+ badgeStatus: args.badgeStatus ?? undefined,
35
+ badgeType: args.badgeType ?? undefined,
36
+ anomalyOnly: args.anomalyOnly ?? undefined,
37
+ entryMade: args.entryMade ?? undefined,
38
+ afterMs: args.startTime ? new Date(args.startTime).getTime() : undefined,
39
+ beforeMs: args.endTime ? new Date(args.endTime).getTime() : undefined,
40
+ limit: args.limit ?? undefined,
41
+ }, args.timeZone ?? "UTC", requestModifiers, sessionId);
42
+ return createToolStructuredContent(result);
43
+ }
44
+ catch (error) {
45
+ const message = error instanceof Error ? error.message : "Unknown error";
46
+ return createToolStructuredContent({ error: message });
47
+ }
48
+ };
49
+ export function createTool(server) {
50
+ server.registerTool(TOOL_NAME, {
51
+ description: TOOL_DESCRIPTION,
52
+ inputSchema: TOOL_ARGS,
53
+ outputSchema: OUTPUT_SCHEMA.shape,
54
+ }, TOOL_HANDLER);
55
+ }
@@ -0,0 +1,3 @@
1
+ // Identical arg/output shapes to the OnGuard access-anomaly tool (same generalized backend), so reuse
2
+ // the OnGuard Zod schemas verbatim. Re-exported to keep the netbox-* module naming convention parallel.
3
+ export { ANOMALY_RULES, TOOL_ARGS, AnomalyFindingSchema, OUTPUT_SCHEMA, } from "./access-anomaly-tool-types.js";
@@ -0,0 +1,3 @@
1
+ // Identical arg/output shapes to the OnGuard badge-timeline tool (same generalized backend), so reuse
2
+ // the OnGuard Zod schemas verbatim. Re-exported to keep the netbox-* module naming convention parallel.
3
+ export { TOOL_ARGS, BadgeTimelineStopSchema, OUTPUT_SCHEMA, } from "./badge-timeline-tool-types.js";
@@ -0,0 +1,3 @@
1
+ // Identical arg/output shapes to the OnGuard lost-badge tool (same generalized backend), so reuse the
2
+ // OnGuard Zod schemas verbatim. Re-exported to keep the netbox-* module naming convention parallel.
3
+ export { TOOL_ARGS, LostBadgeIncidentSchema, OUTPUT_SCHEMA, } from "./lost-badge-tool-types.js";
@@ -0,0 +1,4 @@
1
+ // The Lenel S2 NetBox event search exposes the exact same filters and result shape as OnGuard
2
+ // (the backend uses one generalized DTO), so reuse the OnGuard Zod schemas verbatim rather than
3
+ // duplicating them. Re-exported here to keep the netbox-* module naming convention parallel.
4
+ export { TOOL_ARGS, OnGuardEventSchema as NetboxEventSchema, OUTPUT_SCHEMA, } from "./onguard-tool-types.js";
@@ -287,6 +287,15 @@ export var ActivityEnum;
287
287
  ActivityEnum["ALM_THREAT_DETECTED"] = "ALM_THREAT_DETECTED";
288
288
  ActivityEnum["ALM_MONITORING_EVENT_DETECTED"] = "ALM_MONITORING_EVENT_DETECTED";
289
289
  ActivityEnum["ROBOT_DOG_DETECTED"] = "ROBOT_DOG_DETECTED";
290
+ ActivityEnum["ONGUARD_BADGE_AUTHORIZED"] = "ONGUARD_BADGE_AUTHORIZED";
291
+ ActivityEnum["ONGUARD_BADGE_ANOMALY"] = "ONGUARD_BADGE_ANOMALY";
292
+ ActivityEnum["ONGUARD_NO_ENTRY_MADE"] = "ONGUARD_NO_ENTRY_MADE";
293
+ ActivityEnum["ELEMENTS_BADGE_AUTHORIZED"] = "ELEMENTS_BADGE_AUTHORIZED";
294
+ ActivityEnum["ELEMENTS_BADGE_ANOMALY"] = "ELEMENTS_BADGE_ANOMALY";
295
+ ActivityEnum["ELEMENTS_NO_ENTRY_MADE"] = "ELEMENTS_NO_ENTRY_MADE";
296
+ ActivityEnum["NETBOX_BADGE_AUTHORIZED"] = "NETBOX_BADGE_AUTHORIZED";
297
+ ActivityEnum["NETBOX_BADGE_ANOMALY"] = "NETBOX_BADGE_ANOMALY";
298
+ ActivityEnum["NETBOX_NO_ENTRY_MADE"] = "NETBOX_NO_ENTRY_MADE";
290
299
  ActivityEnum["UNKNOWN"] = "UNKNOWN";
291
300
  })(ActivityEnum || (ActivityEnum = {}));
292
301
  export var AddOnLicenseEnum;
@@ -10843,6 +10843,26 @@ const Eventsearch_SearchOnGuardEventsWSResponse = z.object({
10843
10843
  events: z.array(SeekpointIndexType).optional(),
10844
10844
  warningMsg: z.string().optional()
10845
10845
  });
10846
+ const Eventsearch_SearchIntegrationAccessEventsWSRequest = z.object({
10847
+ activityTypes: z.array(ActivityEnum).optional(),
10848
+ afterMs: z.number().int().optional(),
10849
+ anomalyOnly: z.boolean().optional(),
10850
+ area: z.string().optional(),
10851
+ badgeStatus: z.string().optional(),
10852
+ badgeType: z.string().optional(),
10853
+ beforeMs: z.number().int().optional(),
10854
+ cardholderQuery: z.string().optional(),
10855
+ deviceUuids: z.array(z.string()).optional(),
10856
+ entryMade: z.boolean().optional(),
10857
+ limit: z.number().int().optional(),
10858
+ locationUuids: z.array(z.string()).optional()
10859
+ });
10860
+ const Eventsearch_SearchIntegrationAccessEventsWSResponse = z.object({
10861
+ error: z.boolean().optional(),
10862
+ errorMsg: z.string().optional(),
10863
+ events: z.array(SeekpointIndexType).optional(),
10864
+ warningMsg: z.string().optional()
10865
+ });
10846
10866
  const Export_ExportAuditEventsWSRequest = z.object({
10847
10867
  endInterval: z.number().int().optional(),
10848
10868
  excludeActions: z.array(z.string()).optional(),
@@ -22012,6 +22032,8 @@ export const schemas = {
22012
22032
  Eventsearch_GetEventSeekpointsWSResponse,
22013
22033
  Eventsearch_SearchOnGuardEventsWSRequest,
22014
22034
  Eventsearch_SearchOnGuardEventsWSResponse,
22035
+ Eventsearch_SearchIntegrationAccessEventsWSRequest,
22036
+ Eventsearch_SearchIntegrationAccessEventsWSResponse,
22015
22037
  Export_ExportAuditEventsWSRequest,
22016
22038
  Export_ExportClimateEventsWSRequest,
22017
22039
  Export_ExportCountReportsWSRequest,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhombus-node-mcp",
3
- "version": "0.1.51",
3
+ "version": "0.1.52",
4
4
  "description": "MCP server for Rhombus API",
5
5
  "keywords": [
6
6
  "ai",