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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,13 @@ jobs:
node_modules
apps/loopover-ui/node_modules
key: ${{ steps.node-modules-cache.outputs.cache-primary-key }}
# apps/loopover-ui/.source (fumadocs-mdx codegen -- the collections/* alias
# apps/loopover-ui/tsconfig.json depends on) lives outside node_modules and is normally produced by
# npm ci's own postinstall. A node_modules cache hit above skips npm ci entirely, so .source/ would
# otherwise never exist on a cache-hit run. Regenerate it explicitly and unconditionally: cheap,
# deterministic, and a harmless no-op re-run of the same postinstall on a cache miss.
- name: Generate fumadocs content-collection sources
run: npm --workspace @loopover/ui run postinstall
- name: Lint workflows
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run actionlint
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

68 changes: 68 additions & 0 deletions packages/loopover-mcp/lib/telemetry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { PostHog } from "posthog-node";

// Local MCP telemetry wrapper (#6236, mirrors the remote wrapper from #6235). Same allowlisted event shape
// and PostHog vendor as src/mcp/telemetry.ts, so the two servers report consistent data -- the only real
// difference is the trust posture: this CLI runs on a user's own machine, so it is gated on an EXPLICIT,
// persisted opt-in flag rather than mere env-var presence. This module stays a pure helper like its lib/
// siblings (cli-error.js, format-table.js, ...) -- it never reads the CLI's config file itself. The caller
// (bin/loopover-mcp.js) resolves `telemetryEnabled` from the persisted config and passes it in.
//
// SAFE NO-OP: unless the caller passes `telemetryEnabled: true` AND LOOPOVER_MCP_POSTHOG_API_KEY is set,
// this records nothing and behaves byte-identically to before this module existed -- true for every user
// who has not run `loopover-mcp telemetry enable` (the default). It also never throws: a PostHog init/
// capture failure degrades to recording nothing, so it can never affect the CLI's actual command behavior.

/** PostHog US-cloud ingestion host -- the default when LOOPOVER_MCP_POSTHOG_HOST isn't set. */
const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";

/** The PostHog event name every MCP tool call is recorded under (matches the remote wrapper, #6235). */
const MCP_TOOL_CALL_EVENT = "mcp_tool_call";

/** Anonymous, constant distinct id: this fleet telemetry carries NO per-actor identity by design (#6228),
* so every event shares one handle and there is no per-user person to build up. */
const MCP_TELEMETRY_DISTINCT_ID = "loopover-mcp";

/**
* Record a single local MCP tool call to PostHog. Safe no-op unless `telemetryEnabled` is explicitly
* `true` (the caller's resolved, persisted opt-in flag, default OFF -- #6236) AND
* LOOPOVER_MCP_POSTHOG_API_KEY is configured; never throws.
*
* @param {{ telemetryEnabled: boolean }} options
* @param {{ tool: string, callerType?: "local", ok: boolean, durationMs: number }} event
*/
export function recordMcpToolCall(options, event) {
// Opt-in default OFF (#6236, per #6228's privacy decision) -- unlike the remote wrapper, presence of an
// API key alone is not enough; the user must have explicitly enabled telemetry.
if (options?.telemetryEnabled !== true) return;

const apiKey = trimmedOrUndefined(process.env.LOOPOVER_MCP_POSTHOG_API_KEY);
// Unconfigured -> record nothing, byte-identical to before this module existed.
if (!apiKey) return;

const host = trimmedOrUndefined(process.env.LOOPOVER_MCP_POSTHOG_HOST) ?? DEFAULT_POSTHOG_HOST;
try {
const client = new PostHog(apiKey, { host, flushAt: 1, flushInterval: 0 });
client.capture({
distinctId: MCP_TELEMETRY_DISTINCT_ID,
event: MCP_TOOL_CALL_EVENT,
// Exactly the #6228 allowlist -- nothing more.
properties: {
tool: event.tool,
caller_type: event.callerType ?? "local",
ok: event.ok,
duration_ms: event.durationMs,
},
// No IP-based geo enrichment: the event is anonymous fleet telemetry, not a user location.
disableGeoip: true,
});
} catch {
// Telemetry is best-effort and MUST NOT throw into the CLI (#6236): a PostHog init/capture failure
// degrades to recording nothing, identical to the unconfigured path above.
}
}

