diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78f8bef729..5f94cccfaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/package-lock.json b/package-lock.json index d89c709c41..b9512ce8ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20753,6 +20753,7 @@ "dependencies": { "@loopover/engine": "^3.0.0", "@modelcontextprotocol/sdk": "1.29.0", + "posthog-node": "^5.44.0", "zod": "^4.4.3" }, "bin": { diff --git a/packages/loopover-mcp/lib/telemetry.js b/packages/loopover-mcp/lib/telemetry.js new file mode 100644 index 0000000000..435b1eb395 --- /dev/null +++ b/packages/loopover-mcp/lib/telemetry.js @@ -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; +} diff --git a/packages/loopover-mcp/package.json b/packages/loopover-mcp/package.json index 515f9ba59f..2cb20c424a 100644 --- a/packages/loopover-mcp/package.json +++ b/packages/loopover-mcp/package.json @@ -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": { diff --git a/scripts/mcp-package-allowlist.mjs b/scripts/mcp-package-allowlist.mjs index 45c849cff4..c0bf12162d 100644 --- a/scripts/mcp-package-allowlist.mjs +++ b/scripts/mcp-package-allowlist.mjs @@ -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$/, diff --git a/test/unit/mcp-local-telemetry.test.ts b/test/unit/mcp-local-telemetry.test.ts new file mode 100644 index 0000000000..bf3779e164 --- /dev/null +++ b/test/unit/mcp-local-telemetry.test.ts @@ -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; 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); + }); +});