rhombus-node-mcp 0.1.46 → 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,16 +1,13 @@
|
|
|
1
1
|
import { logger } from "../logger.js";
|
|
2
2
|
import { postApi } from "../network/network.js";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
|
28
|
-
|
|
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
|
|
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);
|
package/dist/createServer.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rhombus-node-mcp",
|
|
3
|
-
"version": "0.1.
|
|
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",
|