Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions glama.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,29 @@
"type": "string",
"description": "Advertised tool-surface scope. 'full' (or unset) advertises every tool (default, backward-compatible). 'core' advertises only the discovery/dispatch core (boj_health, boj_menu, boj_cartridges, boj_cartridge_info, boj_cartridge_invoke) plus all coord_* tools. A CSV of domain prefixes (e.g. 'core,github,browser') advertises core plus the named explicit groups. Every explicit boj_<domain>_* tool remains reachable via boj_cartridge_invoke regardless of scope.",
"default": "full"
},
"OTEL_EXPORTER_OTLP_ENDPOINT": {
"type": "string",
"description": "OTLP/HTTP collector endpoint (e.g. http://localhost:4318). When set, every tools/call emits an OTLP/JSON span to <endpoint>/v1/traces. Pairs with observe-mcp / grafana-mcp / prometheus-mcp for unified telemetry."
},
"OTEL_SERVICE_NAME": {
"type": "string",
"description": "Service name attribute on emitted spans.",
"default": "boj-server"
},
"OTEL_SERVICE_VERSION": {
"type": "string",
"description": "Service version attribute on emitted spans.",
"default": "0.4.7"
},
"OTEL_BATCH_MS": {
"type": "string",
"description": "Span-batch flush interval in milliseconds.",
"default": "5000"
},
"OTEL_EXPORTER_OTLP_HEADERS": {
"type": "string",
"description": "Optional comma-separated key=value headers attached to OTLP export POSTs (e.g. 'authorization=Bearer xyz,x-honeycomb-team=abc'). Used for hosted collectors that require auth."
}
}
}
Expand Down
234 changes: 234 additions & 0 deletions mcp-bridge/lib/otel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
//
// BoJ Server — Minimal OTLP/HTTP+JSON span emitter
//
// Zero runtime dependencies. Disabled unless OTEL_EXPORTER_OTLP_ENDPOINT
// is set. Spans are buffered and flushed via fire-and-forget POST to
// `${endpoint}/v1/traces` at a configurable batch interval (default 5s)
// and on `before-exit` / SIGTERM / SIGINT.
//
// Why hand-rolled (no @opentelemetry/api)?
// - Bridge has zero runtime deps by policy (see package.json + CLAUDE.md).
// Adding ~30 transitive packages for what is structurally a couple of
// JSON-RPC wrappers is not proportionate.
// - OTLP/HTTP JSON is a stable, externally-documented wire format. Any
// conformant collector (Jaeger, Tempo, Honeycomb, Grafana Agent,
// OTel Collector itself) accepts these payloads.
//
// Span shape conforms to OTLP/JSON v1.0:
// https://opentelemetry.io/docs/specs/otlp/#otlphttp
//
// Pairs naturally with the observe-mcp / grafana-mcp / prometheus-mcp
// cartridges — same telemetry destination, unified pane.

import { env } from "./runtime.js";

const ENDPOINT = env.get("OTEL_EXPORTER_OTLP_ENDPOINT") ?? "";
const SERVICE_NAME = env.get("OTEL_SERVICE_NAME") ?? "boj-server";
const SERVICE_VERSION = env.get("OTEL_SERVICE_VERSION") ?? "0.4.7";
const BATCH_MS = parseInt(env.get("OTEL_BATCH_MS") ?? "5000", 10) || 5000;
const HEADERS_RAW = env.get("OTEL_EXPORTER_OTLP_HEADERS") ?? "";

const ENABLED = ENDPOINT.length > 0;

