rhombus-node-mcp 0.1.48 → 0.1.50

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.
@@ -0,0 +1,105 @@
1
+ import { buildMediaHints } from "./badge-correlation.js";
2
+ import { searchOnGuardEvents } from "./onguard-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 OnGuard access events over a window: deterministic rules (lost/inactive badge, no-entry,
13
+ * off-hours, impossible travel, first-time-in-area vs a baseline) computed over searchOnGuardEvents, each
14
+ * finding carrying the still/clip hints to confirm it. The agent narrates/triages from here.
15
+ */
16
+ export async function getAccessAnomalies(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 searchOnGuardEvents({ ...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 searchOnGuardEvents({ ...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
+ }
@@ -82,8 +82,10 @@ export async function searchSimilarFaces(faceEventUuid, timeZone, requestModifie
82
82
  throw new Error(JSON.stringify(res));
83
83
  return (res.faceEvents || []).map((event) => ({
84
84
  uuid: event.uuid ?? undefined,
85
+ deviceUuid: event.deviceUuid ?? undefined,
85
86
  personUuid: event.personUuid ?? undefined,
86
87
  similarity: event.similarity ?? undefined,
88
+ eventTimestampMs: event.eventTimestamp ?? undefined,
87
89
  eventTimestamp: event.eventTimestamp
88
90
  ? formatTimestamp(event.eventTimestamp, timeZone)
89
91
  : undefined,
@@ -0,0 +1,85 @@
1
+ import { buildMediaHints } from "./badge-correlation.js";
2
+ import { getFaceEvents, searchSimilarFaces } from "./faces-tool-api.js";
3
+ import { searchOnGuardEvents } from "./onguard-tool-api.js";
4
+ /**
5
+ * Lost / stolen-badge live response. Finds recent lost/inactive-badge use, and for each: the door clip/still,
6
+ * the face captured at the door (identified or UNIDENTIFIED), and a cross-camera track of that same face
7
+ * (via similar-face search) ordered to a last-known location. Detection + door evidence are exact; the track
8
+ * is best-effort (depends on face capture + recognition coverage).
9
+ */
10
+ export async function getLostBadgeResponse(args, timeZone, requestModifiers, sessionId) {
11
+ const scope = { area: args.area, locationUuids: args.locationUuids, deviceUuids: args.deviceUuids };
12
+ const { events } = await searchOnGuardEvents({ ...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.
14
+ const lostEvents = events.filter((e) => e.badgeStatus && e.badgeStatus.toLowerCase() !== "active");
15
+ const winMs = (args.faceWindowSeconds ?? 30) * 1000;
16
+ const incidents = [];
17
+ for (const e of lostEvents) {
18
+ const { clipHint, stillHint } = buildMediaHints({ deviceUuid: e.deviceUuid, timestampMs: e.timestampMs });
19
+ let facesAtDoor = [];
20
+ let sightings = [];
21
+ if (e.deviceUuid && e.timestampMs != null) {
22
+ try {
23
+ // Face(s) at the door: query by time window (the faces tool can't filter by device), then match
24
+ // the door camera client-side.
25
+ const { faceEvents } = await getFaceEvents({
26
+ pageRequest: { lastEvaluatedKey: null, maxPageSize: 75 },
27
+ searchFilter: {
28
+ faceNameContains: null,
29
+ faceNames: [],
30
+ hasEmbedding: null,
31
+ hasName: null,
32
+ labels: [],
33
+ locationUuids: [],
34
+ personUuids: [],
35
+ timestampFilter: {
36
+ rangeStart: new Date(e.timestampMs - winMs).toISOString(),
37
+ rangeEnd: new Date(e.timestampMs + winMs).toISOString(),
38
+ },
39
+ },
40
+ }, timeZone, requestModifiers, sessionId);
41
+ facesAtDoor = faceEvents
42
+ .filter((f) => f.deviceUuid === e.deviceUuid)
43
+ .map((f) => ({
44
+ faceName: f.faceName ?? undefined,
45
+ personUuid: f.personUuid ?? undefined,
46
+ thumbnailS3Key: f.thumbnailS3Key ?? undefined,
47
+ faceEventUuid: f.uuid ?? undefined,
48
+ eventTimestamp: f.eventTimestamp,
49
+ }));
50
+ // Track that face across cameras, ordered in time.
51
+ const seed = facesAtDoor.find((f) => f.faceEventUuid);
52
+ if (seed?.faceEventUuid) {
53
+ const similar = await searchSimilarFaces(seed.faceEventUuid, timeZone, requestModifiers, sessionId);
54
+ sightings = similar
55
+ .map((s) => ({
56
+ deviceUuid: s.deviceUuid,
57
+ datetime: s.eventTimestamp,
58
+ timestampMs: s.eventTimestampMs,
59
+ similarity: s.similarity,
60
+ personUuid: s.personUuid,
61
+ }))
62
+ .sort((a, b) => (a.timestampMs ?? 0) - (b.timestampMs ?? 0));
63
+ }
64
+ }
65
+ catch {
66
+ // Face enrichment is best-effort — never fail the whole response over it.
67
+ }
68
+ }
69
+ const last = sightings.length ? sightings[sightings.length - 1] : undefined;
70
+ incidents.push({
71
+ cardholderOfRecord: e.cardholderName,
72
+ badgeStatus: e.badgeStatus,
73
+ datetime: e.datetime,
74
+ timestampMs: e.timestampMs,
75
+ deviceUuid: e.deviceUuid,
76
+ area: e.areaEntering ?? e.areaExiting,
77
+ clipHint,
78
+ stillHint,
79
+ facesAtDoor,
80
+ sightings,
81
+ lastKnownSighting: last ? { deviceUuid: last.deviceUuid, datetime: last.datetime } : undefined,
82
+ });
83
+ }
84
+ return { incidents, count: incidents.length };
85
+ }
@@ -31,8 +31,11 @@ export async function searchOnGuardEvents(args, timeZone, requestModifiers, sess
31
31
  timestampMs: e.timestampMs ?? undefined,
32
32
  datetime: e.timestampMs != null ? formatTimestamp(e.timestampMs, timeZone) : undefined,
33
33
  deviceUuid: e.deviceUuid ?? undefined,
34
- label: e.customDisplayName ?? undefined,
35
- cardholderName: e.customDescription ?? undefined,
34
+ label: e.customDisplayName ?? e.objectType ?? undefined,
35
+ // Dedicated-event-types put the cardholder in its own `cardholderName` field (customDescription
36
+ // is null on ONGUARD_* docs); keep customDescription as a legacy fallback. The generated schema
37
+ // predates the field, so read it through a cast until `assets/openapi.json` is regenerated.
38
+ cardholderName: e.cardholderName ?? e.customDescription ?? undefined,
36
39
  badgeStatus: e.badgeStatus ?? undefined,
37
40
  badgeType: e.badgeType ?? undefined,
38
41
  areaEntering: e.areaEntering ?? undefined,
@@ -1,9 +1,20 @@
1
- import { SpanStatusCode, trace } from "@opentelemetry/api";
1
+ import { context, SpanKind, SpanStatusCode, trace, TraceFlags, } from "@opentelemetry/api";
2
2
  import { resolveSessionIdentity } from "../api/get-accessible-apps.js";
3
3
  import { logger } from "../logger.js";
4
4
  import { extractFromToolExtra } from "../util.js";
5
5
  const TRACER_NAME = "rhombus-node-mcp";
6
6
  const TOOL_CALL_SPAN = "mcp.tool.call";
7
+ const INVALID_TRACE_ID = "00000000000000000000000000000000";
8
+ let warnedNoopTracer = false;
9
+ function warnIfNoopTracer(span) {
10
+ if (warnedNoopTracer)
11
+ return;
12
+ const { traceId, traceFlags } = span.spanContext();
13
+ if (traceId === INVALID_TRACE_ID || traceFlags === TraceFlags.NONE) {
14
+ warnedNoopTracer = true;
15
+ logger.warn("📡 OpenTelemetry custom spans are no-ops — @opentelemetry/api is not sharing the SDK TracerProvider. Check for duplicate @opentelemetry/api in node_modules.");
16
+ }
17
+ }
7
18
  function setArgKeys(span, args) {
8
19
  if (args && typeof args === "object") {
9
20
  const keys = Object.keys(args);
@@ -34,7 +45,9 @@ async function attachSessionIdentity(span, extra) {
34
45
  function wrapHandler(toolName, handler) {
35
46
  return async (args, extra) => {
36
47
  const tracer = trace.getTracer(TRACER_NAME);
37
- return tracer.startActiveSpan(TOOL_CALL_SPAN, async (span) => {
48
+ const parentContext = context.active();
49
+ return tracer.startActiveSpan(TOOL_CALL_SPAN, { kind: SpanKind.INTERNAL }, parentContext, async (span) => {
50
+ warnIfNoopTracer(span);
38
51
  const start = Date.now();
39
52
  span.setAttribute("mcp.tool.name", toolName);
40
53
  span.setAttribute("mcp.transport", process.env.TRANSPORT_TYPE ?? "stdio");
@@ -50,6 +63,9 @@ function wrapHandler(toolName, handler) {
50
63
  message: "tool returned isError",
51
64
  });
52
65
  }
66
+ else {
67
+ span.setStatus({ code: SpanStatusCode.OK });
68
+ }
53
69
  return result;
54
70
  }
55
71
  catch (error) {
@@ -63,6 +79,7 @@ function wrapHandler(toolName, handler) {
63
79
  }
64
80
  finally {
65
81
  span.setAttribute("mcp.tool.duration_ms", Date.now() - start);
82
+ span.end();
66
83
  }
67
84
  });
68
85
  };
@@ -0,0 +1,53 @@
1
+ import { getAccessAnomalies } from "../api/access-anomaly-tool-api.js";
2
+ import { OUTPUT_SCHEMA, TOOL_ARGS } from "../types/access-anomaly-tool-types.js";
3
+ import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
4
+ const TOOL_NAME = "access-anomaly-tool";
5
+ const TOOL_DESCRIPTION = `
6
+ Scans Honeywell OnGuard (Lenel) 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 getAccessAnomalies({
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,47 @@
1
+ import { getLostBadgeResponse } from "../api/lost-badge-tool-api.js";
2
+ import { OUTPUT_SCHEMA, TOOL_ARGS } from "../types/lost-badge-tool-types.js";
3
+ import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
4
+ const TOOL_NAME = "lost-badge-tool";
5
+ const TOOL_DESCRIPTION = `
6
+ Lost / stolen-badge live response for Honeywell OnGuard (Lenel) 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 getLostBadgeResponse({
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,76 @@
1
+ import { z } from "zod";
2
+ import { createUuidSchema } from "../types.js";
3
+ import { ISOTimestampFormatDescription } from "../utils/timestampInput.js";
4
+ export const ANOMALY_RULES = [
5
+ "lost_or_inactive_badge",
6
+ "entry_not_made",
7
+ "off_hours",
8
+ "impossible_travel",
9
+ "area_novelty",
10
+ ];
11
+ export const TOOL_ARGS = {
12
+ area: z.string().nullable().describe("Optional: restrict analysis to events entering this area (full-text)."),
13
+ locationUuids: z
14
+ .array(createUuidSchema())
15
+ .nullable()
16
+ .describe("Optional: restrict to these Rhombus location UUIDs."),
17
+ deviceUuids: z.array(createUuidSchema()).nullable().describe("Optional: restrict to these camera UUIDs."),
18
+ startTime: z
19
+ .string()
20
+ .datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
21
+ .nullable()
22
+ .describe("Start of the window to analyze (inclusive). " + ISOTimestampFormatDescription),
23
+ endTime: z
24
+ .string()
25
+ .datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
26
+ .nullable()
27
+ .describe("End of the window to analyze (inclusive). " + ISOTimestampFormatDescription),
28
+ rules: z
29
+ .array(z.enum(ANOMALY_RULES))
30
+ .nullable()
31
+ .describe("Which rules to run; default all. Options: lost_or_inactive_badge, entry_not_made, off_hours, impossible_travel, area_novelty."),
32
+ baselineDays: z
33
+ .number()
34
+ .nullable()
35
+ .describe("Days of prior history used to learn each person's normal areas for area_novelty (default 30; 0 disables it)."),
36
+ offHoursStartHour: z
37
+ .number()
38
+ .nullable()
39
+ .describe("Local business-hours start hour 0–23; entries before it are off-hours (default 7)."),
40
+ offHoursEndHour: z
41
+ .number()
42
+ .nullable()
43
+ .describe("Local business-hours end hour 0–23; entries at/after it are off-hours (default 19)."),
44
+ impossibleTravelMaxSeconds: z
45
+ .number()
46
+ .nullable()
47
+ .describe("Max seconds between two different-area taps by one person to flag impossible travel (default 30)."),
48
+ limit: z.number().nullable().describe("Max events to analyze in the window (default 500)."),
49
+ timeZone: z
50
+ .string()
51
+ .nullable()
52
+ .describe("IANA timezone for hour-of-day checks and formatting, e.g. America/New_York. Defaults to UTC."),
53
+ };
54
+ const TOOL_ARGS_SCHEMA = z.object(TOOL_ARGS);
55
+ const ClipHintSchema = z.object({ deviceUuid: z.string(), startTimeMs: z.number(), endTimeMs: z.number() });
56
+ const StillHintSchema = z.object({ deviceUuid: z.string(), timestampMs: z.number() });
57
+ export const AnomalyFindingSchema = z.object({
58
+ cardholderName: z.string().optional(),
59
+ rule: z.string().describe("Which rule fired."),
60
+ severity: z.string().describe('"high" or "medium".'),
61
+ datetime: z.string().optional(),
62
+ timestampMs: z.number().optional(),
63
+ deviceUuid: z.string().optional().describe("Camera at the event. Pass to camera-tool (image) / clips-tool (createClip)."),
64
+ area: z.string().optional(),
65
+ rationale: z.string().describe("Plain-language explanation of why this was flagged."),
66
+ clipHint: ClipHintSchema.optional(),
67
+ stillHint: StillHintSchema.optional(),
68
+ });
69
+ export const OUTPUT_SCHEMA = z.object({
70
+ findings: z
71
+ .array(AnomalyFindingSchema)
72
+ .optional()
73
+ .describe("Anomalies, ranked high severity first then most-recent."),
74
+ eventsAnalyzed: z.number().optional(),
75
+ error: z.string().optional(),
76
+ });
@@ -0,0 +1,68 @@
1
+ import { z } from "zod";
2
+ import { createUuidSchema } from "../types.js";
3
+ import { ISOTimestampFormatDescription } from "../utils/timestampInput.js";
4
+ export const TOOL_ARGS = {
5
+ area: z.string().nullable().describe("Optional: restrict to events entering this area (full-text)."),
6
+ locationUuids: z.array(createUuidSchema()).nullable().describe("Optional: restrict to these location UUIDs."),
7
+ deviceUuids: z.array(createUuidSchema()).nullable().describe("Optional: restrict to these camera UUIDs."),
8
+ startTime: z
9
+ .string()
10
+ .datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
11
+ .nullable()
12
+ .describe("Start of the window to scan for lost/inactive-badge use (inclusive). " + ISOTimestampFormatDescription),
13
+ endTime: z
14
+ .string()
15
+ .datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
16
+ .nullable()
17
+ .describe("End of the window (inclusive). " + ISOTimestampFormatDescription),
18
+ faceWindowSeconds: z
19
+ .number()
20
+ .nullable()
21
+ .describe("± seconds around each badge event to look for the face at the door (default 30)."),
22
+ limit: z.number().nullable().describe("Max badge events to scan in the window (default 50)."),
23
+ timeZone: z
24
+ .string()
25
+ .nullable()
26
+ .describe("IANA timezone for formatting times, e.g. America/New_York. Defaults to UTC."),
27
+ };
28
+ const TOOL_ARGS_SCHEMA = z.object(TOOL_ARGS);
29
+ const ClipHintSchema = z.object({ deviceUuid: z.string(), startTimeMs: z.number(), endTimeMs: z.number() });
30
+ const StillHintSchema = z.object({ deviceUuid: z.string(), timestampMs: z.number() });
31
+ const FaceAtDoorSchema = z.object({
32
+ faceName: z.string().optional().describe('Recognized name, or absent/UNIDENTIFIED if no enrolled match.'),
33
+ personUuid: z.string().optional(),
34
+ thumbnailS3Key: z.string().optional().describe("Face crop thumbnail key for display."),
35
+ faceEventUuid: z.string().optional(),
36
+ eventTimestamp: z.string().optional(),
37
+ });
38
+ const SightingSchema = z.object({
39
+ deviceUuid: z.string().optional().describe("Camera where the same face was seen."),
40
+ datetime: z.string().optional(),
41
+ timestampMs: z.number().optional(),
42
+ similarity: z.number().optional(),
43
+ personUuid: z.string().optional(),
44
+ });
45
+ export const LostBadgeIncidentSchema = z.object({
46
+ cardholderOfRecord: z.string().optional().describe("The cardholder the badge is registered to (may not be who used it)."),
47
+ badgeStatus: z.string().optional(),
48
+ datetime: z.string().optional(),
49
+ timestampMs: z.number().optional(),
50
+ deviceUuid: z.string().optional().describe("Door camera. Pass to camera-tool (image) / clips-tool (createClip)."),
51
+ area: z.string().optional(),
52
+ clipHint: ClipHintSchema.optional(),
53
+ stillHint: StillHintSchema.optional(),
54
+ facesAtDoor: z.array(FaceAtDoorSchema).optional().describe("Face(s) captured at the door at the time of use."),
55
+ sightings: z
56
+ .array(SightingSchema)
57
+ .optional()
58
+ .describe("Same face seen across cameras, ordered in time — the track after the door."),
59
+ lastKnownSighting: z
60
+ .object({ deviceUuid: z.string().optional(), datetime: z.string().optional() })
61
+ .optional()
62
+ .describe("Most recent sighting — the person's last-known location."),
63
+ });
64
+ export const OUTPUT_SCHEMA = z.object({
65
+ incidents: z.array(LostBadgeIncidentSchema).optional(),
66
+ count: z.number().optional(),
67
+ error: z.string().optional(),
68
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhombus-node-mcp",
3
- "version": "0.1.48",
3
+ "version": "0.1.50",
4
4
  "description": "MCP server for Rhombus API",
5
5
  "keywords": [
6
6
  "ai",
@@ -50,7 +50,6 @@
50
50
  ],
51
51
  "dependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.27.1",
53
- "@opentelemetry/api": "^1.9.0",
54
53
  "axios": "^1.11.0",
55
54
  "cheerio": "^1.1.2",
56
55
  "chrono-node": "^2.8.0",
@@ -66,6 +65,7 @@
66
65
  "zod": "^4.3.6"
67
66
  },
68
67
  "devDependencies": {
68
+ "@opentelemetry/api": "^1.9.0",
69
69
  "@types/cors": "^2.8.19",
70
70
  "@types/express": "^5.0.3",
71
71
  "@types/luxon": "^3.6.2",
@@ -78,5 +78,13 @@
78
78
  },
79
79
  "engines": {
80
80
  "node": ">=18"
81
+ },
82
+ "peerDependencies": {
83
+ "@opentelemetry/api": "^1.9.0"
84
+ },
85
+ "peerDependenciesMeta": {
86
+ "@opentelemetry/api": {
87
+ "optional": true
88
+ }
81
89
  }
82
90
  }