/** Trim a possibly-undefined env string, treating blank/whitespace as absent. */
function trimmedOrUndefined(value) {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
3 changes: 2 additions & 1 deletion packages/loopover-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,12 @@
"CHANGELOG.md"
],
"scripts": {
"build": "node --check bin/loopover-mcp.js && node --check lib/cli-error.js && node --check lib/local-branch.js && node --check lib/format-table.js && node --check lib/redact-local-path.js && node --check scripts/gittensor-score-preview.mjs"
"build": "node --check bin/loopover-mcp.js && node --check lib/cli-error.js && node --check lib/local-branch.js && node --check lib/format-table.js && node --check lib/redact-local-path.js && node --check lib/telemetry.js && node --check scripts/gittensor-score-preview.mjs"
},
"dependencies": {
"@loopover/engine": "^3.0.0",
"@modelcontextprotocol/sdk": "1.29.0",
"posthog-node": "^5.44.0",
"zod": "^4.4.3"
},
"engines": {
Expand Down
1 change: 1 addition & 0 deletions scripts/mcp-package-allowlist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const MCP_PACKAGE_ALLOWED_FILE_PATTERNS = [
/^lib\/local-branch\.js$/,
/^lib\/format-table\.js$/,
/^lib\/redact-local-path\.js$/,
/^lib\/telemetry\.js$/,
/^scripts\/gittensor-score-preview\.(mjs|py)$/,
/^package\.json$/,
/^README\.md$/,
Expand Down
166 changes: 166 additions & 0 deletions test/unit/mcp-local-telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// Mock the PostHog Node SDK so nothing hits the network: the class records every constructor + capture call
// on hoisted spies, and per-test flags let us force an init/capture failure to exercise the never-throw path.
// Mirrors test/unit/mcp-telemetry.test.ts's mock for the remote wrapper (#6235).
const h = vi.hoisted(() => ({
constructSpy: vi.fn(),
captureSpy: vi.fn(),
state: { throwOnConstruct: false, throwOnCapture: false },
}));

vi.mock("posthog-node", () => ({
PostHog: class {
constructor(apiKey: string, options: unknown) {
h.constructSpy(apiKey, options);
if (h.state.throwOnConstruct) throw new Error("posthog init failed");
}
capture(message: unknown): void {
h.captureSpy(message);
if (h.state.throwOnCapture) throw new Error("posthog capture failed");
}
},
}));

// @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package.
const { recordMcpToolCall } = await import("../../packages/loopover-mcp/lib/telemetry.js");

type LocalToolCallEvent = { tool: string; callerType?: "local"; ok: boolean; durationMs: number };
type CapturedMessage = { distinctId: string; event: string; properties: Record<string, unknown>; disableGeoip: boolean };

const EVENT: LocalToolCallEvent = { tool: "predict_gate", callerType: "local", ok: true, durationMs: 42 };

describe("recordMcpToolCall (local MCP wrapper, #6236)", () => {
beforeEach(() => {
h.constructSpy.mockClear();
h.captureSpy.mockClear();
h.state.throwOnConstruct = false;
h.state.throwOnCapture = false;
});

afterEach(() => {
vi.unstubAllEnvs();
});

it("is a safe no-op when telemetry is not opted in, even with an API key configured", () => {
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
recordMcpToolCall({ telemetryEnabled: false }, EVENT);
expect(h.constructSpy).not.toHaveBeenCalled();
expect(h.captureSpy).not.toHaveBeenCalled();
});

it("is a safe no-op when telemetryEnabled is omitted (default OFF)", () => {
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
recordMcpToolCall({}, EVENT);
expect(h.constructSpy).not.toHaveBeenCalled();
expect(h.captureSpy).not.toHaveBeenCalled();
});

it("is a safe no-op when opted in but LOOPOVER_MCP_POSTHOG_API_KEY is unset", () => {
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", undefined);
recordMcpToolCall({ telemetryEnabled: true }, EVENT);
expect(h.constructSpy).not.toHaveBeenCalled();
expect(h.captureSpy).not.toHaveBeenCalled();
});

it("treats a blank/whitespace API key as unconfigured", () => {
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", " ");
recordMcpToolCall({ telemetryEnabled: true }, EVENT);
expect(h.constructSpy).not.toHaveBeenCalled();
expect(h.captureSpy).not.toHaveBeenCalled();
});

it("captures exactly the allowlisted fields against the US-cloud default host when opted in and configured", () => {
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
recordMcpToolCall({ telemetryEnabled: true }, EVENT);

expect(h.constructSpy).toHaveBeenCalledTimes(1);
expect(h.constructSpy).toHaveBeenCalledWith("phc_test", {
host: "https://us.i.posthog.com",
flushAt: 1,
flushInterval: 0,
});

expect(h.captureSpy).toHaveBeenCalledTimes(1);
const message = h.captureSpy.mock.calls[0]![0] as CapturedMessage;
expect(message.distinctId).toBe("loopover-mcp");
expect(message.event).toBe("mcp_tool_call");
expect(message.disableGeoip).toBe(true);
expect(message.properties).toEqual({
tool: "predict_gate",
caller_type: "local",
ok: true,
duration_ms: 42,
});
// The allowlist is the whole payload -- no argument/source/wallet/hotkey/trust-score field can ride along.
expect(Object.keys(message.properties).sort()).toEqual(["caller_type", "duration_ms", "ok", "tool"]);
});

it("defaults callerType to local when the caller omits it", () => {
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
recordMcpToolCall({ telemetryEnabled: true }, { tool: "status", ok: false, durationMs: 0 });

const message = h.captureSpy.mock.calls[0]![0] as CapturedMessage;
expect(message.properties).toEqual({
tool: "status",
caller_type: "local",
ok: false,
duration_ms: 0,
});
});

it("honors a LOOPOVER_MCP_POSTHOG_HOST override and carries a failed call verbatim", () => {
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
vi.stubEnv("LOOPOVER_MCP_POSTHOG_HOST", "https://eu.i.posthog.com");
recordMcpToolCall({ telemetryEnabled: true }, { tool: "check_slop_risk", callerType: "local", ok: false, durationMs: 7 });

expect(h.constructSpy).toHaveBeenCalledWith("phc_test", {
host: "https://eu.i.posthog.com",
flushAt: 1,
flushInterval: 0,
});
const message = h.captureSpy.mock.calls[0]![0] as CapturedMessage;
expect(message.properties).toEqual({
tool: "check_slop_risk",
caller_type: "local",
ok: false,
duration_ms: 7,
});
});

it("trims surrounding whitespace from the API key and host", () => {
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", " phc_test ");
vi.stubEnv("LOOPOVER_MCP_POSTHOG_HOST", " https://eu.i.posthog.com ");
recordMcpToolCall({ telemetryEnabled: true }, EVENT);
expect(h.constructSpy).toHaveBeenCalledWith("phc_test", {
host: "https://eu.i.posthog.com",
flushAt: 1,
flushInterval: 0,
});
});

it("falls back to the default host when LOOPOVER_MCP_POSTHOG_HOST is blank", () => {
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
vi.stubEnv("LOOPOVER_MCP_POSTHOG_HOST", " ");
recordMcpToolCall({ telemetryEnabled: true }, EVENT);
expect(h.constructSpy).toHaveBeenCalledWith("phc_test", {
host: "https://us.i.posthog.com",
flushAt: 1,
flushInterval: 0,
});
});

it("never throws when the PostHog client fails to initialize", () => {
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
h.state.throwOnConstruct = true;
expect(() => recordMcpToolCall({ telemetryEnabled: true }, EVENT)).not.toThrow();
expect(h.captureSpy).not.toHaveBeenCalled();
});

it("never throws when capture itself fails", () => {
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
h.state.throwOnCapture = true;
expect(() => recordMcpToolCall({ telemetryEnabled: true }, EVENT)).not.toThrow();
expect(h.captureSpy).toHaveBeenCalledTimes(1);
});
});
Loading