rhombus-node-mcp 0.1.45 → 0.1.46

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,70 +1,65 @@
1
1
  import { getLogger } from "../logger.js";
2
- import { constructRequestHeaders, postApi } from "../network/network.js";
2
+ import { postApi } from "../network/network.js";
3
3
  import { removeNullFields } from "../util.js";
4
4
  const logger = getLogger("camera-tool");
5
5
  const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
6
- export async function getImageForCameraAtTime(cameraUuid, timestampMs, requestModifiers, sessionId) {
6
+ /** Convert a 0-100 percentage to permyriad (0-10000), clamped to the valid range. */
7
+ function pctToPermyriad(pct) {
8
+ return Math.round(Math.max(0, Math.min(100, pct)) * 100);
9
+ }
10
+ export async function getImageForCameraAtTime(cameraUuid, timestampMs, requestModifiers, sessionId, options) {
11
+ // biome-ignore lint/suspicious/noExplicitAny: request body is a loose JSON shape
7
12
  const body = {
8
- cameraUuid: cameraUuid,
9
- downscaleFactor: 10,
13
+ cameraUuid,
14
+ timestampMs,
15
+ downscaleFactor: options?.downscaleFactor ?? 10,
10
16
  jpgQuality: 70,
11
- timestampMs: timestampMs,
12
17
  };
13
- logger.debug(`Getting frameUri from UUID: ${cameraUuid} at timestampMs: ${timestampMs}`);
14
- let frameUri;
15
- const base64Image = await postApi({
16
- route: "/video/getExactFrameUri",
18
+ const crop = options?.crop;
19
+ const hasCrop = crop != null &&
20
+ (crop.x != null || crop.y != null || crop.width != null || crop.height != null);
21
+ const resolvedCrop = hasCrop
22
+ ? {
23
+ x: crop.x ?? 0,
24
+ y: crop.y ?? 0,
25
+ width: crop.width ?? 100,
26
+ height: crop.height ?? 100,
27
+ }
28
+ : null;
29
+ if (resolvedCrop) {
30
+ body.permyriadCropX = pctToPermyriad(resolvedCrop.x);
31
+ body.permyriadCropY = pctToPermyriad(resolvedCrop.y);
32
+ body.permyriadCropWidth = pctToPermyriad(resolvedCrop.width);
33
+ body.permyriadCropHeight = pctToPermyriad(resolvedCrop.height);
34
+ }
35
+ logger.debug(`Getting exact frame data for UUID: ${cameraUuid} at timestampMs: ${timestampMs}` +
36
+ (hasCrop ? ` (cropped)` : ``));
37
+ const res = await postApi({
38
+ route: "/video/getExactFrameData",
17
39
  body,
18
40
  modifiers: requestModifiers,
19
41
  sessionId,
20
- }).then(async (res) => {
21
- logger.debug(`Received frameUri ${res.frameUri}`);
22
- if (!res.frameUri) {
23
- throw new Error("No frameUri given. Maybe camera does not support it.");
24
- }
25
- // construct request headers
26
- const { url: _frameUri, requestHeaders } = constructRequestHeaders(res.frameUri, requestModifiers, sessionId);
27
- frameUri = _frameUri;
28
- // remove content type
29
- delete requestHeaders["Content-Type"];
30
- delete requestHeaders["accept"];
31
- logger.debug(`Fetching with headers\n${JSON.stringify(requestHeaders, null, 2)}`);
32
- return await fetch(frameUri, {
33
- method: "GET",
34
- headers: requestHeaders,
35
- }).then(async (res) => {
36
- if (!res.ok) {
37
- logger.error(`Failed to fetch image (HTTP ${res.status}): ${await res.text()}`);
38
- logger.error(res);
39
- return res.status === 404 ? 404 : null;
40
- }
41
- const arrayBuffer = await res.arrayBuffer();
42
- const buffer = Buffer.from(arrayBuffer);
43
- const base64 = buffer.toString("base64");
44
- logger.debug(`Received image base64:\n ${base64}`);
45
- return base64;
46
- });
47
42
  });
48
- if (!base64Image) {
49
- return {
50
- success: false,
51
- status: "failed to fetch image",
52
- };
53
- }
54
- if (base64Image === 404) {
43
+ if (res.error || !res.frameData) {
44
+ logger.error(`getExactFrameData failed: ${JSON.stringify({ error: res.error, errorMsg: res.errorMsg, status: res.status, hasFrameData: !!res.frameData })}`);
55
45
  return {
56
46
  success: false,
57
47
  status: "failed to fetch image",
58
- message: "Camera snapshot unavailable: this camera has no recorded video (VOD) at the requested time. " +
59
- "It may not have any footage stored, or the requested timestamp may be outside its retention window.",
48
+ // Prefer a structured business error from the endpoint, then any transport-level
49
+ // status set by postApi (e.g. permission/HTTP errors), and only fall back to the
50
+ // no-VOD explanation when no error detail is available.
51
+ message: res.errorMsg ??
52
+ res.status ??
53
+ "Camera snapshot unavailable: this camera may have no recorded video (VOD) at the requested time. " +
54
+ "It may not have any footage stored, or the requested timestamp may be outside its retention window.",
60
55
  };
61
56
  }
62
57
  return {
63
58
  success: true,
64
59
  status: "successfully fetched image",
65
- frameUri: frameUri ?? "",
66
60
  imageType: "base64",
67
- imageData: base64Image,
61
+ imageData: res.frameData,
62
+ crop: resolvedCrop,
68
63
  };
69
64
  }
70
65
  async function getCameraStorageData(cameraUuid, requestModifiers, sessionId) {
@@ -21,8 +21,8 @@ This tool captures and returns a real-time snapshot from a designated security c
21
21
  The image reflects the current scene in the camera's field of view and serves as a contextual
22
22
  input source for downstream tasks such as object recognition, anomaly detection, incident investigation,
23
23
  or situational assessment. When invoked, the tool provides the following:
24
- - Visual Scene Capture: A high-resolution image of what the camera is actively observing, including people, vehicles, license plates, and any detectable objects.
25
- - The frameUri that was used to fetch the image. It may be useful to show the user this image as well through the frameUri.
24
+ - Visual Scene Capture: A high-resolution image of what the camera is actively observing, including people, vehicles, license plates, and any detectable objects.
25
+ - Optional zoom: pass cropX, cropY, cropWidth, cropHeight (each a percentage 0-100, origin at the top-left) to return only a sub-region of the frame so you can inspect a detail (e.g. a license plate or a doorway) more closely. Omit them for the full frame. When zooming into a small crop, pass a smaller downscaleFactor (e.g. 1-3) to preserve detail.
26
26
 
27
27
  What follows is a description of the behavior of this tool given the requestType "get-settings"
28
28
 
@@ -56,7 +56,7 @@ Examples that REQUIRE the automatic snapshot flow:
56
56
  const logger = getLogger("camera-tool");
57
57
  const TOOL_ARGS = BASE_TOOL_ARGS;
58
58
  const TOOL_HANDLER = async (args, extra) => {
59
- const { cameraUuid, timestampISO, requestType } = args;
59
+ const { cameraUuid, timestampISO, requestType, cropX, cropY, cropWidth, cropHeight, downscaleFactor } = args;
60
60
  if (!cameraUuid) {
61
61
  return {
62
62
  content: [
@@ -76,13 +76,21 @@ const TOOL_HANDLER = async (args, extra) => {
76
76
  const { requestModifiers, sessionId } = extractFromToolExtra(extra);
77
77
  switch (requestType) {
78
78
  case "image":
79
- response = await getImageForCameraAtTime(cameraUuid, timestampMs, requestModifiers, sessionId);
79
+ response = await getImageForCameraAtTime(cameraUuid, timestampMs, requestModifiers, sessionId, {
80
+ crop: {
81
+ x: cropX ?? null,
82
+ y: cropY ?? null,
83
+ width: cropWidth ?? null,
84
+ height: cropHeight ?? null,
85
+ },
86
+ downscaleFactor: downscaleFactor ?? null,
87
+ });
80
88
  if (!response.success || !response.imageData) {
81
89
  return {
82
90
  content: [{ type: "text", text: JSON.stringify(response) }],
83
91
  };
84
92
  }
85
- logger.debug(`Received image response:\n ${JSON.stringify(response)}`);
93
+ logger.debug(`Received image response (base64 length ${response.imageData.length})`);
86
94
  return {
87
95
  content: [
88
96
  {
@@ -97,7 +105,7 @@ const TOOL_HANDLER = async (args, extra) => {
97
105
  status: "image-attached",
98
106
  cameraUuid,
99
107
  timestampMs,
100
- frameUri: response.frameUri,
108
+ cropApplied: response.crop ?? null,
101
109
  }),
102
110
  },
103
111
  ],
@@ -221,5 +221,39 @@ export const BASE_TOOL_ARGS = {
221
221
  the timestamp for the image. This will default to 5 minutes before the current time. You can also call time-tool to parse the user's time description.
222
222
  ` + ISOTimestampFormatDescription),
223
223
  requestType: z.enum(["image", "get-settings", "get-media-uris", "get-ai-thresholds"]),
224
+ cropX: z
225
+ .number()
226
+ .min(0)
227
+ .max(100)
228
+ .nullable()
229
+ .optional()
230
+ .describe("For requestType 'image' only. Left edge of the crop region as a percentage (0-100) of image width, measured from the left. 0 = left edge. Omit for full frame."),
231
+ cropY: z
232
+ .number()
233
+ .min(0)
234
+ .max(100)
235
+ .nullable()
236
+ .optional()
237
+ .describe("For requestType 'image' only. Top edge of the crop region as a percentage (0-100) of image height, measured from the top. 0 = top edge. Omit for full frame."),
238
+ cropWidth: z
239
+ .number()
240
+ .min(0)
241
+ .max(100)
242
+ .nullable()
243
+ .optional()
244
+ .describe("For requestType 'image' only. Width of the crop region as a percentage (0-100) of image width. Omit for full frame (100)."),
245
+ cropHeight: z
246
+ .number()
247
+ .min(0)
248
+ .max(100)
249
+ .nullable()
250
+ .optional()
251
+ .describe("For requestType 'image' only. Height of the crop region as a percentage (0-100) of image height. Omit for full frame (100)."),
252
+ downscaleFactor: z
253
+ .number()
254
+ .min(1)
255
+ .nullable()
256
+ .optional()
257
+ .describe("For requestType 'image' only. Ratio to shrink the image pixels by (default 10). Use a smaller value (e.g. 1-3) when zooming into a crop to keep detail."),
224
258
  };
225
259
  const BASE_TOOL_ARGS_SCHEMA = z.object(BASE_TOOL_ARGS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhombus-node-mcp",
3
- "version": "0.1.45",
3
+ "version": "0.1.46",
4
4
  "description": "MCP server for Rhombus API",
5
5
  "keywords": [
6
6
  "ai",