docs/features/telemetry.md
MCPProxy collects anonymous usage statistics to help improve the product. This page explains what is collected, what is not, and how to disable it.
MCPProxy sends a daily heartbeat containing only aggregate, non-identifying information. The current schema is version 7 (schema_version: 7 in the JSON payload); the schema is forward-compatible so older consumers simply ignore fields they don't recognize.
| Field | Example | Purpose |
|---|---|---|
anonymous_id | 550e8400-... | Random UUID for deduplication (not linked to you) |
machine_id | 9f86d081... (64-hex) | Stable, non-reversible salted hash of the OS machine id — dedups ephemeral installs whose anonymous_id churns every run (schema v6). Empty/omitted when unreadable. Never the raw machine id |
version | 0.21.3 | Track version adoption |
edition | personal | Understand edition usage |
os | darwin | Platform distribution |
arch | arm64 | Architecture distribution |
server_count | 12 | Understand scale of usage |
connected_server_count | 8 | Connection success rates |
tool_count | 156 | Tool ecosystem size |
uptime_hours | 47 | Usage patterns |
routing_mode | retrieve_tools | Feature adoption |
quarantine_enabled | true | Security feature adoption |
feature_flags.docker_available | true | Fraction of installs with a reachable Docker daemon (schema v3) |
server_protocol_counts | {"stdio":3,"http":2,"sse":0,"streamable_http":1,"auto":0} | Ratio of remote-HTTP vs local-stdio upstreams (schema v3) |
server_docker_isolated_count | 2 | How many configured servers the runtime actually wraps in Docker isolation (schema v3) |
feature_flags.docker_isolation_enabled | true | Whether global Docker isolation is turned on (schema v5). Lets us tell "isolation on, 0 matching servers" apart from "isolation off" |
feature_flags.docker_cli_source | bundled | How the docker CLI was located — fixed enum path / bundled / login_shell / absent (schema v5). The direct signal for "Docker installed but not on the spawn PATH" (issue #696). Never the path string itself |
wizard_shown | true | Whether the onboarding wizard ever rendered for this install (schema v7). Makes "shown but ignored" measurable |
wizard_connect_step | completed_external | Onboarding connect-step outcome — fixed enum, widened in v7 (see below) |
web_ui_opened | 12 | Lifetime count of embedded Web UI entrypoint serves (schema v7) |
days_since_install | 14 | Whole-day age of the install (schema v7). A day count, never a timestamp |
active_days_30d | 5 | Distinct UTC days with process activity in the trailing 30 days (schema v7). Only the count — never the per-day breakdown |
previous_shutdown | clean | How the previous process instance ended — fixed enum clean / crash, absent on first run (schema v7) |
last_error_code | MCPX_DOCKER_CLI_NOT_FOUND | Most recent stable MCPX_* diagnostic code (schema v7). Enum code only, never error text |
The server_protocol_counts map uses a fixed enum of keys (stdio, http, sse, streamable_http, auto) — server names and URLs are never included. Unknown or misconfigured protocol values are bucketed into auto.
The docker_cli_source field is likewise a fixed enum (path, bundled, login_shell, absent); the resolved path is never transmitted.
Docker isolation failures surface in error_code_counts_24h via three stable diagnostic codes (schema v5): MCPX_DOCKER_CLI_NOT_FOUND (isolation requested but the docker binary is unresolved — issue #696), MCPX_DOCKER_EXEC_NOT_FOUND (the image lacks the interpreter the server needs, e.g. uvx missing in python:3.11), and MCPX_DOCKER_OCI_RUNTIME (OCI runtime / architecture-mismatch failures).
The following is never collected:
machine_id hash is sent — see below)The anonymous ID is a random UUID (v4) generated on first run. It has no correlation to your hardware, user account, or identity. It exists solely to deduplicate heartbeats (so we don't count the same install twice in a day).
You can delete it by removing the telemetry.anonymous_id field from your config — a new random ID will be generated on next startup.
The anonymous_id above is a UUID persisted in the config file. In ephemeral environments — throwaway HOMEs, layered Docker builds, CI runners — the config (and therefore the UUID) is regenerated on every run, so a single machine can masquerade as hundreds of distinct installs. That inflates our install counts and defeats deduplication.
machine_id fixes this without collecting anything identifying:
HMAC-SHA256 keyed by the OS machine id, scoped by an mcpproxy-specific application key. The raw machine id is never transmitted; only the hash leaves your machine./etc/machine-id, a permission error, or an exotic platform), the field is simply omitted — the heartbeat is never blocked, and the backend treats an absent value as "unknown".machine_id respects the same opt-out as every other field: when telemetry is disabled (see below), the entire heartbeat — including machine_id — is never sent.
Schema v7 adds seven purely additive signals so we can measure whether installs come back after day one — and, when they don't, whether the last session ended cleanly or crashed. Every field keeps the established privacy posture: booleans, non-negative integers, or documented fixed enums only — no timestamps, no per-server identity, no free text. The anonymity scanner (internal/telemetry/anonymity.go) enforces these shapes on the serialized payload before every send, and all fields use omitempty, so a payload with none of them set is shape-identical to a v6 payload except for schema_version.
wizard_connect_stepThe onboarding connect-step status (a v4 field) gains a fourth value in v7:
| Value | Meaning |
|---|---|
| (absent) | Step never shown to this install |
completed | User completed the connect step inside the wizard |
completed_external | New in v7. User dismissed the wizard with the connect step untouched, but the install was already connected (via mcpproxy connect, the ConnectModal, or manual config). Previously miscounted as skipped |
skipped | User dismissed the wizard with the connect step untouched and no connection evidence existed |
Guidance for consumers: this is a string enum that may widen again. Code that switches on completed / skipped must treat unknown values as "other/engaged", never as a skip or an error. Statuses recorded before v7 are never rewritten — segment analyses by schema_version.
| Field | Type | When it is set | Privacy rationale |
|---|---|---|---|
wizard_shown | boolean | true once the onboarding wizard has rendered at least once for this install; omitted otherwise. Together with wizard_engaged it distinguishes "shown but ignored" from "never shown" | A single boolean about our own UI; carries no user data |
web_ui_opened | non-negative integer | Lifetime count of serves of the embedded Web UI entrypoint (index document). Asset and API requests never increment it; it is independent of surface_requests.webui. Coarse by design — health checkers fetching / count too | A counter of our own page serves; no URLs, sessions, or timing |
days_since_install | non-negative integer | Whole-day UTC age of the install, from a persisted first-install day stamp (independent of anonymous_id). 0 on install day; clamped at 0 on clock skew. Omitted when the local store isn't available (short-lived CLI commands) | Only a day count is transmitted — the install timestamp itself never leaves the machine |
active_days_30d | non-negative integer (1–30) | Number of distinct UTC days with process activity in the trailing 30-day window. Old days age out | The per-day set is stored locally and never transmitted — counters, not timelines |
previous_shutdown | fixed enum clean | crash | How the previous process instance ended: clean = the graceful-shutdown path ran; crash = it didn't (SIGKILL, panic, power loss). Absent on a first-ever run — a fresh install is never reported as a crash. Stable across all heartbeats of the current instance | One enum value about our own process lifecycle; no stack traces, no session timing |
last_error_code | fixed enum (MCPX_*) | The most recently observed stable diagnostic code (same fixed set as diagnostics.error_code_counts_24h), persisted across restarts so a post-crash heartbeat carries the pre-crash code. Absent when no error was ever recorded | Only the enum code is stored and sent — never error messages, server names, paths, or stack traces. The scanner rejects any value outside the fixed diagnostics catalog |
Why these exist: telemetry showed most installs connect successfully but never return after day one. days_since_install + active_days_30d make retention computable from a single heartbeat (no cross-heartbeat identity joins), and previous_shutdown + last_error_code let the final heartbeat before an install goes silent distinguish "crashed and never came back" from "exited cleanly and never returned".
All v7 fields ride the same opt-out as the rest of the heartbeat: when telemetry is disabled, nothing is transmitted. Local counters may still persist on disk (so re-enabling doesn't fabricate a fresh-install picture), but they never leave the machine.
You can inspect exactly what would be sent — including every v7 field — with:
mcpproxy telemetry show-payload
When telemetry transitions from enabled to disabled (via the CLI, the config
file, or the web UI / macOS app), MCPProxy sends exactly one final, anonymous
beacon — an event: "telemetry_disabled" carrying only your anonymous install
ID and no usage data. It lets us count how many installs opt out so we can
gauge how the feature is received. The send is best-effort: if it fails,
telemetry is still disabled. After it, no further telemetry is emitted.
Disabling while already disabled (or reloading a config that is already
disabled) sends nothing. Setting MCPPROXY_TELEMETRY=false is treated as
"never enabled" and also sends nothing.
There are three ways to disable telemetry:
mcpproxy telemetry disable
Verify with:
mcpproxy telemetry status
Re-enable anytime:
mcpproxy telemetry enable
Edit ~/.mcpproxy/mcp_config.json:
{
"telemetry": {
"enabled": false
}
}
export MCPPROXY_TELEMETRY=false
This overrides the config file setting and is useful for CI/CD environments or system-wide policies.
The telemetry implementation is fully open-source:
internal/telemetry/telemetry.go — heartbeat logicinternal/config/config.go — configuration (TelemetryConfig)