const customHeaders = {};
if (HEADERS_RAW) {
for (const pair of HEADERS_RAW.split(",")) {
const idx = pair.indexOf("=");
if (idx > 0) {
customHeaders[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
}
}
}

const traceUrl = ENABLED
? ENDPOINT.replace(/\/$/, "") + "/v1/traces"
: null;

const pendingSpans = [];

function randHex(bytes) {
let s = "";
for (let i = 0; i < bytes; i++) {
s += Math.floor(Math.random() * 256).toString(16).padStart(2, "0");
}
return s;
}

function nowNs() {
// Number → BigInt-safe nanoseconds. Date.now() is ms; multiply by 1e6.
// OTLP accepts string-encoded nanoseconds.
return String(BigInt(Date.now()) * 1000000n);
}

function attributesToOtlp(attrs) {
const out = [];
for (const [k, v] of Object.entries(attrs || {})) {
if (v === undefined || v === null) continue;
if (typeof v === "string") {
out.push({ key: k, value: { stringValue: v } });
} else if (typeof v === "number") {
if (Number.isInteger(v)) {
out.push({ key: k, value: { intValue: String(v) } });
} else {
out.push({ key: k, value: { doubleValue: v } });
}
} else if (typeof v === "boolean") {
out.push({ key: k, value: { boolValue: v } });
} else {
out.push({ key: k, value: { stringValue: JSON.stringify(v) } });
}
}
return out;
}

/**
* Start a span. Returns an opaque handle to pass to endSpan.
* If telemetry is disabled, returns a no-op handle that endSpan ignores.
*
* @param {string} name — span name (e.g. "tools/call", "resources/read")
* @param {Record<string, any>} attributes — initial attributes
* @returns {{traceId:string,spanId:string,startNs:string,name:string,attributes:object} | null}
*/
function startSpan(name, attributes = {}) {
if (!ENABLED) return null;
return {
traceId: randHex(16),
spanId: randHex(8),
startNs: nowNs(),
name,
attributes: { ...attributes },
};
}

/**
* End a span and queue it for export. No-op if span is null.
*
* @param {object|null} span — handle from startSpan
* @param {{status?: "ok"|"error", error?: string, attributes?: object}} opts
*/
function endSpan(span, opts = {}) {
if (!span || !ENABLED) return;
const endNs = nowNs();
const mergedAttrs = { ...span.attributes, ...(opts.attributes || {}) };

// Status code per OTLP: 0=UNSET, 1=OK, 2=ERROR
let statusCode = 0;
let statusMessage;
if (opts.status === "ok") statusCode = 1;
if (opts.status === "error") {
statusCode = 2;
statusMessage = opts.error;
}

pendingSpans.push({
traceId: span.traceId,
spanId: span.spanId,
name: span.name,
kind: 1, // SPAN_KIND_INTERNAL
startTimeUnixNano: span.startNs,
endTimeUnixNano: endNs,
attributes: attributesToOtlp(mergedAttrs),
status: {
code: statusCode,
...(statusMessage ? { message: statusMessage } : {}),
},
});
}

function buildResourceSpansPayload(spans) {
return {
resourceSpans: [
{
resource: {
attributes: attributesToOtlp({
"service.name": SERVICE_NAME,
"service.version": SERVICE_VERSION,
"telemetry.sdk.name": "boj-server-otel",
"telemetry.sdk.language": "javascript",
"telemetry.sdk.version": "1.0",
}),
},
scopeSpans: [
{
scope: { name: "boj-mcp-bridge", version: SERVICE_VERSION },
spans,
},
],
},
],
};
}

async function flush() {
if (!ENABLED || pendingSpans.length === 0) return;
const batch = pendingSpans.splice(0, pendingSpans.length);
const payload = buildResourceSpansPayload(batch);
try {
const resp = await fetch(traceUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
...customHeaders,
},
body: JSON.stringify(payload),
});
if (!resp.ok) {
// Re-buffer so we try again next tick. Cap at 10k to avoid runaway.
if (pendingSpans.length < 10_000) {
pendingSpans.push(...batch);
}
}
} catch {
// Network failure — re-buffer (best-effort; bounded).
if (pendingSpans.length < 10_000) {
pendingSpans.push(...batch);
}
}
}

let intervalHandle = null;
let shutdownInstalled = false;

function startBatchFlush() {
if (!ENABLED || intervalHandle) return;
intervalHandle = setInterval(() => {
flush().catch(() => {});
}, BATCH_MS);
// setInterval keeps the process alive in Node; unref so stdio handles
// process lifetime. Deno doesn't expose unref but treats it as no-op.
if (typeof intervalHandle?.unref === "function") {
intervalHandle.unref();
}
}

function installShutdownHooks() {
if (!ENABLED || shutdownInstalled) return;
shutdownInstalled = true;

const finalFlush = () => flush().catch(() => {});

if (typeof process !== "undefined" && typeof process.on === "function") {
process.on("beforeExit", finalFlush);
process.on("SIGTERM", finalFlush);
process.on("SIGINT", finalFlush);
}
// Deno: signal handlers via Deno.addSignalListener if available.
if (typeof globalThis.Deno !== "undefined" && globalThis.Deno?.addSignalListener) {
try { globalThis.Deno.addSignalListener("SIGTERM", finalFlush); } catch {}
try { globalThis.Deno.addSignalListener("SIGINT", finalFlush); } catch {}
}
}

