Skip to content
Merged
24 changes: 24 additions & 0 deletions cmd/mcpproxy/telemetry_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/spf13/cobra"
"go.etcd.io/bbolt"
"go.uber.org/zap"

clioutput "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient"
Expand Down Expand Up @@ -267,6 +268,13 @@ func runTelemetryDisable(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("failed to load config: %w", err)
}

// Capture the EFFECTIVE resolved state BEFORE mutating, so we only beacon on
// a genuine enabled->disabled transition (MCP-2482). Effective resolution
// includes env overrides (DO_NOT_TRACK / CI), so an install where telemetry
// was never actually enabled emits nothing. A second `disable` when already
// disabled also emits nothing (wasEnabled == false).
wasEnabled := telemetry.EffectiveTelemetryEnabled(cfg)

if cfg.Telemetry == nil {
cfg.Telemetry = &config.TelemetryConfig{}
}
Expand All @@ -278,7 +286,23 @@ func runTelemetryDisable(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("failed to save config: %w", err)
}

// The disable is now persisted and effective — confirm immediately so the
// command never appears to hang on the (best-effort) beacon below.
fmt.Println("Telemetry disabled.")

// One-time opt-out beacon. When a daemon is running it does NOT auto-reload
// this file (there is no fsnotify watcher), so the CLI is responsible for the
// beacon in the CLI-driven path. Route it through the SAME guarded server-side
// entry point (EmitOptOutBeacon applies the dev-build/semver, env, and
// anon-id guards and owns the single send) rather than duplicating the send
// or bypassing a guard. A short timeout keeps this from blocking on a slow
// endpoint; the CLI is short-lived so the send must complete before exit.
if wasEnabled {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
beaconSvc := telemetry.New(cfg, "", version, Edition, zap.NewNop())
beaconSvc.EmitOptOutBeacon(ctx)
}
return nil
}

Expand Down
13 changes: 13 additions & 0 deletions docs/features/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ The anonymous ID is a random UUID (v4) generated on first run. It has **no corre

You can delete it by removing the `telemetry.anonymous_id` field from your config — a new random ID will be generated on next startup.

## One-time opt-out signal

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.

## How to disable

There are three ways to disable telemetry:
Expand Down
27 changes: 21 additions & 6 deletions frontend/src/components/TelemetryBanner.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div v-if="visible" class="alert alert-info">
<div v-if="visible" class="alert alert-info" data-test="telemetry-banner">
<svg class="w-6 h-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Expand All @@ -11,17 +11,32 @@
rel="noopener noreferrer"
class="link link-hover underline"
>Learn more</a>
<!-- Transparency note (MCP-2482): disclose the one-time opt-out signal. -->
<p class="text-xs opacity-80 mt-1" data-test="telemetry-banner-disclosure">
Disabling sends a single anonymous opt-out signal, then stops all telemetry.
</p>
</div>
<div class="flex items-center gap-2">
<RouterLink
to="/settings?focus=telemetry.enabled"
class="btn btn-sm btn-ghost"
data-test="telemetry-banner-settings-link"
@click="dismiss"
>
Manage in Settings
</RouterLink>
<button class="btn btn-sm btn-ghost btn-square" @click="dismiss" aria-label="Dismiss" data-test="telemetry-banner-dismiss">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button class="btn btn-sm btn-ghost" @click="dismiss" aria-label="Dismiss">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'

const STORAGE_KEY = 'telemetry-banner-dismissed'
const visible = ref(false)
Expand Down
49 changes: 45 additions & 4 deletions frontend/src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,8 @@
</template>

<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { useServersStore } from '@/stores/servers'
import { useSystemStore } from '@/stores/system'
Expand All @@ -200,6 +200,7 @@ import api from '@/services/api'

const serversStore = useServersStore()
const systemStore = useSystemStore()
const route = useRoute()

const securityFields = SECURITY_FIELDS
const generalFields = GENERAL_FIELDS
Expand Down Expand Up @@ -434,6 +435,41 @@ function handleConfigSaved() {
loadConfig()
}

// Deep-link support (MCP-2482): the telemetry consent banner links to
// /settings?focus=telemetry.enabled. Map any field key to the tab that renders
// it, switch to that tab, open the enclosing accordion if it lives under
// Advanced, then scroll to and briefly highlight the field row.
function tabForFieldKey(key: string): string {
if (securityFields.some((f) => f.key === key)) return 'security'
if (generalFields.some((f) => f.key === key)) return 'general'
if (advancedAccordions.value.some((a) => a.fields.some((f) => f.key === key))) return 'advanced'
if (serverEditionFields.some((f) => f.key === key)) return 'teams'
return activeTab.value
}

