rhombus-node-mcp 0.1.11 → 0.1.12

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,81 @@
1
+ import { logger } from "./logger.js";
2
+ export const RHOMBUS_API_KEY = process.env.RHOMBUS_API_KEY;
3
+ if (!RHOMBUS_API_KEY) {
4
+ console.error("Missing RHOMBUS_API_KEY");
5
+ }
6
+ export const serverUrl = process.env.RHOMBUS_API_SERVER || "api2.rhombussystems.com";
7
+ export const BASE_URL = `https://${serverUrl}/api`;
8
+ export const STATIC_HEADERS = {
9
+ "Content-Type": "application/json",
10
+ "x-rhombus-agent": "chatbot",
11
+ accept: "application/json",
12
+ };
13
+ export const AUTH_HEADERS = {
14
+ "x-auth-apikey": RHOMBUS_API_KEY,
15
+ "x-auth-scheme": "api-token",
16
+ };
17
+ const enableLogs = process.env.ENABLE_LOGS;
18
+ const log = (msg) => {
19
+ if (!enableLogs)
20
+ return;
21
+ console.error(msg);
22
+ };
23
+ export const appendQueryParams = (url, params) => {
24
+ if (!params || typeof params !== "object")
25
+ return url;
26
+ const urlObj = new URL(url);
27
+ const existingSearchParams = new URLSearchParams(urlObj.search);
28
+ for (const [key, value] of Object.entries(params)) {
29
+ if (value !== undefined && value !== null) {
30
+ existingSearchParams.append(key, String(value));
31
+ }
32
+ }
33
+ const baseUrl = url.split("?")[0];
34
+ const queryString = existingSearchParams.toString();
35
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl;
36
+ };
37
+ export async function postApi(route, body, modifiers = undefined) {
38
+ let requestHeaders = {
39
+ ...(modifiers?.headers ?? AUTH_HEADERS),
40
+ ...STATIC_HEADERS,
41
+ };
42
+ let url = BASE_URL + route;
43
+ if (modifiers?.query) {
44
+ url = appendQueryParams(url, modifiers.query);
45
+ }
46
+ if (typeof body === "object") {
47
+ body = JSON.stringify(body);
48
+ }
49
+ try {
50
+ logger.info(`[POSTAPI] REQUEST - ${url} - ${body} - ${JSON.stringify(requestHeaders)}`);
51
+ const response = await fetch(url, {
52
+ method: "POST",
53
+ headers: requestHeaders,
54
+ body,
55
+ });
56
+ if (!response.ok) {
57
+ logger.debug(`❌ RESPONSE - ${response.ok} - ${response.status}`);
58
+ if (response.status === 401 || response.status === 403) {
59
+ return {
60
+ error: true,
61
+ status: "Sorry, I don't have permission to help with this request. Consider upgrading my permissions by changing the role of the API Key I am using.",
62
+ };
63
+ }
64
+ throw {
65
+ body: JSON.parse(body),
66
+ error: await response.text(),
67
+ };
68
+ // throw new Error(`HTTP error! status: ${response.status}`);
69
+ }
70
+ const ret = await response.json();
71
+ logger.debug(`✅ RESPONSE - ${response.ok} - ${JSON.stringify(ret)}`);
72
+ return ret;
73
+ }
74
+ catch (error) {
75
+ logger.error(`[POSTAPI] ERROR - ${JSON.stringify(error || {}, null, 4)}`);
76
+ return {
77
+ error: true,
78
+ status: `Request Error: ${error}`,
79
+ };
80
+ }
81
+ }
@@ -0,0 +1,19 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import path from "path";
3
+ import { getFilePathsInDirectory } from "../util.js";
4
+ async function getResources() {
5
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
6
+ const filePaths = getFilePathsInDirectory(currentDir);
7
+ const resources = [];
8
+ for (const filePath of filePaths) {
9
+ const imported = (await import(filePath));
10
+ if (imported.createResource !== undefined) {
11
+ resources.push({
12
+ name: filePath,
13
+ create: imported.createResource,
14
+ });
15
+ }
16
+ }
17
+ return resources;
18
+ }
19
+ export default getResources;
@@ -0,0 +1,19 @@
1
+ import fs from "fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "path";
4
+ // change from _createResource to createResource to add it back in
5
+ export function _createResource(server) {
6
+ server.resource("knowledge-base.pdf", "knowledge-base://llms.pdf", async (uri) => {
7
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
8
+ const filePath = path.resolve(currentDir, "../../assets/llms.pdf");
9
+ return {
10
+ contents: [
11
+ {
12
+ uri: uri.href,
13
+ text: fs.readFileSync(filePath).toString(),
14
+ },
15
+ ],
16
+ };
17
+ });
18
+ return;
19
+ }
@@ -0,0 +1,31 @@
1
+ import fs from "fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "path";
4
+ // change from _createResource to createResource to add it back in
5
+ export function createResource(server) {
6
+ server.resource("routes.json", "file://routes.json/", {
7
+ mimeType: "application/json",
8
+ description: `
9
+ This resource provides a comprehensive catalog of application paths within the Rhombus Console, including both common and parameterized routes.
10
+ Each path is accompanied by a brief description derived from KBA support articles, offering context on its function within the product.
11
+ The resource also lists relevant external support links.
12
+ The paths are categorized into "common_paths", "parameterized_paths", and "external_paths" for easy navigation and understanding.
13
+
14
+ It is in the form of a JSON file.
15
+
16
+ Use these paths to help the user navigate to the correct that they may want to go to. However, try not directly reference the path itself, rather describe it, and make sure to show the user a button to help navigate with.
17
+ When providing a path, make sure to be very exact. You're only allowed to substitute in path segments that begin with : or are surrounded by brackets []. For example, /locations/:locationUuid, you need to get a location's UUID and replace :locationUuid with the actual UUID`,
18
+ }, async (uri) => {
19
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
20
+ const filePath = path.resolve(currentDir, "../../assets/routes.json");
21
+ return {
22
+ contents: [
23
+ {
24
+ uri: uri.href,
25
+ text: fs.readFileSync(filePath).toString(),
26
+ },
27
+ ],
28
+ };
29
+ });
30
+ return;
31
+ }
@@ -0,0 +1,62 @@
1
+ import { z } from "zod";
2
+ import { postApi } from "../network.js";
3
+ import { createToolArgs } from "../util.js";
4
+ const ClipsArgs = z.object({
5
+ deviceUuidFilters: z
6
+ .array(z.string())
7
+ .optional()
8
+ .describe("A list of UUIDs representing specific devices to filter clips by. Only clips emitted by these devices will be returned."),
9
+ locationUuidFilters: z
10
+ .array(z.string())
11
+ .optional()
12
+ .describe("A list of UUIDs representing specific locations to filter clips by. Only clips associated with these locations will be returned."),
13
+ searchFilter: z
14
+ .string()
15
+ .optional()
16
+ .describe("A simple string to search for within the names of the clips."),
17
+ timestampMsAfter: z
18
+ .number()
19
+ .describe("The start of the time range (in milliseconds since epoch) for which to retrieve clips. Only clips that occurred AFTER this timestamp will be returned."),
20
+ timestampMsBefore: z
21
+ .number()
22
+ .describe("The end of the time range (in milliseconds since epoch) for which to retrieve clips. Only clips that occurred BEFORE this timestamp will be returned."),
23
+ });
24
+ async function getSavedClips(args, requestModifiers) {
25
+ return await postApi("/event/getClipsWithProgress", args, requestModifiers);
26
+ }
27
+ export function createTool(server) {
28
+ server.tool("clips-tool", `
29
+ Retrieves saved video clips from the Rhombus system. Saved clips can be viewed for up to 2 years and are typically found in the "Clips" tab of the "Saved Video" section of the Rhombus Console.
30
+
31
+ This tool allows you to filter clips by:
32
+ * Specific devices using their UUIDs.
33
+ * Specific locations using their UUIDs.
34
+ * A simple string search on clip names.
35
+ * A time range, specifying a start (timestampMsAfter) and/or end (timestampMsBefore) timestamp in milliseconds since epoch.
36
+
37
+ The tool returns a JSON object with the following structure and important fields:
38
+ * **errorMsg (string | null):** An error message if the request failed.
39
+ * **objecterror (boolean | null):** Indicates if an object-level error occurred.
40
+ * **pageToken (string | null):** A token to be supplied on the next search request to get the next page of results. If this token is null, there is no more data available.
41
+ * **savedClips (array of objects | null):** An array where each object represents a saved video clip. Each clip object contains the following important fields:
42
+ * **uuid (string):** The unique identifier for the video clip.
43
+ * **title (string):** The name given to the video clip.
44
+ * **description (string | null):** An optional description for the clip.
45
+ * **timestampMs (int64):** The start time of the video clip in milliseconds since epoch.
46
+ * **createdAtMs (int64):** The creation timestamp of the clip in milliseconds since epoch.
47
+ * **deviceUuid (string):** The UUID of the primary device (e.g., camera) that recorded the clip.
48
+ * **deviceUuids (array of strings or null):** A list of UUIDs for all devices associated with the clip.
49
+ * **durationSec (int32):** The length of the video clip in seconds.
50
+ * **status (string):** The current processing status of the clip, with possible values such as INITIATING, UPLOADING, RENDERING, FAILED, COMPLETE, OFFLINE, or UNKNOWN.
51
+ * **userUuid (string | null):** The UUID of the user associated with the clip, if applicable.
52
+ * **sourceAlertUuid (string | null):** The UUID of the alert that triggered the creation of this clip, if any.
53
+ `, createToolArgs({
54
+ ...ClipsArgs.shape,
55
+ }), async ({ requestModifiers, ...args }) => {
56
+ let ret;
57
+ ret = await getSavedClips(args, requestModifiers);
58
+ return {
59
+ content: [{ type: "text", text: JSON.stringify(ret) }],
60
+ };
61
+ });
62
+ }
@@ -0,0 +1,80 @@
1
+ import { z } from "zod";
2
+ import { createToolArgs } from "../util.js";
3
+ import { CreateVideoWallOptions } from "../types.js";
4
+ import { postApi } from "../network.js";
5
+ import { logger } from "../logger.js";
6
+ import { addConfirmationParams, requireConfirmation } from "../utils/confirmation.js";
7
+ async function createVideoWall(options, headers) {
8
+ const body = {
9
+ videoWall: {
10
+ displayName: options?.displayName,
11
+ deviceList: options?.deviceList,
12
+ othersCanEdit: true,
13
+ orgUuid: options?.orgUuid,
14
+ shared: true,
15
+ settings: {
16
+ gridSize: { width: options?.settings.columnCount, height: options?.settings.columnCount },
17
+ gridLayout: "1 2\n3 4",
18
+ intervalSeconds: options?.settings.intervalSeconds || 5,
19
+ },
20
+ },
21
+ };
22
+ const response = await postApi("/camera/createVideoWall", body, headers);
23
+ return response;
24
+ }
25
+ async function handleCreateVideoWallRequest(videoWallCreateOptions, headers) {
26
+ let text = "Unable to create video wall!";
27
+ logger.error("🔨 Creating video wall");
28
+ if (!videoWallCreateOptions?.displayName) {
29
+ text = JSON.stringify({
30
+ needUserInput: true,
31
+ commandForUser: "What should the name of the video wall be?",
32
+ });
33
+ }
34
+ else if ((videoWallCreateOptions?.deviceList || []).length === 0) {
35
+ text = JSON.stringify({
36
+ needUserInput: true,
37
+ commandForUser: "Which cameras would you like on this video wall?",
38
+ });
39
+ }
40
+ else {
41
+ logger.error("Creating video wall with options: ", JSON.stringify(videoWallCreateOptions));
42
+ text = JSON.stringify(await createVideoWall(videoWallCreateOptions, headers));
43
+ }
44
+ return Promise.resolve({
45
+ content: [
46
+ {
47
+ type: "text",
48
+ text,
49
+ },
50
+ ],
51
+ });
52
+ }
53
+ export function createTool(server) {
54
+ server.tool("create-tool", "Tool for creating many entity types such as video walls.", addConfirmationParams(createToolArgs({
55
+ entityType: z
56
+ .enum(["video-wall"])
57
+ .describe("The entity type to create. Example: video wall."),
58
+ videoWallCreateOptions: CreateVideoWallOptions,
59
+ })), async ({ entityType, videoWallCreateOptions, requestModifiers, confirmationId }) => {
60
+ const confirmation = requireConfirmation(confirmationId);
61
+ if (confirmation === true) {
62
+ switch (entityType) {
63
+ case "video-wall":
64
+ return await handleCreateVideoWallRequest(videoWallCreateOptions, requestModifiers);
65
+ default:
66
+ }
67
+ return {
68
+ content: [
69
+ {
70
+ type: "text",
71
+ text: "",
72
+ },
73
+ ],
74
+ };
75
+ }
76
+ else {
77
+ return confirmation;
78
+ }
79
+ });
80
+ }
@@ -0,0 +1,218 @@
1
+ import { z } from "zod";
2
+ import { getLogger } from "../../../logger.js";
3
+ import { appendQueryParams, AUTH_HEADERS, postApi, STATIC_HEADERS } from "../../../network.js";
4
+ import { createToolArgs, createToolTextContent, removeNullFields, } from "../../../util.js";
5
+ import { addConfirmationParams, isConfirmed, requireConfirmation, } from "../../../utils/confirmation.js";
6
+ import { ExternalUpdateableFacetedUserConfigSchema, } from "./types.js";
7
+ const logger = getLogger("camera-tool");
8
+ async function getImageForCameraAtTime(cameraUuid, timestampMs, requestModifiers) {
9
+ const body = {
10
+ cameraUuid: cameraUuid,
11
+ downscaleFactor: 10,
12
+ jpgQuality: 70,
13
+ permyriadCropHeight: 10000,
14
+ permyriadCropWidth: 5625,
15
+ permyriadCropX: 2188,
16
+ permyriadCropY: 0,
17
+ timestampMs: timestampMs,
18
+ };
19
+ logger.debug(`Getting frameUri from UUID: ${cameraUuid} at timestampMs: ${timestampMs}`);
20
+ const base64Image = await postApi("/video/getExactFrameUri", body, requestModifiers).then(async (res) => {
21
+ logger.debug(`Received frameUri ${res.frameUri}`);
22
+ // construct request headers
23
+ let requestHeaders = {
24
+ ...(requestModifiers?.headers ?? AUTH_HEADERS),
25
+ ...STATIC_HEADERS,
26
+ };
27
+ // add query params
28
+ if (requestModifiers?.query) {
29
+ res.frameUri = appendQueryParams(res.frameUri, requestModifiers.query);
30
+ }
31
+ logger.trace(`Fetching with headers\n${JSON.stringify(requestHeaders)}`);
32
+ return await fetch(res.frameUri, {
33
+ method: "GET",
34
+ headers: requestHeaders,
35
+ }).then(async (res) => {
36
+ if (!res.ok) {
37
+ logger.error(`Failed to fetch image: ${await res.text()}`);
38
+ logger.error(res);
39
+ return null;
40
+ }
41
+ const arrayBuffer = await res.arrayBuffer();
42
+ const buffer = Buffer.from(arrayBuffer);
43
+ const base64 = buffer.toString("base64");
44
+ return base64;
45
+ });
46
+ });
47
+ if (!base64Image) {
48
+ return {
49
+ success: false,
50
+ status: "failed to fetch image",
51
+ };
52
+ }
53
+ return {
54
+ success: true,
55
+ status: "successfully fetched image",
56
+ imageType: "base64",
57
+ imageData: base64Image,
58
+ };
59
+ }
60
+ export async function getCameraSettings(cameraUuid, requestModifiers) {
61
+ const res = await postApi("/camera/getFacetedConfig", {
62
+ deviceUuid: cameraUuid,
63
+ }, requestModifiers);
64
+ if (res.error) {
65
+ return {
66
+ success: false,
67
+ status: "failed to fetch camera settings",
68
+ };
69
+ }
70
+ return {
71
+ success: true,
72
+ config: res.config,
73
+ status: "fetched camera settings",
74
+ };
75
+ }
76
+ export async function updateCameraSettings(cameraUuid, update, requestModifiers) {
77
+ // remove any "null" values
78
+ const res = await postApi("/camera/updateFacetedConfig", {
79
+ configUpdate: {
80
+ deviceUuid: cameraUuid,
81
+ ...removeNullFields(update),
82
+ },
83
+ }, requestModifiers);
84
+ if (res.error) {
85
+ return {
86
+ success: false,
87
+ status: "failed to update camera settings",
88
+ };
89
+ }
90
+ return {
91
+ success: true,
92
+ config: res.config,
93
+ status: "updated camera settings",
94
+ };
95
+ }
96
+ export function createTool(server) {
97
+ server.tool("camera-tool", `
98
+ This tool can perform some action pertaining to the video stream of a camera. There are three types of requests
99
+ that can be passed into "requestType":
100
+ - image
101
+ - get-settings
102
+ - update-settings
103
+
104
+ What follows is a description of the behavior of this tool given the requestType
105
+
106
+ If the requestType is "image":
107
+
108
+ This tool captures and returns a real-time snapshot from a designated security camera.
109
+ The image reflects the current scene in the camera\'s field of view and serves as a contextual
110
+ input source for downstream tasks such as object recognition, anomaly detection, incident investigation,
111
+ or situational assessment. When invoked, the tool provides the following: \n
112
+ • Visual Scene Capture: A high-resolution image of what the camera is actively observing, including people, vehicles, license plates, and any detectable objects. \n
113
+ • 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.
114
+
115
+ If the requestType is "get-settings":
116
+
117
+ THIS TOOL UPDATES AND SETS DATA.
118
+
119
+ This tool retrieves the current configuration for a specified camera or associated device (e.g., sensor, access controller). The returned JSON object can include detailed camera settings (e.g., resolution, bitrate) and various device-specific configurations.
120
+ Use Cases: Retrieve the current resolution of Camera A.
121
+
122
+ If the requestType is "update-settings":
123
+
124
+ THIS TOOL UPDATES AND SETS DATA.
125
+
126
+ You can call call this tool with requestType "get-settings" and/or with "image" first to get a better idea of what needs to be updated.
127
+ This tool updates the configuration for a camera or associated device using the "configUpdate" parameter, which must be a JSON object containing the specific fields and their new values. For example, you can modify streaming parameters.
128
+ Thus, "configUpdate' is a necessary parameter if updating settings.
129
+ Please make sure you only update the necessary fields, since any unnecessary changes may cause the camera to behave improperly.
130
+ It may be a good idea to call "image" on this tool again after updating settings to make sure the new settings were effective in fulfilling
131
+ the user's request.
132
+ Use Cases: Adjust streaming parameters for Camera E.
133
+ `, addConfirmationParams(createToolArgs({
134
+ requestType: z.enum(["image", "get-settings", "update-settings"]),
135
+ timestampMs: z.optional(z.number()).describe(`
136
+ the timestamp in milliseconds. You can default to the current time if the user didn't specify a time, or you can call time-tool to parse the user's time description
137
+ `),
138
+ cameraUuid: z.optional(z.string()).describe("the camera uuid requested"),
139
+ configUpdate: ExternalUpdateableFacetedUserConfigSchema.optional().describe('the config update that would be applied to the camera if the requestType is "update-settings"'),
140
+ })), async ({ cameraUuid, timestampMs, requestType, configUpdate, requestModifiers, confirmationId, }) => {
141
+ if (!cameraUuid) {
142
+ return {
143
+ content: [
144
+ {
145
+ type: "text",
146
+ text: JSON.stringify({
147
+ needUserInput: true,
148
+ commandForUser: "Which camera are you talking about?",
149
+ }),
150
+ },
151
+ ],
152
+ };
153
+ }
154
+ let response;
155
+ switch (requestType) {
156
+ case "image":
157
+ if (!timestampMs) {
158
+ return {
159
+ content: [
160
+ {
161
+ type: "text",
162
+ text: JSON.stringify({
163
+ needUserInput: true,
164
+ commandForUser: "At what time?",
165
+ }),
166
+ },
167
+ ],
168
+ };
169
+ }
170
+ response = await getImageForCameraAtTime(cameraUuid, timestampMs, requestModifiers);
171
+ if (!response.success || !response.imageData) {
172
+ return {
173
+ content: [{ type: "text", text: JSON.stringify(response) }],
174
+ };
175
+ }
176
+ return {
177
+ content: [
178
+ {
179
+ type: "image",
180
+ data: response.imageData,
181
+ mimeType: "image/jpeg",
182
+ },
183
+ ],
184
+ };
185
+ case "get-settings":
186
+ response = await getCameraSettings(cameraUuid, requestModifiers);
187
+ return {
188
+ content: [{ type: "text", text: JSON.stringify(response) }],
189
+ };
190
+ case "update-settings":
191
+ const confirmation = requireConfirmation(confirmationId);
192
+ if (!isConfirmed(confirmation)) {
193
+ return confirmation;
194
+ }
195
+ if (!configUpdate) {
196
+ return createToolTextContent("Missing configUpdate");
197
+ }
198
+ response = await updateCameraSettings(cameraUuid, configUpdate, requestModifiers);
199
+ return {
200
+ content: [{ type: "text", text: JSON.stringify(response) }],
201
+ };
202
+ default:
203
+ response = {
204
+ error: true,
205
+ status: "missing unknown type from tool call",
206
+ };
207
+ break;
208
+ }
209
+ return {
210
+ content: [
211
+ {
212
+ type: "text",
213
+ text: JSON.stringify({ response }),
214
+ },
215
+ ],
216
+ };
217
+ });
218
+ }