rhombus-node-mcp 0.1.46 → 0.1.48

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.
@@ -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 tracing
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 { createTracingProxy } from "./telemetry/tracingProxy.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
+ // Tracing wraps every tool handler; filtering wraps on top so handlers are
110
+ // timed after includeFields/filterBy are stripped (keeping them out of
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"]));
109
113
  for (const tool of toolsToRegister) {
110
114
  try {
111
115
  await tool.create(filteredServer);
@@ -0,0 +1,106 @@
1
+ import { SpanStatusCode, trace } 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
+ function setArgKeys(span, args) {
8
+ if (args && typeof args === "object") {
9
+ const keys = Object.keys(args);
10
+ span.setAttribute("mcp.tool.arg_keys", keys.join(","));
11
+ const requestType = args.requestType;
12
+ if (typeof requestType === "string") {
13
+ span.setAttribute("mcp.tool.request_type", requestType);
14
+ }
15
+ }
16
+ }
17
+ async function attachSessionIdentity(span, extra) {
18
+ try {
19
+ const { sessionId } = extractFromToolExtra(extra);
20
+ if (!sessionId)
21
+ return;
22
+ span.setAttribute("mcp.session.id", sessionId);
23
+ const identity = await resolveSessionIdentity(sessionId);
24
+ if (identity?.userId)
25
+ span.setAttribute("enduser.id", identity.userId);
26
+ if (identity?.orgUuid)
27
+ span.setAttribute("mcp.org.uuid", identity.orgUuid);
28
+ }
29
+ catch (error) {
30
+ logger.debug(`tracing: attachSessionIdentity failed: ${String(error)}`);
31
+ }
32
+ }
33
+ /** Wrap a tool handler so each invocation emits an `mcp.tool.call` span. */
34
+ function wrapHandler(toolName, handler) {
35
+ return async (args, extra) => {
36
+ const tracer = trace.getTracer(TRACER_NAME);
37
+ return tracer.startActiveSpan(TOOL_CALL_SPAN, async (span) => {
38
+ const start = Date.now();
39
+ span.setAttribute("mcp.tool.name", toolName);
40
+ span.setAttribute("mcp.transport", process.env.TRANSPORT_TYPE ?? "stdio");
41
+ setArgKeys(span, args);
42
+ try {
43
+ await attachSessionIdentity(span, extra);
44
+ const result = await handler(args, extra);
45
+ const isError = result && typeof result === "object" && result.isError;
46
+ span.setAttribute("mcp.tool.success", !isError);
47
+ if (isError) {
48
+ span.setStatus({
49
+ code: SpanStatusCode.ERROR,
50
+ message: "tool returned isError",
51
+ });
52
+ }
53
+ return result;
54
+ }
55
+ catch (error) {
56
+ span.setAttribute("mcp.tool.success", false);
57
+ span.recordException(error instanceof Error ? error : new Error(String(error)));
58
+ span.setStatus({
59
+ code: SpanStatusCode.ERROR,
60
+ message: error instanceof Error ? error.message : String(error),
61
+ });
62
+ throw error;
63
+ }
64
+ finally {
65
+ span.setAttribute("mcp.tool.duration_ms", Date.now() - start);
66
+ }
67
+ });
68
+ };
69
+ }
70
+ /**
71
+ * Returns a Proxy over an `McpServer` that wraps every tool handler with an
72
+ * OpenTelemetry span. Both registration methods are intercepted: `registerTool`
73
+ * (most tools) and the legacy `tool` (a handful, including the
74
+ * filtering-blacklisted ones), so coverage is complete.
75
+ *
76
+ * Uses only `@opentelemetry/api` — spans are no-ops unless a host process (e.g.
77
+ * Rhombus EB deploy with `otel-init.mjs`) registers a tracer provider.
78
+ *
79
+ * Compose it *inside* the filtering proxy —
80
+ * `createFilteringProxy(createTracingProxy(server))` — so handlers are traced
81
+ * after filtering strips `includeFields`/`filterBy`, keeping those synthetic
82
+ * args out of `mcp.tool.arg_keys`.
83
+ */
84
+ export function createTracingProxy(server) {
85
+ return new Proxy(server, {
86
+ get(target, prop, receiver) {
87
+ if (prop === "registerTool") {
88
+ // biome-ignore lint/suspicious/noExplicitAny: proxy intercept
89
+ return (name, config, handler) => target.registerTool(name, config, wrapHandler(name, handler));
90
+ }
91
+ if (prop === "tool") {
92
+ // Legacy signature: tool(name, [description], [paramsSchema], [annotations], handler).
93
+ // biome-ignore lint/suspicious/noExplicitAny: proxy intercept
94
+ return (...toolArgs) => {
95
+ const name = toolArgs[0];
96
+ const lastIdx = toolArgs.length - 1;
97
+ if (typeof toolArgs[lastIdx] === "function") {
98
+ toolArgs[lastIdx] = wrapHandler(name, toolArgs[lastIdx]);
99
+ }
100
+ return target.tool(...toolArgs);
101
+ };
102
+ }
103
+ return Reflect.get(target, prop, receiver);
104
+ },
105
+ });
106
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhombus-node-mcp",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "description": "MCP server for Rhombus API",
5
5
  "keywords": [
6
6
  "ai",
@@ -50,6 +50,7 @@
50
50
  ],
51
51
  "dependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.27.1",
53
+ "@opentelemetry/api": "^1.9.0",
53
54
  "axios": "^1.11.0",
54
55
  "cheerio": "^1.1.2",
55
56
  "chrono-node": "^2.8.0",