rhombus-node-mcp 0.1.47 → 0.1.49

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.
@@ -2,7 +2,7 @@ import { logger } from "../logger.js";
2
2
  import { postApi } from "../network/network.js";
3
3
  /**
4
4
  * Single cached `getCurrentUser` fetch per session. Both `resolveAccessibleApps`
5
- * and `resolveSessionIdentity` read from this cache, so identity for analytics
5
+ * and `resolveSessionIdentity` read from this cache, so identity for tracing
6
6
  * costs no extra API call. Successful results are cached for the lifetime of
7
7
  * the session; failures are NOT cached so transient errors don't poison it.
8
8
  */
@@ -1,6 +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
+ import { createTracingProxy } from "./telemetry/tracingProxy.js";
4
4
  import { resolveAccessibleApps } from "./api/get-accessible-apps.js";
5
5
  import { logger } from "./logger.js";
6
6
  import { createFilteringProxy } from "./util.js";
@@ -106,10 +106,10 @@ export default async function createServer({ sessionId } = {}) {
106
106
  const toolsToRegister = pickToolsForSession(apps);
107
107
  logDevToolRegistration(sessionId, apps, toolsToRegister);
108
108
  logger.info(`🔒 Session ${sessionId ?? "(none)"}: apps=[${apps?.join(", ") ?? "unknown"}] — registering ${toolsToRegister.length} tools`);
109
- // Analytics wraps every tool handler; filtering wraps on top so handlers are
109
+ // Tracing wraps every tool handler; filtering wraps on top so handlers are
110
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"]));
111
+ // mcp.tool.arg_keys). Spans are no-ops unless a host registers an OTel SDK.
112
+ const filteredServer = createFilteringProxy(createTracingProxy(server), new Set(["time-tool", "count-tool", "time-conversion-tool"]));
113
113
  for (const tool of toolsToRegister) {
114
114
  try {
115
115
  await tool.create(filteredServer);
package/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import "dotenv/config";
3
- import { flushAnalytics, initAnalytics } from "./analytics/amplitude.js";
4
3
  import { serverInit } from "./createServer.js";
5
4
  import { logger } from "./logger.js";
6
5
  import stdioTransport from "./transports/stdio.js";
@@ -13,7 +12,6 @@ async function main() {
13
12
  logger.info(`🔑 Using API_KEY: ${RHOMBUS_API_KEY}`);
14
13
  }
15
14
  logger.info("🌐 Using server url", serverUrl);
16
- initAnalytics();
17
15
  await serverInit();
18
16
  if (TRANSPORT_TYPE === "stdio") {
19
17
  await stdioTransport();
@@ -25,13 +23,6 @@ async function main() {
25
23
  throw new Error(`Invalid transport type: ${TRANSPORT_TYPE}`);
26
24
  }
27
25
  }
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
- }
35
26
  main().catch(error => {
36
27
  console.error("Fatal error in main():", error);
37
28
  process.exit(1);
@@ -0,0 +1,123 @@
1
+ import { context, SpanKind, SpanStatusCode, trace, TraceFlags, } from "@opentelemetry/api";
2
+ import { resolveSessionIdentity } from "../api/get-accessible-apps.js";
3
+ import { logger } from "../logger.js";
4
+ import { extractFromToolExtra } from "../util.js";
5
+ const TRACER_NAME = "rhombus-node-mcp";
6
+ const TOOL_CALL_SPAN = "mcp.tool.call";
7
+ const INVALID_TRACE_ID = "00000000000000000000000000000000";
8
+ let warnedNoopTracer = false;
9
+ function warnIfNoopTracer(span) {
10
+ if (warnedNoopTracer)
11
+ return;
12
+ const { traceId, traceFlags } = span.spanContext();
13
+ if (traceId === INVALID_TRACE_ID || traceFlags === TraceFlags.NONE) {
14
+ warnedNoopTracer = true;
15
+ logger.warn("📡 OpenTelemetry custom spans are no-ops — @opentelemetry/api is not sharing the SDK TracerProvider. Check for duplicate @opentelemetry/api in node_modules.");
16
+ }
17
+ }
18
+ function setArgKeys(span, args) {
19
+ if (args && typeof args === "object") {
20
+ const keys = Object.keys(args);
21
+ span.setAttribute("mcp.tool.arg_keys", keys.join(","));
22
+ const requestType = args.requestType;
23
+ if (typeof requestType === "string") {
24
+ span.setAttribute("mcp.tool.request_type", requestType);
25
+ }
26
+ }
27
+ }
28
+ async function attachSessionIdentity(span, extra) {
29
+ try {
30
+ const { sessionId } = extractFromToolExtra(extra);
31
+ if (!sessionId)
32
+ return;
33
+ span.setAttribute("mcp.session.id", sessionId);
34
+ const identity = await resolveSessionIdentity(sessionId);
35
+ if (identity?.userId)
36
+ span.setAttribute("enduser.id", identity.userId);
37
+ if (identity?.orgUuid)
38
+ span.setAttribute("mcp.org.uuid", identity.orgUuid);
39
+ }
40
+ catch (error) {
41
+ logger.debug(`tracing: attachSessionIdentity failed: ${String(error)}`);
42
+ }
43
+ }
44
+ /** Wrap a tool handler so each invocation emits an `mcp.tool.call` span. */
45
+ function wrapHandler(toolName, handler) {
46
+ return async (args, extra) => {
47
+ const tracer = trace.getTracer(TRACER_NAME);
48
+ const parentContext = context.active();
49
+ return tracer.startActiveSpan(TOOL_CALL_SPAN, { kind: SpanKind.INTERNAL }, parentContext, async (span) => {
50
+ warnIfNoopTracer(span);
51
+ const start = Date.now();
52
+ span.setAttribute("mcp.tool.name", toolName);
53
+ span.setAttribute("mcp.transport", process.env.TRANSPORT_TYPE ?? "stdio");
54
+ setArgKeys(span, args);
55
+ try {
56
+ await attachSessionIdentity(span, extra);
57
+ const result = await handler(args, extra);
58
+ const isError = result && typeof result === "object" && result.isError;
59
+ span.setAttribute("mcp.tool.success", !isError);
60
+ if (isError) {
61
+ span.setStatus({
62
+ code: SpanStatusCode.ERROR,
63
+ message: "tool returned isError",
64
+ });
65
+ }
66
+ else {
67
+ span.setStatus({ code: SpanStatusCode.OK });
68
+ }
69
+ return result;
70
+ }
71
+ catch (error) {
72
+ span.setAttribute("mcp.tool.success", false);
73
+ span.recordException(error instanceof Error ? error : new Error(String(error)));
74
+ span.setStatus({
75
+ code: SpanStatusCode.ERROR,
76
+ message: error instanceof Error ? error.message : String(error),
77
+ });
78
+ throw error;
79
+ }
80
+ finally {
81
+ span.setAttribute("mcp.tool.duration_ms", Date.now() - start);
82
+ span.end();
83
+ }
84
+ });
85
+ };
86
+ }
87
+ /**
88
+ * Returns a Proxy over an `McpServer` that wraps every tool handler with an
89
+ * OpenTelemetry span. Both registration methods are intercepted: `registerTool`
90
+ * (most tools) and the legacy `tool` (a handful, including the
91
+ * filtering-blacklisted ones), so coverage is complete.
92
+ *
93
+ * Uses only `@opentelemetry/api` — spans are no-ops unless a host process (e.g.
94
+ * Rhombus EB deploy with `otel-init.mjs`) registers a tracer provider.
95
+ *
96
+ * Compose it *inside* the filtering proxy —
97
+ * `createFilteringProxy(createTracingProxy(server))` — so handlers are traced
98
+ * after filtering strips `includeFields`/`filterBy`, keeping those synthetic
99
+ * args out of `mcp.tool.arg_keys`.
100
+ */
101
+ export function createTracingProxy(server) {
102
+ return new Proxy(server, {
103
+ get(target, prop, receiver) {
104
+ if (prop === "registerTool") {
105
+ // biome-ignore lint/suspicious/noExplicitAny: proxy intercept
106
+ return (name, config, handler) => target.registerTool(name, config, wrapHandler(name, handler));
107
+ }
108
+ if (prop === "tool") {
109
+ // Legacy signature: tool(name, [description], [paramsSchema], [annotations], handler).
110
+ // biome-ignore lint/suspicious/noExplicitAny: proxy intercept
111
+ return (...toolArgs) => {
112
+ const name = toolArgs[0];
113
+ const lastIdx = toolArgs.length - 1;
114
+ if (typeof toolArgs[lastIdx] === "function") {
115
+ toolArgs[lastIdx] = wrapHandler(name, toolArgs[lastIdx]);
116
+ }
117
+ return target.tool(...toolArgs);
118
+ };
119
+ }
120
+ return Reflect.get(target, prop, receiver);
121
+ },
122
+ });
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhombus-node-mcp",
3
- "version": "0.1.47",
3
+ "version": "0.1.49",
4
4
  "description": "MCP server for Rhombus API",
5
5
  "keywords": [
6
6
  "ai",
@@ -49,7 +49,6 @@
49
49
  "dist"
50
50
  ],
51
51
  "dependencies": {
52
- "@amplitude/analytics-node": "^1.5.60",
53
52
  "@modelcontextprotocol/sdk": "^1.27.1",
54
53
  "axios": "^1.11.0",
55
54
  "cheerio": "^1.1.2",
@@ -66,6 +65,7 @@
66
65
  "zod": "^4.3.6"
67
66
  },
68
67
  "devDependencies": {
68
+ "@opentelemetry/api": "^1.9.0",
69
69
  "@types/cors": "^2.8.19",
70
70
  "@types/express": "^5.0.3",
71
71
  "@types/luxon": "^3.6.2",
@@ -78,5 +78,13 @@
78
78
  },
79
79
  "engines": {
80
80
  "node": ">=18"
81
+ },
82
+ "peerDependencies": {
83
+ "@opentelemetry/api": "^1.9.0"
84
+ },
85
+ "peerDependenciesMeta": {
86
+ "@opentelemetry/api": {
87
+ "optional": true
88
+ }
81
89
  }
82
90
  }
@@ -1,74 +0,0 @@
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
- }
@@ -1,97 +0,0 @@
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
- }