rhombus-node-mcp
Version:
MCP server for Rhombus API
124 lines (123 loc) • 5.42 kB
JavaScript
import { context, SpanKind, SpanStatusCode, trace, TraceFlags, } from "@opentelemetry/api";
import { resolveSessionIdentity } from "../api/get-accessible-apps.js";
import { logger } from "../logger.js";
import { extractFromToolExtra } from "../util.js";
const TRACER_NAME = "rhombus-node-mcp";
const TOOL_CALL_SPAN = "mcp.tool.call";
const INVALID_TRACE_ID = "00000000000000000000000000000000";
let warnedNoopTracer = false;
function warnIfNoopTracer(span) {
if (warnedNoopTracer)
return;
const { traceId, traceFlags } = span.spanContext();
if (traceId === INVALID_TRACE_ID || traceFlags === TraceFlags.NONE) {
warnedNoopTracer = true;
logger.warn("📡 OpenTelemetry custom spans are no-ops — @opentelemetry/api is not sharing the SDK TracerProvider. Check for duplicate @opentelemetry/api in node_modules.");
}
}
function setArgKeys(span, args) {
if (args && typeof args === "object") {
const keys = Object.keys(args);
span.setAttribute("mcp.tool.arg_keys", keys.join(","));
const requestType = args.requestType;
if (typeof requestType === "string") {
span.setAttribute("mcp.tool.request_type", requestType);
}
}
}
async function attachSessionIdentity(span, extra) {
try {
const { sessionId } = extractFromToolExtra(extra);
if (!sessionId)
return;
span.setAttribute("mcp.session.id", sessionId);
const identity = await resolveSessionIdentity(sessionId);
if (identity?.userId)
span.setAttribute("enduser.id", identity.userId);
if (identity?.orgUuid)
span.setAttribute("mcp.org.uuid", identity.orgUuid);
}
catch (error) {
logger.debug(`tracing: attachSessionIdentity failed: ${String(error)}`);
}
}
/** Wrap a tool handler so each invocation emits an `mcp.tool.call` span. */
function wrapHandler(toolName, handler) {
return async (args, extra) => {
const tracer = trace.getTracer(TRACER_NAME);
const parentContext = context.active();
return tracer.startActiveSpan(TOOL_CALL_SPAN, { kind: SpanKind.INTERNAL }, parentContext, async (span) => {
warnIfNoopTracer(span);
const start = Date.now();
span.setAttribute("mcp.tool.name", toolName);
span.setAttribute("mcp.transport", process.env.TRANSPORT_TYPE ?? "stdio");
setArgKeys(span, args);
try {
await attachSessionIdentity(span, extra);
const result = await handler(args, extra);
const isError = result && typeof result === "object" && result.isError;
span.setAttribute("mcp.tool.success", !isError);
if (isError) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: "tool returned isError",
});
}
else {
span.setStatus({ code: SpanStatusCode.OK });
}
return result;
}
catch (error) {
span.setAttribute("mcp.tool.success", false);
span.recordException(error instanceof Error ? error : new Error(String(error)));
span.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : String(error),
});
throw error;
}
finally {
span.setAttribute("mcp.tool.duration_ms", Date.now() - start);
span.end();
}
});
};
}
/**
* Returns a Proxy over an `McpServer` that wraps every tool handler with an
* OpenTelemetry span. Both registration methods are intercepted: `registerTool`
* (most tools) and the legacy `tool` (a handful, including the
* filtering-blacklisted ones), so coverage is complete.
*
* Uses only `@opentelemetry/api` — spans are no-ops unless a host process (e.g.
* Rhombus EB deploy with `otel-init.mjs`) registers a tracer provider.
*
* Compose it *inside* the filtering proxy —
* `createFilteringProxy(createTracingProxy(server))` — so handlers are traced
* after filtering strips `includeFields`/`filterBy`, keeping those synthetic
* args out of `mcp.tool.arg_keys`.
*/
export function createTracingProxy(server) {
return new Proxy(server, {
get(target, prop, receiver) {
if (prop === "registerTool") {
// biome-ignore lint/suspicious/noExplicitAny: proxy intercept
return (name, config, handler) => target.registerTool(name, config, wrapHandler(name, handler));
}
if (prop === "tool") {
// Legacy signature: tool(name, [description], [paramsSchema], [annotations], handler).
// biome-ignore lint/suspicious/noExplicitAny: proxy intercept
return (...toolArgs) => {
const name = toolArgs[0];
const lastIdx = toolArgs.length - 1;
if (typeof toolArgs[lastIdx] === "function") {
toolArgs[lastIdx] = wrapHandler(name, toolArgs[lastIdx]);
}
return target.tool(...toolArgs);
};
}
return Reflect.get(target, prop, receiver);
},
});
}