rhombus-node-mcp 0.1.8 → 0.1.10

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 (2) hide show
  1. package/dist/index.js +197 -45
  2. package/package.json +4 -1
package/dist/index.js CHANGED
@@ -2,6 +2,9 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
+ import { CreateVideoWallOptions } from "./types.js";
6
+ import { parse } from "chrono-node";
7
+ import { DateTime } from "luxon";
5
8
  const THREE_HOURS_MS = 3 * 60 * 60 * 1000;
6
9
  const FIVE_SECONDS_MS = 5 * 1000;
7
10
  const RHOMBUS_API_KEY = process.env.RHOMBUS_API_KEY;
@@ -9,15 +12,17 @@ if (!RHOMBUS_API_KEY) {
9
12
  console.error("Missing RHOMBUS_API_KEY");
10
13
  }
11
14
  const enableLogs = process.env.ENABLE_LOGS;
12
- const BASE_URL = "https://api2.rhombussystems.com/api";
15
+ const serverUrl = process.env.RHOMBUS_API_SERVER || "api2.rhombussystems.com";
16
+ console.error("🌐 Using server url", serverUrl);
17
+ const BASE_URL = `https://${serverUrl}/api`;
13
18
  const STATIC_HEADERS = {
14
19
  "Content-Type": "application/json",
20
+ "x-rhombus-agent": "chatbot",
15
21
  accept: "application/json",
16
22
  };
17
23
  const AUTH_HEADERS = {
18
24
  "x-auth-apikey": RHOMBUS_API_KEY,
19
25
  "x-auth-scheme": "api-token",
20
- "x-rhombus-agent": "chatbot",
21
26
  };
