rhombus-node-mcp 0.1.45 → 0.1.47

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,74 @@
1
+ import { flush, init, track } from "@amplitude/analytics-node";
2
+ import { logger } from "../logger.js";
3
+ /**
4
+ * Amplitude analytics for the Rhombus MCP server.
5
+ *
6
+ * Analytics are **opt-in**: nothing is sent unless `AMPLITUDE_API_KEY` is set.
7
+ * This keeps the published npm package / self-hosted deployments silent by
8
+ * default and confines telemetry to Rhombus-operated deployments that supply
9
+ * the key. Every function here is defensive — analytics must never throw into,
10
+ * slow down, or otherwise affect the request path.
11
+ */
12
+ let enabled = false;
13
+ /**
14
+ * Initialize the Amplitude client once at process start. No-op (and leaves
15
+ * analytics disabled) when `AMPLITUDE_API_KEY` is unset or init fails.
16
+ */
17
+ export function initAnalytics() {
18
+ const apiKey = process.env.AMPLITUDE_API_KEY;
19
+ if (!apiKey) {
20
+ logger.info("📊 Amplitude analytics disabled (AMPLITUDE_API_KEY not set)");
21
+ return;
22
+ }
23
+ const serverZone = (process.env.AMPLITUDE_SERVER_ZONE ?? "US").toUpperCase() === "EU" ? "EU" : "US";
24
+ try {
25
+ init(apiKey, {
26
+ serverZone,
27
+ // Batch in the background; the request path never waits on Amplitude.
28
+ flushIntervalMillis: 10_000,
29
+ flushQueueSize: 50,
30
+ });
31
+ enabled = true;
32
+ logger.info(`📊 Amplitude analytics enabled (zone=${serverZone})`);
33
+ }
34
+ catch (error) {
35
+ logger.warn(`📊 Failed to initialize Amplitude analytics: ${String(error)}`);
36
+ }
37
+ }
38
+ /** Whether analytics are active (key present and init succeeded). */
39
+ export function analyticsEnabled() {
40
+ return enabled;
41
+ }
42
+ /**
43
+ * Record an analytics event. Fire-and-forget and fully guarded — callers do
44
+ * not await this and a failure here is logged at debug level only.
45
+ */
46
+ export function trackEvent(eventType, eventProperties, identity = {}) {
47
+ if (!enabled)
48
+ return;
49
+ try {
50
+ const { userId, orgUuid, deviceId } = identity;
51
+ track(eventType, eventProperties, {
52
+ // Amplitude requires a user_id or a device_id. Prefer the stable user id;
53
+ // fall back to a device id (session id) and finally a constant so the
54
+ // event is still accepted for stateless (api-key / oauth) callers.
55
+ user_id: userId,
56
+ ...(userId ? {} : { device_id: deviceId || "mcp-stateless" }),
57
+ ...(orgUuid ? { groups: { org: orgUuid } } : {}),
58
+ });
59
+ }
60
+ catch (error) {
61
+ logger.debug(`📊 trackEvent("${eventType}") failed: ${String(error)}`);
62
+ }
63
+ }
64
+ /** Flush any buffered events. Call on graceful shutdown. */
65
+ export async function flushAnalytics() {
66
+ if (!enabled)
67
+ return;
68
+ try {
69
+ await flush().promise;
70
+ }
71
+ catch (error) {
72
+ logger.debug(`📊 flushAnalytics failed: ${String(error)}`);
73
+ }
74
+ }
@@ -0,0 +1,97 @@
1
+ import { resolveSessionIdentity } from "../api/get-accessible-apps.js";
2
+ import { logger } from "../logger.js";
3
+ import { extractFromToolExtra } from "../util.js";
4
+ import { analyticsEnabled, trackEvent } from "./amplitude.js";
5
+ const TOOL_CALLED_EVENT = "MCP Tool Called";
6
+ /**
7
+ * Resolve identity (cached `getCurrentUser` — no extra API call for session
8
+ * callers) and emit the tool-call event. Fire-and-forget: never awaited by the
9
+ * handler and fully guarded so analytics cannot affect the response.
10
+ */
11
+ async function emitToolCall(toolName,
12
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic tool args
13
+ args, extra, success, durationMs, errorMessage) {
14
+ try {
15
+ const { sessionId } = extractFromToolExtra(extra);
16
+ const identity = sessionId ? await resolveSessionIdentity(sessionId) : null;
17
+ trackEvent(TOOL_CALLED_EVENT, {
18
+ tool_name: toolName,
19
+ success,
20
+ duration_ms: durationMs,
21
+ // Record which arguments were supplied, never their values (avoid PII).
22
+ arg_keys: args && typeof args === "object" ? Object.keys(args) : [],
23
+ transport: process.env.TRANSPORT_TYPE ?? "stdio",
24
+ ...(errorMessage ? { error_message: errorMessage } : {}),
25
+ }, {
26
+ userId: identity?.userId,
27
+ orgUuid: identity?.orgUuid,
28
+ deviceId: sessionId,
29
+ });
30
+ }
31
+ catch (error) {
32
+ logger.debug(`📊 emitToolCall("${toolName}") failed: ${String(error)}`);
33
+ }
34
+ }
35
+ /** Wrap a tool handler so each invocation is timed and tracked. */
36
+ function wrapHandler(toolName, handler) {
37
+ return async (args, extra) => {
38
+ const start = Date.now();
39
+ let success = true;
40
+ let errorMessage;
41
+ try {
42
+ const result = await handler(args, extra);
43
+ // A tool can signal failure via `isError` rather than throwing.
44
+ if (result && typeof result === "object" && result.isError) {
45
+ success = false;
46
+ }
47
+ return result;
48
+ }
49
+ catch (error) {
50
+ success = false;
51
+ errorMessage = error instanceof Error ? error.message : String(error);
52
+ throw error;
53
+ }
54
+ finally {
55
+ void emitToolCall(toolName, args, extra, success, Date.now() - start, errorMessage);
56
+ }
57
+ };
58
+ }
59
+ /**
60
+ * Returns a Proxy over an `McpServer` that wraps every tool handler to emit a
61
+ * `"${TOOL_CALLED_EVENT}"` analytics event. Both registration methods are
62
+ * intercepted: `registerTool` (most tools) and the legacy `tool` (a handful,
63
+ * including the filtering-blacklisted ones), so coverage is complete.
64
+ *
65
+ * When analytics are disabled the proxy returns the server untouched, so there
66
+ * is zero overhead on deployments without `AMPLITUDE_API_KEY`.
67
+ *
68
+ * Compose it *inside* the filtering proxy — `createFilteringProxy(createAnalyticsProxy(server))`
69
+ * — so handlers are timed after filtering strips `includeFields`/`filterBy`,
70
+ * keeping those synthetic args out of `arg_keys`.
71
+ */
72
+ export function createAnalyticsProxy(server) {
73
+ if (!analyticsEnabled())
74
+ return server;
75
+ return new Proxy(server, {
76
+ get(target, prop, receiver) {
77
+ if (prop === "registerTool") {
78
+ // biome-ignore lint/suspicious/noExplicitAny: proxy intercept
79
+ return (name, config, handler) => target.registerTool(name, config, wrapHandler(name, handler));
80
+ }
81
+ if (prop === "tool") {
82
+ // Legacy signature: tool(name, [description], [paramsSchema], [annotations], handler).
83
+ // The handler is always the final argument.
84
+ // biome-ignore lint/suspicious/noExplicitAny: proxy intercept
85
+ return (...toolArgs) => {
86
+ const name = toolArgs[0];
87
+ const lastIdx = toolArgs.length - 1;
88
+ if (typeof toolArgs[lastIdx] === "function") {
89
+ toolArgs[lastIdx] = wrapHandler(name, toolArgs[lastIdx]);
90
+ }
91
+ return target.tool(...toolArgs);
92
+ };
93
+ }
94
+ return Reflect.get(target, prop, receiver);
95
+ },
96
+ });
97
+ }
@@ -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) {
@@ -1,16 +1,13 @@
1
1
  import { logger } from "../logger.js";
2
2
  import { postApi } from "../network/network.js";
3
3
  /**
4
- * Fetches `user.accessibleRhombusApps` from getCurrentUser for the given session.
5
- * Successful results are cached in-memory for the lifetime of the session;
6
- * failures are NOT cached so transient errors don't poison the session.
7
- * Returns null on error or missing session; callers should fall back to a
8
- * permissive default.
4
+ * Single cached `getCurrentUser` fetch per session. Both `resolveAccessibleApps`
5
+ * and `resolveSessionIdentity` read from this cache, so identity for analytics
6
+ * costs no extra API call. Successful results are cached for the lifetime of
7
+ * the session; failures are NOT cached so transient errors don't poison it.
9
8
  */
10
9
  const cache = new Map();
11
- export async function resolveAccessibleApps(sessionId) {
12
- if (!sessionId)
13
- return null;
10
+ async function fetchSession(sessionId) {
14
11
  const cached = cache.get(sessionId);
15
12
  if (cached !== undefined)
16
13
  return cached;
@@ -24,16 +21,43 @@ export async function resolveAccessibleApps(sessionId) {
24
21
  logger.warn(`resolveAccessibleApps: getCurrentUser failed for session ${sessionId}`);
25
22
  return null;
26
23
  }
27
- const apps = (res.user?.accessibleRhombusApps ?? []).filter((a) => a !== null && a !== undefined);
28
- cache.set(sessionId, apps);
24
+ const user = res.user;
25
+ const apps = (user?.accessibleRhombusApps ?? []).filter((a) => a !== null && a !== undefined);
26
+ const identity = {
27
+ userId: user?.uuid ?? user?.rhombusUserUuid ?? undefined,
28
+ orgUuid: user?.orgUuid ?? undefined,
29
+ email: user?.email ?? undefined,
30
+ };
31
+ const entry = { apps, identity };
32
+ cache.set(sessionId, entry);
29
33
  logger.info(`resolveAccessibleApps: session ${sessionId} -> [${apps.join(", ")}]`);
30
- return apps;
34
+ return entry;
31
35
  }
32
36
  catch (e) {
33
37
  logger.warn(`resolveAccessibleApps: error for session ${sessionId}: ${String(e)}`);
34
38
  return null;
35
39
  }
36
40
  }
41
+ /**
42
+ * Fetches `user.accessibleRhombusApps` from getCurrentUser for the given session.
43
+ * Returns null on error or missing session; callers should fall back to a
44
+ * permissive default.
45
+ */
46
+ export async function resolveAccessibleApps(sessionId) {
47
+ if (!sessionId)
48
+ return null;
49
+ return (await fetchSession(sessionId))?.apps ?? null;
50
+ }
51
+ /**
52
+ * Fetches identity (user uuid, org uuid, email) from getCurrentUser for the
53
+ * given session, reusing the same cached response as `resolveAccessibleApps`.
54
+ * Returns null on error or missing session.
55
+ */
56
+ export async function resolveSessionIdentity(sessionId) {
57
+ if (!sessionId)
58
+ return null;
59
+ return (await fetchSession(sessionId))?.identity ?? null;
60
+ }
37
61
  /** Drop the cached entry — call when a session ends. */
38
62
  export function clearAccessibleAppsCache(sessionId) {
39
63
  cache.delete(sessionId);
@@ -1,5 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import path from "path";
3
+ import { createAnalyticsProxy } from "./analytics/analyticsProxy.js";
3
4
  import { resolveAccessibleApps } from "./api/get-accessible-apps.js";
4
5
  import { logger } from "./logger.js";
5
6
  import { createFilteringProxy } from "./util.js";
@@ -105,7 +106,10 @@ export default async function createServer({ sessionId } = {}) {
105
106
  const toolsToRegister = pickToolsForSession(apps);
106
107
  logDevToolRegistration(sessionId, apps, toolsToRegister);
107
108
  logger.info(`🔒 Session ${sessionId ?? "(none)"}: apps=[${apps?.join(", ") ?? "unknown"}] — registering ${toolsToRegister.length} tools`);
108
- const filteredServer = createFilteringProxy(server, new Set(["time-tool", "count-tool", "time-conversion-tool"]));
109
+ // Analytics wraps every tool handler; filtering wraps on top so handlers are
110
+ // timed after includeFields/filterBy are stripped (keeping them out of
111
+ // arg_keys). Both are no-ops when their respective features are disabled.
112
+ const filteredServer = createFilteringProxy(createAnalyticsProxy(server), new Set(["time-tool", "count-tool", "time-conversion-tool"]));
109
113
  for (const tool of toolsToRegister) {
110
114
  try {
111
115
  await tool.create(filteredServer);
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import "dotenv/config";
3
+ import { flushAnalytics, initAnalytics } from "./analytics/amplitude.js";
3
4
  import { serverInit } from "./createServer.js";
4
5
  import { logger } from "./logger.js";
5
6
  import stdioTransport from "./transports/stdio.js";
@@ -12,6 +13,7 @@ async function main() {
12
13
  logger.info(`🔑 Using API_KEY: ${RHOMBUS_API_KEY}`);
13
14
  }
14
15
  logger.info("🌐 Using server url", serverUrl);
16
+ initAnalytics();
15
17
  await serverInit();
16
18
  if (TRANSPORT_TYPE === "stdio") {
17
19
  await stdioTransport();
@@ -23,6 +25,13 @@ async function main() {
23
25
  throw new Error(`Invalid transport type: ${TRANSPORT_TYPE}`);
24
26
  }
25
27
  }
28
+ // Flush buffered analytics on graceful shutdown so in-flight events aren't lost.
29
+ for (const signal of ["SIGINT", "SIGTERM"]) {
30
+ process.on(signal, async () => {
31
+ await flushAnalytics();
32
+ process.exit(0);
33
+ });
34
+ }
26
35
  main().catch(error => {
27
36
  console.error("Fatal error in main():", error);
28
37
  process.exit(1);
@@ -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.47",
4
4
  "description": "MCP server for Rhombus API",
5
5
  "keywords": [
6
6
  "ai",
@@ -49,6 +49,7 @@
49
49
  "dist"
50
50
  ],
51
51
  "dependencies": {
52
+ "@amplitude/analytics-node": "^1.5.60",
52
53
  "@modelcontextprotocol/sdk": "^1.27.1",
53
54
  "axios": "^1.11.0",
54
55
  "cheerio": "^1.1.2",