function init() {
if (!ENABLED) return;
startBatchFlush();
installShutdownHooks();
}

function isEnabled() {
return ENABLED;
}

export { startSpan, endSpan, flush, init, isEnabled };
37 changes: 31 additions & 6 deletions mcp-bridge/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,16 @@ import {
validateEnvelope,
} from "./lib/nickel-validator.js";
import { info, warn, error as logError, setLevel as setLogLevel } from "./lib/logger.js";
import * as otel from "./lib/otel.js";

const BOJ_BASE = env.get("BOJ_URL") ?? "http://localhost:7700";
const SERVER_NAME = "boj-server";
const SERVER_VERSION = "0.4.0";

// Initialise OTel batch-flush + shutdown hooks (no-op unless
// OTEL_EXPORTER_OTLP_ENDPOINT is set).
otel.init();

// ===================================================================
// JSON-RPC stdio transport
// ===================================================================
Expand Down Expand Up @@ -407,19 +412,39 @@ async function handleMessage(line) {
const args = params?.arguments || {};
const token = params?.token;

const span = otel.startSpan("mcp.tools.call", {
"mcp.tool.name": toolName,
"mcp.tool.arg_count": Object.keys(args).length,
});

const rejection = hardeningGate(toolName, args, token);
if (rejection) {
otel.endSpan(span, {
status: "error",
error: "gate_rejected",
attributes: { "mcp.rejection.code": rejection.code },
});
sendError(id, rejection.code, rejection.message);
break;
}

const result = await dispatchTool(toolName, args);
if (result === null) {
sendError(id, -32601, "Unknown tool");
} else {
sendResult(id, {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
try {
const result = await dispatchTool(toolName, args);
if (result === null) {
otel.endSpan(span, { status: "error", error: "unknown_tool" });
sendError(id, -32601, "Unknown tool");
} else {
otel.endSpan(span, { status: "ok" });
sendResult(id, {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
});
}
} catch (e) {
otel.endSpan(span, {
status: "error",
error: sanitizeErrorMessage(e?.message ?? String(e)),
});
throw e;
}
break;
}
Expand Down
44 changes: 44 additions & 0 deletions mcp-bridge/tests/dispatch_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -336,3 +336,47 @@ test("prompts surface is well-formed and required-arg-validated", async () => {
const ok = getPrompt("audit-repo", { owner: "hyperpolymath", repo: "boj-server" });
assert.ok(ok.result?.messages?.[0]?.content?.text?.length > 100);
});

// -----------------------------------------------------------------
// 14. OTel exporter — disabled by default; startSpan returns null
// unless OTEL_EXPORTER_OTLP_ENDPOINT is set.
// -----------------------------------------------------------------
test("otel disabled-mode is a no-op", async () => {
const saved = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
try {
// Fresh import (esm caches by URL, so use cache-busting query)
const otel = await import("../lib/otel.js?disabled=1");
assert.equal(otel.isEnabled(), false);
const span = otel.startSpan("test", { foo: "bar" });
assert.equal(span, null, "disabled mode returns null span");
otel.endSpan(span, { status: "ok" }); // must not throw on null
await otel.flush(); // must not throw / no network call
} finally {
if (saved !== undefined) process.env.OTEL_EXPORTER_OTLP_ENDPOINT = saved;
}
});

// -----------------------------------------------------------------
// 15. OTel exporter — when enabled, spans carry traceId/spanId,
// start/end times, and attributes survive endSpan.
// -----------------------------------------------------------------
test("otel enabled-mode produces well-formed span handles", async () => {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://127.0.0.1:0/never-reached";
try {
const otel = await import("../lib/otel.js?enabled=1");
assert.equal(otel.isEnabled(), true);
const span = otel.startSpan("mcp.tools.call", {
"mcp.tool.name": "boj_health",
"mcp.tool.arg_count": 0,
});
assert.ok(span && span.traceId && span.spanId);
assert.match(span.traceId, /^[0-9a-f]{32}$/);
assert.match(span.spanId, /^[0-9a-f]{16}$/);
assert.ok(span.startNs && /^\d+$/.test(span.startNs));
// endSpan must not throw and must accept a status
otel.endSpan(span, { status: "ok", attributes: { result: "ok" } });
} finally {
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
}
});
Loading