async function focusField(key: string) {
// Clear any active search so the tabbed layout (not search results) renders.
search.value = ''
activeTab.value = tabForFieldKey(key)
await nextTick()

// Advanced settings live inside collapsed <details>; open the one holding it.
const acc = advancedAccordions.value.find((a) => a.fields.some((f) => f.key === key))
if (acc) {
const summary = document.querySelector(`[data-test="settings-accordion-${acc.id}"]`)
const det = summary?.closest('details') as HTMLDetailsElement | null
if (det) det.open = true
await nextTick()
}

const row = document.querySelector(`[data-test="setting-row-${key}"]`) as HTMLElement | null
if (!row) return
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
const highlight = ['ring-2', 'ring-primary', 'ring-offset-2']
row.classList.add(...highlight)
setTimeout(() => row.classList.remove(...highlight), 2500)
}

// Fetch the resolved built-in MCP instructions default for the textarea
// placeholder (MCP-2175). Non-fatal: a failure or an older core just leaves the
// static catalogue placeholder in place.
Expand All @@ -456,10 +492,15 @@ watch(defaultInstructions, () => {
maybePrefillInstructions()
})

onMounted(() => {
loadConfig()
onMounted(async () => {
loadDefaultInstructions()
window.addEventListener('mcpproxy:config-saved', handleConfigSaved)
await loadConfig()
const focus = route.query.focus
if (typeof focus === 'string' && focus) {
await nextTick()
await focusField(focus)
}
})
onUnmounted(() => {
window.removeEventListener('mcpproxy:config-saved', handleConfigSaved)
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/views/settings/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,13 +251,13 @@ export const GENERAL_FIELDS: SettingField[] = [
key: 'telemetry.enabled',
docs: '/features/telemetry',
label: 'Anonymous usage telemetry',
help: 'Sends anonymous usage counts (never tool arguments, content, or identities). Opt-out at any time.',
help: 'Sends anonymous usage counts (never tool arguments, content, or identities). Opt-out at any time. Disabling sends a single anonymous opt-out signal, then stops all telemetry.',
control: 'toggle',
danger: {
confirmValue: false,
tone: 'info',
message:
'Anonymous telemetry is how we see which features matter and catch problems — it never includes your tool arguments, content, or any identifying info. Turning it off removes that signal. Turn it off anyway?',
'Anonymous telemetry is how we see which features matter and catch problems — it never includes your tool arguments, content, or any identifying info. Turning it off removes that signal, and sends a single anonymous opt-out signal before all telemetry stops. Turn it off anyway?',
},
},
{ key: 'enable_prompts', label: 'Expose MCP prompts to clients', help: 'Advertises mcpproxy’s built-in guided prompts to connected AI clients: “setup-new-mcp-server” (add a server) and “troubleshoot-mcp-server” (diagnose connection issues).', control: 'toggle' },
Expand Down
8 changes: 8 additions & 0 deletions internal/runtime/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,14 @@ func (r *Runtime) ReloadConfiguration() error {
return fmt.Errorf("failed to reload servers: %w", err)
}

// MCP-2482: detect a telemetry enabled->disabled flip across the reload and
// fire the one-time opt-out beacon. This covers config changes that arrive
// via a disk reload (there is no fsnotify auto-watcher, so this is the
// manual/triggered-reload path). nil-safe + fire-and-forget.
if r.telemetryService != nil {
r.telemetryService.NotifyConfigChanged(newSnapshot.Config)
}

go r.postConfigReload()

r.logger.Info("Configuration reload completed",
Expand Down
8 changes: 8 additions & 0 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -1304,6 +1304,14 @@ func (r *Runtime) ApplyConfig(newCfg *config.Config, cfgPath string) (*ConfigApp
// Event handlers may need to acquire locks on other resources
r.mu.Unlock()

// MCP-2482: drive the one-time telemetry opt-out beacon on an
// enabled->disabled flip. NotifyConfigChanged is fire-and-forget and
// nil-safe, so this never blocks the apply path. Covers web UI + macOS app,
// which both reach this via the REST /config apply pipeline.
if r.telemetryService != nil {
r.telemetryService.NotifyConfigChanged(newCfg)
}

// Update configSvc to notify subscribers (like supervisor)
// This must happen BEFORE LoadConfiguredServers to ensure supervisor reconciles
if err := r.configSvc.Update(&configCopy, configsvc.UpdateTypeModify, "api_apply_config"); err != nil {
Expand Down
Loading
Loading