rhombus-node-mcp
Version:
MCP server for Rhombus API
332 lines (318 loc) • 18.2 kB
JavaScript
import { findPromptConfigurations, getAuditFeed, getCustomEventsReport, getCustomLLMReport, getDiagnosticFeed, getLineCrossingEnabledCameras, getOccupancyCountReport, getOccupancyEnabledCameras, getPeopleCountEvents, getSummaryCountReport, getThresholdCrossingCountReport, getThresholdCrossingEvents, getUniqueFaceCount, } from "../api/report-tool-api.js";
import { logger } from "../logger.js";
import { OUTPUT_SCHEMA, RequestType, TOOL_ARGS, } from "../types/report-tool-types.js";
import { extractFromToolExtra } from "../util.js";
const TOOL_NAME = "report-tool";
const TOOL_DESCRIPTION = `
**Scope:** This tool returns **aggregated counts and time-series summaries** over specified intervals and scopes. Use **events-tool** when you need raw, event-level data (individual events with timestamps). Use this tool for high-level reports, analytics, and trends—especially over periods of a day or more.
**Interval guidance:** A shorter interval (HOURLY instead of DAILY) gives a better representation of data over time. Balance interval and range so you don't request too much data. For ranges spanning a week or so, HOURLY is appropriate.
---
**People / occupancy counting strategy**
When asked to count people on a camera or at a location, follow this strategy:
1. **Always call GET_OCCUPANCY_ENABLED_CAMERAS first** to discover which cameras have occupancy counting enabled.
2. If the target camera IS in the list, call **GET_OCCUPANCY_COUNT_REPORT** for that device. The response will automatically include a \`faceCountEnrichment\` field with the number of unique individuals identified by face recognition in the same time range. Present both data sources: occupancy estimate and unique face count.
3. If the target camera is NOT in the list, **tell the user** that camera does not have occupancy counting enabled, and list the cameras that do. You can still call GET_SUMMARY_COUNT_REPORT with PEOPLE type — its response will also include \`faceCountEnrichment\` with unique face data as a fallback. If the PEOPLE count returns zero, the response will also include the list of occupancy-enabled cameras and a hint.
4. When both occupancy data and face recognition data are available, **synthesize both** in your answer (e.g., "Occupancy estimates ~15 people. Face recognition identified 9 unique individuals during this period.").
**PEOPLE type (in GET_SUMMARY_COUNT_REPORT):** Not a unique person count; it counts people-detection events. Requires people detection to be enabled on the camera. Use for high-level activity trends, not for deduplicated head counts.
---
**Summary and occupancy**
- **GET_SUMMARY_COUNT_REPORT:** Aggregated counts (people, faces, motion, vehicles, etc.) over time at device, location, or org scope. Interval: minutely, hourly, daily, weekly, monthly, yearly. When called with PEOPLE type at DEVICE scope, the response is automatically enriched with face recognition data.
- **GET_OCCUPANCY_ENABLED_CAMERAS:** List of cameras with occupancy reporting enabled. **Always call this first** before any people/occupancy counting request to verify camera support.
- **GET_OCCUPANCY_COUNT_REPORT:** Occupancy count time series for a specific device over a time range. Response is automatically enriched with face recognition data. If the device does not support occupancy, the response will include a hint and the list of cameras that do.
---
**Line crossing**
- **GET_LINE_CROSSING_ENABLED_CAMERAS:** Cameras at a location with line crossing enabled, plus their configs. Call first to see which cameras support threshold crossing reports.
- **GET_THRESHOLD_CROSSING_COUNT_REPORT:** Ingress/egress counts for line crossings over time. Supports human and vehicle detection; bucket size: quarter hour, hour, day, week. Response includes computed metrics: average entries/exits per hour, hour with most entries/exits, busiest hour (with breakdown).
---
**Custom LLM events**
- **FIND_PROMPT_CONFIGURATIONS:** All custom event prompt configurations (e.g. "black dog sightings", "delivery truck arrivals", "parking availability %"). Each has prompt text, UUID, and promptType (COUNT, PERCENT, BOOLEAN). Call first to discover available custom events.
- **GET_CUSTOM_LLM_REPORT:** **This is the PRIMARY way to get custom event reports.** Aggregated time-series for one custom event by prompt UUID. Automatically selects the correct API based on promptType: COUNT (numeric counts), PERCENT (percentages), BOOLEAN (true/false). Intervals: minutely, quarter-hourly, hourly, daily, weekly, monthly. **Always use this for custom event reports, trends, and analytics.** Use FIND_PROMPT_CONFIGURATIONS first to get the promptUuid and promptType.
- **GET_CUSTOM_EVENTS_REPORT:** Raw individual event values only (not aggregated). Use only when you need per-event granularity, not for reports or trends.
---
**Audit and diagnostics**
- **GET_AUDIT_FEED:** Audit log of all user/admin actions in the org over a time range. Returns who did what and when (principalName, targetName, action, displayText).
- **GET_DIAGNOSTIC_FEED:** Device diagnostic events over a time range.
- **GET_THRESHOLD_CROSSING_EVENTS:** Individual line-crossing events (not aggregated counts).
- **GET_PEOPLE_COUNT_EVENTS:** Most recent people count readings for specified devices.
`;
const TOOL_HANDLER = async (args, extra) => {
const { requestType } = args;
const { requestModifiers, sessionId } = extractFromToolExtra(extra);
if (requestType === RequestType.GET_SUMMARY_COUNT_REPORT) {
const { summaryCountRequest } = args;
if (!summaryCountRequest) {
throw new Error("summaryCountRequest is required");
}
const { interval, scope, types, rangeStart, rangeEnd, uuid, timeZone } = summaryCountRequest;
const startTimeMs = new Date(rangeStart).getTime();
const endTimeMs = new Date(rangeEnd).getTime();
const report = await getSummaryCountReport(interval, scope, types, uuid ?? undefined, endTimeMs, startTimeMs, requestModifiers, sessionId, timeZone);
const enrichedReport = { ...report };
const isPeopleQuery = types.includes("PEOPLE") && scope === "DEVICE" && uuid;
if (isPeopleQuery) {
try {
const faceCount = await getUniqueFaceCount(uuid, startTimeMs, endTimeMs, requestModifiers, sessionId);
enrichedReport.faceCountEnrichment = faceCount;
}
catch (err) {
logger.error("Failed to fetch face count enrichment for summary report", err);
}
const allZero = !report?.timeSeriesDataPoints?.length ||
report.timeSeriesDataPoints.every((dp) => !dp.eventCountMap ||
Object.values(dp.eventCountMap).every((v) => v === 0 || v === null || v === undefined));
if (allZero) {
try {
const camerasReport = await getOccupancyEnabledCameras(requestModifiers, sessionId);
enrichedReport.occupancyEnabledCameras = camerasReport?.cameras?.map((c) => ({
uuid: c.uuid,
name: c.name,
locationUuid: c.locationUuid,
}));
enrichedReport.hint =
"People detection returned zero results for this camera. This usually means people counting is not enabled on this device. " +
"The occupancyEnabledCameras field lists cameras that support occupancy counting. " +
"Face recognition data is available in faceCountEnrichment above.";
}
catch (err) {
logger.error("Failed to fetch occupancy-enabled cameras for hint", err);
}
}
}
return {
content: [
{
type: "text",
text: JSON.stringify(enrichedReport),
},
],
structuredContent: {
summaryCountReport: enrichedReport,
},
};
}
if (requestType === RequestType.GET_OCCUPANCY_COUNT_REPORT) {
const { occupancyCountRequest } = args;
if (!occupancyCountRequest) {
throw new Error("occupancyCountRequest is required");
}
const { deviceUuid, rangeStart, rangeEnd, interval } = occupancyCountRequest;
const startTimeMs = new Date(rangeStart).getTime();
const endTimeMs = new Date(rangeEnd).getTime();
const report = await getOccupancyCountReport(deviceUuid, startTimeMs, endTimeMs, interval, requestModifiers, sessionId);
const enrichedReport = { ...report };
try {
const faceCount = await getUniqueFaceCount(deviceUuid, startTimeMs, endTimeMs, requestModifiers, sessionId);
enrichedReport.faceCountEnrichment = faceCount;
}
catch (err) {
logger.error("Failed to fetch face count enrichment for occupancy report", err);
}
const allZero = !report?.timeSeriesDataPoints?.length ||
report.timeSeriesDataPoints.every((dp) => !dp.eventCountMap ||
Object.values(dp.eventCountMap).every((v) => v === 0 || v === null || v === undefined));
if (allZero) {
try {
const camerasReport = await getOccupancyEnabledCameras(requestModifiers, sessionId);
const enabledUuids = new Set(camerasReport?.cameras?.map((c) => c.uuid).filter(Boolean) ?? []);
if (!enabledUuids.has(deviceUuid)) {
enrichedReport.occupancyEnabledCameras = camerasReport?.cameras?.map((c) => ({
uuid: c.uuid,
name: c.name,
locationUuid: c.locationUuid,
}));
enrichedReport.hint =
"This camera does not have occupancy counting enabled (no occupancy polygon defined). " +
"The occupancyEnabledCameras field lists cameras that do support occupancy counting. " +
"Face recognition data is available in faceCountEnrichment above.";
}
else {
enrichedReport.hint =
"This camera has occupancy counting enabled but returned zero counts for the requested time range. " +
"Face recognition data is available in faceCountEnrichment above.";
}
}
catch (err) {
logger.error("Failed to fetch occupancy-enabled cameras for hint", err);
}
}
return {
content: [
{
type: "text",
text: JSON.stringify(enrichedReport),
},
],
structuredContent: {
occupancyCountReport: enrichedReport,
},
};
}
if (requestType === RequestType.GET_OCCUPANCY_ENABLED_CAMERAS) {
const report = await getOccupancyEnabledCameras(requestModifiers, sessionId);
return {
content: [
{
type: "text",
text: JSON.stringify(report),
},
],
structuredContent: {
occupancyEnabledCamerasReport: report,
},
};
}
if (requestType === RequestType.GET_LINE_CROSSING_ENABLED_CAMERAS) {
const { lineCrossingEnabledCamerasRequest } = args;
if (!lineCrossingEnabledCamerasRequest) {
throw new Error("lineCrossingEnabledCamerasRequest is required");
}
const { locationUuid } = lineCrossingEnabledCamerasRequest;
const report = await getLineCrossingEnabledCameras(locationUuid, requestModifiers, sessionId);
return {
content: [
{
type: "text",
text: JSON.stringify(report),
},
],
structuredContent: {
lineCrossingEnabledCamerasReport: report,
},
};
}
if (requestType === RequestType.GET_THRESHOLD_CROSSING_COUNT_REPORT) {
const { thresholdCrossingCountRequest } = args;
if (!thresholdCrossingCountRequest) {
throw new Error("thresholdCrossingCountRequest is required");
}
const { deviceUuid, rangeStart, rangeEnd, bucketSize, crossingObject, dedupe, } = thresholdCrossingCountRequest;
const report = await getThresholdCrossingCountReport(deviceUuid, new Date(rangeStart).getTime(), new Date(rangeEnd).getTime(), bucketSize, crossingObject, dedupe, requestModifiers, sessionId);
return {
content: [
{
type: "text",
text: JSON.stringify(report),
},
],
structuredContent: {
thresholdCrossingCountReport: report,
},
};
}
if (requestType === RequestType.FIND_PROMPT_CONFIGURATIONS) {
const report = await findPromptConfigurations(requestModifiers, sessionId);
return {
content: [
{
type: "text",
text: JSON.stringify(report),
},
],
structuredContent: {
promptConfigurationsReport: report,
},
};
}
if (requestType === RequestType.GET_CUSTOM_LLM_REPORT) {
const { customLLMReportRequest } = args;
if (!customLLMReportRequest) {
throw new Error("customLLMReportRequest is required");
}
const { promptUuid, promptType, rangeStart, rangeEnd, interval } = customLLMReportRequest;
const report = await getCustomLLMReport(promptUuid, promptType, new Date(rangeStart).getTime(), new Date(rangeEnd).getTime(), interval, requestModifiers, sessionId);
// Log the report data to help debug
logger.log("📊 Custom LLM Report Tool Response:", JSON.stringify({
promptType,
hasError: report?.error,
dataPointsCount: report?.timeSeriesDataPoints?.length,
firstDataPoint: report?.timeSeriesDataPoints?.[0],
}));
return {
content: [
{
type: "text",
text: JSON.stringify(report),
},
],
structuredContent: {
customLLMReport: report,
},
};
}
if (requestType === RequestType.GET_AUDIT_FEED) {
const { auditFeedRequest } = args;
if (!auditFeedRequest) {
throw new Error("auditFeedRequest is required");
}
const report = await getAuditFeed(new Date(auditFeedRequest.startTime).getTime(), new Date(auditFeedRequest.endTime).getTime(), requestModifiers, sessionId);
return {
content: [{ type: "text", text: JSON.stringify(report) }],
structuredContent: { auditFeedReport: report },
};
}
if (requestType === RequestType.GET_DIAGNOSTIC_FEED) {
const { diagnosticFeedRequest } = args;
if (!diagnosticFeedRequest) {
throw new Error("diagnosticFeedRequest is required");
}
const report = await getDiagnosticFeed(new Date(diagnosticFeedRequest.startTime).getTime(), new Date(diagnosticFeedRequest.endTime).getTime(), requestModifiers, sessionId);
return {
content: [{ type: "text", text: JSON.stringify(report) }],
structuredContent: { diagnosticFeedReport: report },
};
}
if (requestType === RequestType.GET_THRESHOLD_CROSSING_EVENTS) {
const { thresholdCrossingEventsRequest } = args;
if (!thresholdCrossingEventsRequest) {
throw new Error("thresholdCrossingEventsRequest is required");
}
const report = await getThresholdCrossingEvents(thresholdCrossingEventsRequest.deviceUuid, new Date(thresholdCrossingEventsRequest.startTime).getTime(), new Date(thresholdCrossingEventsRequest.endTime).getTime(), requestModifiers, sessionId);
return {
content: [{ type: "text", text: JSON.stringify(report) }],
structuredContent: { thresholdCrossingEventsReport: report },
};
}
if (requestType === RequestType.GET_CUSTOM_EVENTS_REPORT) {
const { customEventsReportRequest } = args;
if (!customEventsReportRequest) {
throw new Error("customEventsReportRequest is required");
}
const report = await getCustomEventsReport(customEventsReportRequest.promptUuid, new Date(customEventsReportRequest.startTime).getTime(), new Date(customEventsReportRequest.endTime).getTime(), customEventsReportRequest.interval, requestModifiers, sessionId);
return {
content: [{ type: "text", text: JSON.stringify(report) }],
structuredContent: { customEventsReport: report },
};
}
if (requestType === RequestType.GET_PEOPLE_COUNT_EVENTS) {
const { peopleCountEventsRequest } = args;
if (!peopleCountEventsRequest) {
throw new Error("peopleCountEventsRequest is required");
}
const report = await getPeopleCountEvents(peopleCountEventsRequest.deviceUuids, requestModifiers, sessionId);
return {
content: [{ type: "text", text: JSON.stringify(report) }],
structuredContent: { peopleCountEventsReport: report },
};
}
return {
content: [
{
type: "text",
text: "",
},
],
structuredContent: {
error: true,
errorMsg: "Error while fetching report information",
},
};
};
export function createTool(server) {
server.registerTool(TOOL_NAME, {
title: "Reports",
description: TOOL_DESCRIPTION,
inputSchema: TOOL_ARGS.shape,
outputSchema: OUTPUT_SCHEMA.shape,
annotations: { readOnlyHint: true },
}, TOOL_HANDLER);
}