22
27
  const server = new McpServer({
23
28
  name: "rhombus",
@@ -28,8 +33,11 @@ const server = new McpServer({
28
33
  },
29
34
  });
30
35
  const STATIC_ARGS = {
31
- headers: z
32
- .optional(z.any())
36
+ requestModifiers: z
37
+ .optional(z.object({
38
+ headers: z.optional(z.any()),
39
+ query: z.optional(z.any()),
40
+ }))
33
41
  .describe("Optional headers accepted by tools. LLM should never ever use this. 😅"),
34
42
  };
35
43
  const log = (msg) => {
@@ -37,16 +45,34 @@ const log = (msg) => {
37
45
  return;
38
46
  console.error(msg);
39
47
  };
40
- async function postApi(url, body, customHeaders) {
41
- let headers = {
42
- ...(customHeaders || AUTH_HEADERS),
48
+ const appendQueryParams = (url, params) => {
49
+ if (!params || typeof params !== "object")
50
+ return url;
51
+ const urlObj = new URL(url);
52
+ const existingSearchParams = new URLSearchParams(urlObj.search);
53
+ for (const [key, value] of Object.entries(params)) {
54
+ if (value !== undefined && value !== null) {
55
+ existingSearchParams.append(key, String(value));
56
+ }
57
+ }
58
+ const baseUrl = url.split("?")[0];
59
+ const queryString = existingSearchParams.toString();
60
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl;
61
+ };
62
+ async function postApi(url, body, modifiers) {
63
+ let requestHeaders = {
64
+ ...(modifiers?.headers || AUTH_HEADERS),
43
65
  ...STATIC_HEADERS,
44
66
  };
67
+ if (modifiers?.query) {
68
+ url = appendQueryParams(url, modifiers.query);
69
+ }
45
70
  try {
46
- log(`[POSTAPI] REQUEST - ${url} - ${body} - ${JSON.stringify(customHeaders)}`);
47
- const response = await fetch(url, { method: "POST", headers, body });
48
- log(`[POSTAPI] RESPONSE - ${JSON.stringify(response || {})}`);
71
+ log(`[POSTAPI] REQUEST - ${url} - ${body} - ${JSON.stringify(requestHeaders)}`);
72
+ const response = await fetch(url, { method: "POST", headers: requestHeaders, body });
73
+ log(`[POSTAPI] RESPONSE - ${JSON.stringify(response)}`);
49
74
  if (!response.ok) {
75
+ log(`❌ RESPONSE - ${response.ok} - ${response.status}`);
50
76
  if (response.status === 401 || response.status === 403) {
51
77
  return {
52
78
  error: true,
@@ -55,36 +81,39 @@ async function postApi(url, body, customHeaders) {
55
81
  }
56
82
  throw new Error(`HTTP error! status: ${response.status}`);
57
83
  }
58
- return await response.json();
84
+ const ret = await response.json();
85
+ log(`❌ RESPONSE - ${response.ok} - ${JSON.stringify(ret)}`);
86
+ return ret;
59
87
  }
60
88
  catch (error) {
89
+ log(`[POSTAPI] ERROR - ${JSON.stringify(error || {})}`);
61
90
  return {
62
91
  error: true,
63
92
  status: `Request Error: ${error}`,
64
93
  };
65
94
  }
66
95
  }
67
- async function getOrg(headers) {
96
+ async function getOrg(requestModifiers) {
68
97
  const url = BASE_URL + "/org/getOrgV2";
69
- return await postApi(url, "{}", headers);
98
+ return await postApi(url, "{}", requestModifiers);
70
99
  }
71
- async function getLocations(headers) {
100
+ async function getLocations(requestModifiers) {
72
101
  const url = BASE_URL + "/location/getLocationsV2";
73
- return await postApi(url, "{}", headers);
102
+ return await postApi(url, "{}", requestModifiers);
74
103
  }
75
- async function getCameraList(headers) {
104
+ async function getCameraList(requestModifiers) {
76
105
  const url = BASE_URL + "/camera/getMinimalCameraStateList";
77
- return await postApi(url, "{}", headers).then(response => {
106
+ return await postApi(url, "{}", requestModifiers).then(response => {
78
107
  return {
79
108
  cameraStates: response.cameraStates.filter((camera) => !!camera.locationUuid),
80
109
  };
81
110
  });
82
111
  }
83
- async function getAccessControlledDoors(headers) {
112
+ async function getAccessControlledDoors(requestModifiers) {
84
113
  const url = BASE_URL + "/component/findAccessControlledDoors";
85
- return await postApi(url, "{}", headers);
114
+ return await postApi(url, "{}", requestModifiers);
86
115
  }
87
- async function getFaceEvents(_locationUuid, headers) {
116
+ async function getFaceEvents(_locationUuid, requestModifiers) {
88
117
  const nowMs = Date.now();
89
118
  const rangeStartMs = nowMs - THREE_HOURS_MS;
90
119
  const rangeEndMs = nowMs - FIVE_SECONDS_MS;
@@ -105,7 +134,7 @@ async function getFaceEvents(_locationUuid, headers) {
105
134
  },
106
135
  },
107
136
  });
108
- const response = await postApi(BASE_URL + "/faceRecognition/faceEvent/findFaceEventsByOrg", body, headers).then(response => {
137
+ const response = await postApi(BASE_URL + "/faceRecognition/faceEvent/findFaceEventsByOrg", body, requestModifiers).then(response => {
109
138
  return {
110
139
  faceEvents: (response.faceEvents || []).map((event) => ({
111
140
  ...event,
@@ -115,13 +144,13 @@ async function getFaceEvents(_locationUuid, headers) {
115
144
  });
116
145
  return response;
117
146
  }
118
- async function getAccessControlEvents(doorUuid, headers) {
147
+ async function getAccessControlEvents(doorUuid, requestModifiers) {
119
148
  const url = BASE_URL + "/component/findComponentEventsByAccessControlledDoor";
120
149
  const body = JSON.stringify({
121
150
  limit: 50,
122
151
  accessControlledDoorUuid: doorUuid,
123
152
  });
124
- const response = await postApi(url, body, headers).then(response => ({
153
+ const response = await postApi(url, body, requestModifiers).then(response => ({
125
154
  componentEvents: (response.componentEvents || []).map((event) => ({
126
155
  ...event,
127
156
  timestamp: new Date(event.timestampMs).toString(),
@@ -129,14 +158,14 @@ async function getAccessControlEvents(doorUuid, headers) {
129
158
  }));
130
159
  return response;
131
160
  }
132
- async function rebootCameras(cameraUuids, headers) {
161
+ async function rebootCameras(cameraUuids, requestModifiers) {
133
162
  const url = BASE_URL + "/camera/reboot";
134
163
  let successCount = 0;
135
164
  let errorCount = 0;
136
165
  for (const cameraUuid in cameraUuids) {
137
166
  try {
138
167
  const body = JSON.stringify({ cameraUuid: cameraUuid });
139
- const response = await postApi(url, body, headers);
168
+ const response = await postApi(url, body, requestModifiers);
140
169
  if (response.error) {
141
170
  errorCount++;
142
171
  }
@@ -158,7 +187,26 @@ async function rebootCameras(cameraUuids, headers) {
158
187
  return { status, successCount, errorCount };
159
188
  }
160
189
  }
161
- async function getImageForCameraAtTime(cameraUuid, timestampMs, headers) {
190
+ async function createVideoWall(options, headers) {
191
+ const url = BASE_URL + "/camera/createVideoWall";
192
+ const body = JSON.stringify({
193
+ videoWall: {
194
+ displayName: options?.displayName,
195
+ deviceList: options?.deviceList,
196
+ othersCanEdit: true,
197
+ orgUuid: options?.orgUuid,
198
+ shared: true,
199
+ settings: {
200
+ gridSize: { width: options?.settings.columnCount, height: options?.settings.columnCount },
201
+ gridLayout: "1 2\n3 4",
202
+ intervalSeconds: options?.settings.intervalSeconds || 5,
203
+ },
204
+ },
205
+ });
206
+ const response = await postApi(url, body, headers);
207
+ return response;
208
+ }
209
+ async function getImageForCameraAtTime(cameraUuid, timestampMs, requestModifiers) {
162
210
  const url = BASE_URL + "/video/getExactFrameUri";
163
211
  const body = JSON.stringify({
164
212
  cameraUuid: cameraUuid,
@@ -170,8 +218,12 @@ async function getImageForCameraAtTime(cameraUuid, timestampMs, headers) {
170
218
  permyriadCropY: 0,
171
219
  timestampMs: timestampMs,
172
220
  });
173
- const base64Image = await postApi(url, body, headers).then(async (res) => {
174
- return await fetch(res.frameUri, { method: "GET", headers: headers }).then(async (res) => {
221
+ const base64Image = await postApi(url, body, requestModifiers).then(async (res) => {
222
+ let requestHeaders = {
223
+ ...(requestModifiers?.headers || AUTH_HEADERS),
224
+ ...STATIC_HEADERS,
225
+ };
226
+ return await fetch(res.frameUri, { method: "GET", headers: requestHeaders }).then(async (res) => {
175
227
  if (!res.ok)
176
228
  return null;
177
229
  const arrayBuffer = await res.arrayBuffer();
@@ -193,8 +245,8 @@ async function getImageForCameraAtTime(cameraUuid, timestampMs, headers) {
193
245
  imageData: base64Image,
194
246
  };
195
247
  }
196
- server.tool("get-org-information", "Get general information about the organization including org name, camera configuration defaults, contact information, and org settings.", { ...STATIC_ARGS }, async ({ headers }) => {
197
- const org = await getOrg(headers);
248
+ server.tool("get-org-information", "Get general information about the organization including org name, camera configuration defaults, contact information, and org settings.", { ...STATIC_ARGS }, async ({ requestModifiers }) => {
249
+ const org = await getOrg(requestModifiers);
198
250
  return {
199
251
  content: [
200
252
  {
@@ -204,19 +256,117 @@ server.tool("get-org-information", "Get general information about the organizati
204
256
  ],
205
257
  };
206
258
  });
259
+ async function handleCreateVideoWallRequest(videoWallCreateOptions, headers) {
260
+ let text = "Unable to create video wall!";
261
+ // console.error("🔨 Creating video wall");
262
+ if (!videoWallCreateOptions?.displayName) {
263
+ text = JSON.stringify({
264
+ needUserInput: true,
265
+ commandForUser: "What should the name of the video wall be?",
266
+ });
267
+ }
268
+ else if ((videoWallCreateOptions?.deviceList || []).length === 0) {
269
+ text = JSON.stringify({
270
+ needUserInput: true,
271
+ commandForUser: "Which cameras would you like on this video wall?",
272
+ });
273
+ }
274
+ else {
275
+ // console.error("Creating video wall with options: ", JSON.stringify(videoWallCreateOptions));
276
+ text = JSON.stringify(await createVideoWall(videoWallCreateOptions, headers));
277
+ }
278
+ return Promise.resolve({
279
+ content: [
280
+ {
281
+ type: "text",
282
+ text,
283
+ },
284
+ ],
285
+ });
286
+ }
287
+ server.tool("create-tool", "Tool for creating many entity types such as video walls.", {
288
+ entityType: z.enum(["video-wall"]).describe("The entity type to create. Example: video wall."),
289
+ videoWallCreateOptions: CreateVideoWallOptions,
290
+ ...STATIC_ARGS,
291
+ }, async ({ entityType, videoWallCreateOptions, requestModifiers }) => {
292
+ switch (entityType) {
293
+ case "video-wall":
294
+ return await handleCreateVideoWallRequest(videoWallCreateOptions, requestModifiers);
295
+ default:
296
+ }
297
+ return {
298
+ content: [
299
+ {
300
+ type: "text",
301
+ text: "",
302
+ },
303
+ ],
304
+ };
305
+ });
306
+ function nullToUndefined(value) {
307
+ return value === null ? undefined : value;
308
+ }
309
+ server.tool("time-tool", "Tool for converting a natural language time description into a timestamp in milliseconds.", {
310
+ time_description: z
311
+ .string()
312
+ .describe("A natural language description of the time (e.g., '2pm today', 'tomorrow at noon')."),
313
+ timezone: z
314
+ .optional(z.string())
315
+ .describe("Optional IANA timezone string (e.g., 'America/Los_Angeles', 'UTC'). Defaults to system timezone."),
316
+ }, async ({ time_description, timezone }) => {
317
+ // console.error(`🕛 handling tool call for time ${time_description} using timezone ${timezone}`);
318
+ const now = DateTime.now()
319
+ .setZone(timezone || undefined)
320
+ .toJSDate();
321
+ const parsed = parse(time_description, now, { forwardDate: true });
322
+ if (!parsed || parsed.length === 0) {
323
+ throw new Error(`Could not parse time description: ${time_description}`);
324
+ }
325
+ const dateComponents = parsed[0].start;
326
+ if (!dateComponents) {
327
+ throw new Error("Parsed time has no start component");
328
+ }
329
+ const dt = DateTime.fromObject({
330
+ year: nullToUndefined(dateComponents.get("year")),
331
+ month: nullToUndefined(dateComponents.get("month")),
332
+ day: nullToUndefined(dateComponents.get("day")),
333
+ hour: nullToUndefined(dateComponents.get("hour")),
334
+ minute: nullToUndefined(dateComponents.get("minute")),
335
+ second: nullToUndefined(dateComponents.get("second")),
336
+ millisecond: 0,
337
+ }, {
338
+ zone: timezone || "local",
339
+ });
340
+ if (!dt.isValid) {
341
+ throw new Error(`Could not construct valid DateTime: ${dt.invalidReason}`);
342
+ }
343
+ const timestamp = dt.toMillis();
344
+ return {
345
+ content: [
346
+ {
347
+ type: "text",
348
+ text: JSON.stringify({
349
+ timestamp,
350
+ iso: dt.toISO(),
351
+ timezone: dt.zoneName,
352
+ }),
353
+ },
354
+ ],
355
+ };
356
+ });
207
357
  server.tool("get-entity-tool", "get a list of entities like cameras, access controlled doors, sensors, etc", {
208
358
  entityType: z
209
359
  .enum(["camera", "access-controlled-doors"])
210
360
  .describe("The entity type to retreive. Example: cameras."),
211
361
  ...STATIC_ARGS,
212
- }, async ({ entityType, headers }) => {
362
+ }, async ({ entityType, requestModifiers }) => {
213
363
  let ret;
214
364
  switch (entityType) {
215
365
  case "camera":
216
- ret = await getCameraList(headers);
366
+ ret = await getCameraList(requestModifiers);
217
367
  break;
218
368
  case "access-controlled-doors":
219
- ret = await getAccessControlledDoors(headers);
369
+ ret = await getAccessControlledDoors(requestModifiers);
220
370
  break;
221
371
  default:
222
372
  ret = {};
@@ -231,12 +381,14 @@ server.tool("get-entity-tool", "get a list of entities like cameras, access cont
231
381
  ],
232
382
  };
233
383
  });
234
- server.tool("camera-tool", "get specific requested information about a camera such as an image snapshot, or detailed analytics info. this can be used to answer questions about tracking people across cameras", {
384
+ server.tool("camera-tool", 'This tool captures and returns a real-time snapshot from a designated security camera. The image reflects the current scene in the camera\'s field of view and serves as a contextual input source for downstream tasks such as object recognition, anomaly detection, incident investigation, or situational assessment. When invoked, the tool provides the following: \n • Visual Scene Capture: A high-resolution image of what the camera is actively observing, including people, vehicles, license plates, and any detectable objects. \n• Scene Context: Metadata such as camera ID, location name, timestamp, and motion detection status if available. \n• Enriched Data Potential: The image can be paired with AI models or downstream analytics to extract insights such as: \n• Number and type of objects in frame (e.g., humans, cars, packages) \n• Unusual behaviors (e.g., loitering, unauthorized access) \n• Environmental conditions (e.g., lighting, obstruction, cleanliness) \nUse Cases: \n• Verify what triggered a motion alert or analytic rule. \n• Provide visual context for access events or alarms. \n• Support live incident triage or retrospective investigations. \n• Feed contextual imagery to agents making security or operational decisions. \nInvocation Notes: \nTo use this tool correctly, the agent should provide the specific camera identifier or location name. If possible, include the intent (e.g., "verify unauthorized access", "identify vehicle", "check for obstructions") to enhance downstream processing or summarization.', {
235
385
  requestType: z.enum(["image"]),
236
- timestampMs: z.optional(z.number()).describe("the timestamp in milliseconds"),
386
+ timestampMs: z
387
+ .optional(z.number())
388
+ .describe("the timestamp in milliseconds which should always be obtained using time-tool"),
237
389
  cameraUuid: z.optional(z.string()).describe("the camera uuid requested"),
238
390
  ...STATIC_ARGS,
239
- }, async ({ cameraUuid, timestampMs, requestType, headers }) => {
391
+ }, async ({ cameraUuid, timestampMs, requestType, requestModifiers }) => {
240
392
  if (!cameraUuid) {
241
393
  return {
242
394
  content: [
@@ -266,7 +418,7 @@ server.tool("camera-tool", "get specific requested information about a camera su
266
418
  let response;
267
419
  switch (requestType) {
268
420
  case "image":
269
- response = await getImageForCameraAtTime(cameraUuid, timestampMs, headers);
421
+ response = await getImageForCameraAtTime(cameraUuid, timestampMs, requestModifiers);
270
422
  if (!response.success || !response.imageData) {
271
423
  return {
272
424
  content: [{ type: "text", text: JSON.stringify(response) }],
@@ -303,9 +455,9 @@ server.tool("events-tool", "event data for certain types of information like fac
303
455
  locationUuid: z.optional(z.string()),
304
456
  accessControlledDoorUuid: z.optional(z.string()),
305
457
  ...STATIC_ARGS,
306
- }, async ({ eventType, locationUuid, accessControlledDoorUuid, headers }) => {
458
+ }, async ({ eventType, locationUuid, accessControlledDoorUuid, requestModifiers }) => {
307
459
  if (eventType === "faces" || eventType === "people") {
308
- const response = await getFaceEvents(locationUuid, headers);
460
+ const response = await getFaceEvents(locationUuid, requestModifiers);
309
461
  return {
310
462
  content: [
311
463
  {
@@ -330,7 +482,7 @@ server.tool("events-tool", "event data for certain types of information like fac
330
482
  };
331
483
  }
332
484
  else {
333
- const events = await getAccessControlEvents(accessControlledDoorUuid, headers);
485
+ const events = await getAccessControlEvents(accessControlledDoorUuid, requestModifiers);
334
486
  return {
335
487
  content: [
336
488
  {
@@ -351,14 +503,14 @@ server.tool("events-tool", "event data for certain types of information like fac
351
503
  };
352
504
  });
353
505
  server.tool("location-tool", "contains basic operations for locations and response in JSON format.", {
354
- action: z.enum(["get"]),
506
+ action: z.enum(["get", "update"]),
355
507
  locationUpdate: z.optional(z.object({ uuid: z.string(), name: z.optional(z.string()) })),
356
508
  ...STATIC_ARGS,
357
- }, async ({ action, locationUpdate, headers }) => {
509
+ }, async ({ action, locationUpdate, requestModifiers }) => {
358
510
  let ret;
359
511
  switch (action) {
360
512
  case "get":
361
- ret = await getLocations(headers);
513
+ ret = await getLocations(requestModifiers);
362
514
  break;
363
515
  default:
364
516
  ret = { error: true, status: `unsupported location tool call: ${action}` };
@@ -373,8 +525,8 @@ server.tool("reboot-cameras", "this tool is for rebooting one or more cameras ca
373
525
  .array(z.string())
374
526
  .describe("An array of camera UUID strings which are unique identifiers for cameras"),
375
527
  ...STATIC_ARGS,
376
- }, async ({ cameraUuids, headers }) => {
377
- const cameraRebootData = await rebootCameras(cameraUuids, headers);
528
+ }, async ({ cameraUuids, requestModifiers }) => {
529
+ const cameraRebootData = await rebootCameras(cameraUuids, requestModifiers);
378
530
  if (!cameraRebootData) {
379
531
  return {
380
532
  content: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhombus-node-mcp",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "MCP server for Rhombus API",
5
5
  "keywords": [
6
6
  "ai",
@@ -33,9 +33,12 @@
33
33
  ],
34
34
  "dependencies": {
35
35
  "@modelcontextprotocol/sdk": "^1.9.0",
36
+ "chrono-node": "^2.8.0",
37
+ "luxon": "^3.6.1",
36
38
  "zod": "^3.24.2"
37
39
  },
38
40
  "devDependencies": {
41
+ "@types/luxon": "^3.6.2",
39
42
  "@types/node": "^22.14.0",
40
43
  "prettier": "3.5.3",
41
44
  "typescript": "^5.8.3"