diff --git a/packages/loopover-mcp/README.md b/packages/loopover-mcp/README.md index b71f936c53..ae46875300 100644 --- a/packages/loopover-mcp/README.md +++ b/packages/loopover-mcp/README.md @@ -40,6 +40,10 @@ loopover-mcp status loopover-mcp changelog loopover-mcp doctor loopover-mcp doctor --exit-code +loopover-mcp telemetry status +loopover-mcp telemetry enable +loopover-mcp telemetry disable +loopover-mcp telemetry enable --json loopover-mcp profile list loopover-mcp profile create work loopover-mcp profile switch work @@ -267,7 +271,19 @@ The package ships with `CHANGELOG.md`. Run: loopover-mcp changelog ``` -`loopover-mcp status` also reports the local package version, latest npm version when reachable, API health, auth state, and source-upload posture. +`loopover-mcp status` also reports the local package version, latest npm version when reachable, API health, auth state, source-upload posture, and the local telemetry opt-in state. + +## Telemetry opt-in + +Local MCP usage telemetry is **opt-in and defaults to OFF** — nothing is measured until you explicitly enable it. Toggle it with: + +```sh +loopover-mcp telemetry enable +loopover-mcp telemetry disable +loopover-mcp telemetry status +``` + +Enabling persists a top-level `telemetryEnabled` flag in the same config file `loopover-mcp login` uses, so the choice survives across CLI invocations. `status`, `doctor`, and `config` all report the current opt-in state. Add `--json` to any of these for machine-readable output. ## Offline decision-pack fallback diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 5b285e8bf4..100084c6b1 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -46,6 +46,7 @@ const CLI_COMMAND_SPEC = { version: [], tools: ["search"], doctor: [], + telemetry: ["enable", "disable", "status"], "init-client": [], "decision-pack": [], "repo-decision": [], @@ -1995,6 +1996,7 @@ async function runCli(args) { if (command === "agent") return runAgentCli(args.slice(1)); if (command === "cache") return runCacheCli(args.slice(1)); if (command === "maintain") return maintainCli(args.slice(1)); + if (command === "telemetry") return telemetryCommand(args.slice(1)); const options = parseOptions(args.slice(1)); if (command === "login") return login(options); if (command === "logout") return logout(options); @@ -2819,6 +2821,7 @@ function printHelp() { loopover-mcp whoami [--profile name] [--json] loopover-mcp config [--profile name] [--json] loopover-mcp status [--profile name] [--json] + loopover-mcp telemetry enable|disable|status [--json] loopover-mcp profile list|create|switch|remove [name] [--json] loopover-mcp changelog [--json] loopover-mcp doctor [--profile name] [--cwd path] [--exit-code] [--json] @@ -2993,6 +2996,47 @@ async function logout(options) { else process.stdout.write(all ? "Logged out all profiles.\n" : `Logged out profile ${profileName}.\n`); } +// Local MCP usage telemetry is opt-in and defaults OFF (#6239, per #6228's privacy decision): a +// self-hoster must explicitly enable it before anything is measured. The opt-in is a single top-level +// `telemetryEnabled` flag persisted in the same config file `login` uses, so the choice survives across +// CLI invocations; `status`, `doctor`, and `config` all report the current state. +function telemetryCommand(args) { + const subcommand = args[0] ?? "status"; + const options = parseOptions(args.slice(1)); + if (subcommand === "--help" || subcommand === "help") return printTelemetryHelp(); + if (subcommand === "enable" || subcommand === "disable") { + const enabled = subcommand === "enable"; + const nextConfig = setTelemetryEnabled(config, enabled); + // Mirror login/logout persistence: keep the file when any durable state remains, otherwise remove it + // so disabling telemetry on an otherwise-empty config leaves no stray file behind. + if (hasPersistedConfigState(nextConfig)) saveConfig(nextConfig); + else if (existsSync(configPath)) rmSync(configPath, { force: true }); + const payload = { status: enabled ? "telemetry_enabled" : "telemetry_disabled", telemetry: telemetryState(nextConfig) }; + if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + else process.stdout.write(enabled ? "Local MCP usage telemetry enabled.\n" : "Local MCP usage telemetry disabled.\n"); + return; + } + if (subcommand === "status") { + const telemetry = telemetryState(config); + if (options.json) process.stdout.write(`${JSON.stringify({ telemetry }, null, 2)}\n`); + else process.stdout.write(`Telemetry: ${telemetry.enabled ? "enabled (opt-in)" : "disabled (default)"}\n`); + return; + } + throw new Error(`Unknown telemetry command: ${subcommand}. Use enable | disable | status.`); +} + +function printTelemetryHelp() { + process.stdout.write(`Usage: + loopover-mcp telemetry status [--json] + loopover-mcp telemetry enable [--json] + loopover-mcp telemetry disable [--json] + +Local MCP usage telemetry is opt-in and defaults OFF. Enabling it persists a top-level telemetryEnabled +flag in the same config file \`loopover-mcp login\` uses, so the choice survives across CLI invocations. +\`status\`, \`doctor\`, and \`config\` report the current opt-in state. +`); +} + function profileCommand(args) { const subcommand = args[0] ?? "list"; const options = parseOptions(args.slice(1)); @@ -3081,6 +3125,7 @@ async function status(options) { decisionPackCache, sourceUploadDefault: false, sourceUploadSupported: false, + telemetry: telemetryState(), }; if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); else { @@ -3091,6 +3136,7 @@ async function status(options) { process.stdout.write(`Auth: ${auth.status}${auth.login ? ` (${auth.login})` : ""}\n`); process.stdout.write(`Decision-pack cache: ${decisionPackCache.entries} entr${decisionPackCache.entries === 1 ? "y" : "ies"}\n`); process.stdout.write("Source upload: disabled\n"); + process.stdout.write(`Telemetry: ${payload.telemetry.enabled ? "enabled (opt-in)" : "disabled (default)"}\n`); if (pkg.state === "stale") { process.stdout.write(`Update available: ${packageVersion} -> ${pkg.latestVersion}. Upgrade with:\n ${pkg.upgradeCommand}\n`); process.stdout.write(`Or run without installing:\n ${pkg.npxFallback}\n`); @@ -3193,6 +3239,16 @@ async function doctor(options) { add("source_upload", "pass", "Source upload is disabled and unsupported in v1."); } + // Either telemetry stance is a valid, deliberate choice, so this is always a pass — it just makes the + // current opt-in visible (and points at the toggle) rather than gating the checklist. + const telemetry = telemetryState(); + add( + "telemetry", + "pass", + telemetry.enabled ? "Local MCP usage telemetry is enabled (opt-in)." : "Local MCP usage telemetry is disabled (default).", + telemetry.enabled ? "Run `loopover-mcp telemetry disable` to opt back out." : "Run `loopover-mcp telemetry enable` to opt in.", + ); + const decisionPackCache = inspectDecisionPackCache(); add( "decision_pack_cache", @@ -3257,6 +3313,7 @@ async function doctor(options) { config: { configured: existsSync(configPath), activeProfile: activeProfileName, profileCount: profileList(config).length }, decisionPackCache, sourceUploadSupported: false, + telemetry, checklist, nextCommand, checks, @@ -3319,7 +3376,7 @@ function doctorChecklistGroups() { { id: "api_compatibility", title: "API compatibility", checks: ["api_health", "version", "api_compatibility"] }, { id: "local_repo_readiness", title: "Local repo readiness", checks: ["git_metadata", "client_path"] }, { id: "scorer_availability", title: "Scorer availability", checks: ["local_scorer", "gittensor_root"] }, - { id: "output_safety", title: "Output safety", checks: ["source_upload", "decision_pack_cache"] }, + { id: "output_safety", title: "Output safety", checks: ["source_upload", "decision_pack_cache", "telemetry"] }, ]; } @@ -3532,6 +3589,15 @@ function sourceUploadState() { }; } +// Resolve the current local telemetry opt-in from persisted config. The flag is top-level (not +// per-profile) and defaults to disabled when absent, so an unconfigured install reports opt-out. +function telemetryState(currentConfig = config) { + return { + enabled: currentConfig.telemetryEnabled === true, + default: false, + }; +} + // Report the resolved effective configuration and where each value came from, without leaking // local absolute paths or token values. Distinct from `status` (health/version), `doctor` // (diagnostic checks), and `whoami` (session identity): this answers "what config is in effect @@ -3548,6 +3614,7 @@ function configCommand(options) { tokenConfigured: Boolean(getApiToken()), tokenSource: resolvedTokenSource(), sourceUpload: sourceUploadState(), + telemetry: telemetryState(), profile: profilePublicState(activeProfileName), }; if (options.json) { @@ -3564,6 +3631,7 @@ function configCommand(options) { ? `Source upload: enabled via ${payload.sourceUpload.source} (unsupported; unset LOOPOVER_UPLOAD_SOURCE)\n` : "Source upload: disabled (unsupported)\n", ); + process.stdout.write(`Telemetry: ${payload.telemetry.enabled ? "enabled (opt-in)" : "disabled (default)"}\n`); } function normalizeProfileName(value) { @@ -3637,8 +3705,14 @@ function removeProfile(currentConfig, profileName) { return normalizeConfig({ ...currentConfig, activeProfile, profiles, session }); } +function setTelemetryEnabled(currentConfig, enabled) { + // normalizeConfig coerces this to a strict boolean and strips it when not exactly `true`, so disabling + // removes the key entirely (default = absent) rather than persisting `telemetryEnabled: false`. + return normalizeConfig({ ...currentConfig, telemetryEnabled: enabled === true ? true : undefined }); +} + function hasPersistedConfigState(currentConfig) { - return Boolean(currentConfig.apiUrl || Object.keys(currentConfig.profiles ?? {}).length > 0); + return Boolean(currentConfig.apiUrl || currentConfig.telemetryEnabled === true || Object.keys(currentConfig.profiles ?? {}).length > 0); } function validationFromOptions(options) { @@ -4157,6 +4231,9 @@ function normalizeConfig(rawConfig) { activeProfile, profiles, session: profiles[defaultProfileName]?.session, + // Opt-in telemetry flag (#6239): only a literal `true` counts as enabled, so a malformed or legacy + // value in the config file falls back to the privacy-preserving default (absent = disabled). + telemetryEnabled: raw.telemetryEnabled === true ? true : undefined, }); } @@ -4189,6 +4266,7 @@ function configForPersistence(nextConfig) { activeProfile: normalized.activeProfile, profiles: normalized.profiles, session: normalized.profiles?.[defaultProfileName]?.session, + telemetryEnabled: normalized.telemetryEnabled, }); } diff --git a/test/unit/mcp-cli-completion-spec.test.ts b/test/unit/mcp-cli-completion-spec.test.ts index 05ce98d256..e5ae448a5c 100644 --- a/test/unit/mcp-cli-completion-spec.test.ts +++ b/test/unit/mcp-cli-completion-spec.test.ts @@ -56,6 +56,7 @@ const HANDLERS: Array<{ command: string; fn: RegExp }> = [ { command: "agent", fn: /(?:async )?function runAgentCli\([^)]*\)\s*\{/ }, { command: "profile", fn: /(?:async )?function profileCommand\([^)]*\)\s*\{/ }, { command: "maintain", fn: /(?:async )?function maintainCli\([^)]*\)\s*\{/ }, + { command: "telemetry", fn: /(?:async )?function telemetryCommand\([^)]*\)\s*\{/ }, ]; describe("loopover-mcp CLI_COMMAND_SPEC ↔ implementation parity (#6260)", () => { diff --git a/test/unit/mcp-cli-telemetry.test.ts b/test/unit/mcp-cli-telemetry.test.ts new file mode 100644 index 0000000000..f290d20410 --- /dev/null +++ b/test/unit/mcp-cli-telemetry.test.ts @@ -0,0 +1,158 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { closeFixtureServer, run, runAsync, runExpectingFailure, startFixtureServer } from "./support/mcp-cli-harness"; + +// #6239: local MCP usage telemetry is opt-in and defaults OFF (per #6228's privacy decision). The opt-in is +// a top-level `telemetryEnabled` flag persisted in the same config file `login` uses, and status/doctor/config +// surface the current state. These tests exercise both the default-off state and the enable/disable toggle. +describe("loopover-mcp CLI — telemetry opt-in", () => { + let tempDir: string | null = null; + + afterEach(async () => { + await closeFixtureServer(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + }); + + it("defaults telemetry to off and persists an explicit opt-in across CLI invocations", () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-cli-")); + const configPath = join(tempDir, "config.json"); + const env = { LOOPOVER_CONFIG_DIR: tempDir, LOOPOVER_SKIP_NPM_VERSION_CHECK: "true" }; + + // Default: nothing configured -> disabled, and status alone never writes a config file. + const before = JSON.parse(run(["telemetry", "status", "--json"], env)) as { telemetry: { enabled: boolean; default: boolean } }; + expect(before.telemetry).toEqual({ enabled: false, default: false }); + expect(existsSync(configPath)).toBe(false); + + // Enabling persists the flag to disk... + const enabled = JSON.parse(run(["telemetry", "enable", "--json"], env)) as { status: string; telemetry: { enabled: boolean } }; + expect(enabled).toMatchObject({ status: "telemetry_enabled", telemetry: { enabled: true, default: false } }); + const saved = JSON.parse(readFileSync(configPath, "utf8")) as { telemetryEnabled?: boolean }; + expect(saved.telemetryEnabled).toBe(true); + + // ...so a *fresh* process (new invocation) reads the opt-in back as enabled. + const persisted = JSON.parse(run(["telemetry", "status", "--json"], env)) as { telemetry: { enabled: boolean } }; + expect(persisted.telemetry.enabled).toBe(true); + + // Disabling clears the flag; with no other durable state, the config file is removed entirely + // (rather than left holding `telemetryEnabled: false`). + const disabled = JSON.parse(run(["telemetry", "disable", "--json"], env)) as { status: string; telemetry: { enabled: boolean } }; + expect(disabled).toMatchObject({ status: "telemetry_disabled", telemetry: { enabled: false } }); + expect(existsSync(configPath)).toBe(false); + const afterDisable = JSON.parse(run(["telemetry", "status", "--json"], env)) as { telemetry: { enabled: boolean } }; + expect(afterDisable.telemetry.enabled).toBe(false); + }); + + it("prints human-readable telemetry state and toggles", () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-cli-")); + const env = { LOOPOVER_CONFIG_DIR: tempDir, LOOPOVER_SKIP_NPM_VERSION_CHECK: "true" }; + + expect(run(["telemetry", "enable"], env)).toContain("Local MCP usage telemetry enabled."); + expect(run(["telemetry", "status"], env)).toContain("Telemetry: enabled (opt-in)"); + expect(run(["telemetry", "disable"], env)).toContain("Local MCP usage telemetry disabled."); + // A bare `telemetry` invocation defaults to the status view. + expect(run(["telemetry"], env)).toContain("Telemetry: disabled (default)"); + }); + + it("reports the current opt-in state through status, doctor, and config", async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-cli-")); + const url = await startFixtureServer(); + const env = { + LOOPOVER_API_URL: url, + LOOPOVER_TOKEN: "session-token", + LOOPOVER_CONFIG_DIR: tempDir, + LOOPOVER_SKIP_NPM_VERSION_CHECK: "true", + }; + + // Default-off surfaces everywhere. + const statusOff = JSON.parse(await runAsync(["status", "--json"], env)) as { telemetry: { enabled: boolean } }; + const configOff = JSON.parse(await runAsync(["config", "--json"], env)) as { telemetry: { enabled: boolean } }; + const doctorOff = JSON.parse(await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], env)) as { + telemetry: { enabled: boolean }; + checklist: Array<{ id: string; checks?: Array<{ name: string; status: string }> }>; + checks: Array<{ name: string; status: string; detail: string }>; + }; + expect(statusOff.telemetry.enabled).toBe(false); + expect(configOff.telemetry.enabled).toBe(false); + expect(doctorOff.telemetry.enabled).toBe(false); + // The telemetry check is a pass that lives under the Output safety group and states the default. + expect(doctorOff.checks).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "telemetry", status: "pass", detail: expect.stringContaining("disabled (default)") })]), + ); + const outputSafetyOff = doctorOff.checklist.find((group) => group.id === "output_safety"); + expect(outputSafetyOff?.checks).toEqual(expect.arrayContaining([expect.objectContaining({ name: "telemetry", status: "pass" })])); + + // After an explicit opt-in, every reporter reflects it. + await runAsync(["telemetry", "enable", "--json"], env); + const statusOn = JSON.parse(await runAsync(["status", "--json"], env)) as { telemetry: { enabled: boolean } }; + const configOn = JSON.parse(await runAsync(["config", "--json"], env)) as { telemetry: { enabled: boolean } }; + const doctorOn = JSON.parse(await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], env)) as { + telemetry: { enabled: boolean }; + checks: Array<{ name: string; status: string; detail: string }>; + }; + expect(statusOn.telemetry.enabled).toBe(true); + expect(configOn.telemetry.enabled).toBe(true); + expect(doctorOn.telemetry.enabled).toBe(true); + expect(doctorOn.checks).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "telemetry", status: "pass", detail: expect.stringContaining("enabled (opt-in)") })]), + ); + + // Human-readable status and config lines mention the state too. + const statusHuman = await runAsync(["status"], env); + const configHuman = await runAsync(["config"], env); + expect(statusHuman).toContain("Telemetry: enabled (opt-in)"); + expect(configHuman).toContain("Telemetry: enabled (opt-in)"); + }); + + it("keeps an authenticated profile intact when telemetry is toggled", async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-cli-")); + const configPath = join(tempDir, "config.json"); + const url = await startFixtureServer(); + const env = { + LOOPOVER_API_URL: url, + LOOPOVER_CONFIG_DIR: tempDir, + LOOPOVER_SKIP_NPM_VERSION_CHECK: "true", + }; + + await runAsync(["login", "--profile", "jsonbored", "--github-token", "github-jsonbored", "--json"], env); + const enabled = JSON.parse(await runAsync(["telemetry", "enable", "--json"], env)) as { telemetry: { enabled: boolean } }; + expect(enabled.telemetry.enabled).toBe(true); + + // The opt-in persists alongside the existing session, without clobbering it. + const savedAfterEnable = JSON.parse(readFileSync(configPath, "utf8")) as { telemetryEnabled?: boolean; profiles?: Record }; + expect(savedAfterEnable.telemetryEnabled).toBe(true); + expect(savedAfterEnable.profiles?.jsonbored?.session?.login).toBe("JSONbored"); + + // Disabling telemetry clears only the flag; the authenticated profile survives, so the file stays. + await runAsync(["telemetry", "disable", "--json"], env); + const savedAfterDisable = JSON.parse(readFileSync(configPath, "utf8")) as { telemetryEnabled?: boolean; profiles?: Record }; + expect(savedAfterDisable.telemetryEnabled).toBeUndefined(); + expect(savedAfterDisable.profiles?.jsonbored?.session?.login).toBe("JSONbored"); + // Telemetry output never leaks the persisted session token or the temp path. + expect(JSON.stringify({ enabled })).not.toMatch(/session-jsonbored|github-jsonbored|loopover-cli-/); + }); + + it("treats a malformed persisted telemetryEnabled value as the privacy-preserving default", () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-cli-")); + const configPath = join(tempDir, "config.json"); + // A non-boolean (legacy/hand-edited) value must not be read as an opt-in. + writeFileSync(configPath, JSON.stringify({ telemetryEnabled: "true", profiles: {} }), { mode: 0o600 }); + const env = { LOOPOVER_CONFIG_DIR: tempDir, LOOPOVER_SKIP_NPM_VERSION_CHECK: "true" }; + + const status = JSON.parse(run(["telemetry", "status", "--json"], env)) as { telemetry: { enabled: boolean } }; + expect(status.telemetry.enabled).toBe(false); + }); + + it("rejects an unknown telemetry subcommand in both plain and --json modes", () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-cli-")); + const env = { LOOPOVER_CONFIG_DIR: tempDir, LOOPOVER_SKIP_NPM_VERSION_CHECK: "true" }; + + expect(() => run(["telemetry", "bogus"], env)).toThrow(/Unknown telemetry command: bogus\. Use enable \| disable \| status\./); + + const failure = runExpectingFailure(["telemetry", "bogus", "--json"], env); + expect(failure.status).not.toBe(0); + expect(JSON.parse(failure.stdout)).toMatchObject({ ok: false, error: expect.stringMatching(/Unknown telemetry command: bogus/) }); + }); +});