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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,8 @@ See `docs/prerelease-builds.md` for download instructions.
- N/A (no new storage requirements) (033-typescript-code-execution)
- Swift 5.9+ / Xcode 15+ + SwiftUI, AppKit (escape hatches), Sparkle 2.x (SPM), Foundation (URLSession, Process, UNUserNotificationCenter) (037-macos-swift-tray)
- N/A (tray reads all state from core via REST API — no local persistence per Constitution III) (037-macos-swift-tray)
- Go 1.24 (toolchain go1.24.10) — primary; Swift 5.9 — macOS tray header change only + `github.com/google/uuid` (existing), `github.com/go-chi/chi/v5` (existing, for `RoutePattern()`), `github.com/spf13/cobra` (existing, new subcommand), `go.uber.org/zap` (existing), stdlib `sync/atomic`, `sync`, `os` (042-telemetry-tier2)
- Config file `~/.mcpproxy/mcp_config.json` only — counters live in memory and are never persisted between restarts (privacy constraint). No BBolt buckets, no new files. (042-telemetry-tier2)

## Recent Changes
- 001-update-version-display: Added Go 1.24 (toolchain go1.24.10)
217 changes: 98 additions & 119 deletions autonomous_summary.md

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions cmd/mcpproxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,15 @@ import (
"go.uber.org/zap"

clioutput "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/experiments"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/logs"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/registries"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/server"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/telemetry"
_ "github.com/smart-mcp-proxy/mcpproxy-go/oas" // Import generated swagger docs
)

Expand Down Expand Up @@ -497,6 +499,8 @@ func runServer(cmd *cobra.Command, _ []string) error {
// Pass edition and version to internal packages
httpapi.SetEdition(Edition)
server.SetMCPServerVersion(version)
// Spec 042: surface header for outbound CLI HTTP requests.
cliclient.SetClientVersion(version)

// Override other settings from command line
cfg.DebugSearch = cmdDebugSearch
Expand Down Expand Up @@ -591,9 +595,18 @@ func runServer(cmd *cobra.Command, _ []string) error {
}
srv, err := server.NewServerWithConfigPath(cfg, actualConfigPath, logger)
if err != nil {
// Spec 042: classify the failure into a startup outcome enum.
recordStartupOutcome(cfg, actualConfigPath, classifyStartupError(err))
return fmt.Errorf("failed to create server: %w", err)
}

// Spec 042: print the one-time first-run telemetry notice on stderr (if
// the user has not already seen it). Persist the flag so we never nag
// twice.
if telemetry.MaybePrintFirstRunNotice(cfg, os.Stderr) {
_ = config.SaveConfig(cfg, actualConfigPath)
}

// Setup signal handling for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())

Expand Down Expand Up @@ -637,8 +650,12 @@ func runServer(cmd *cobra.Command, _ []string) error {
// Start the server
logger.Info("Starting mcpproxy server")
if err := srv.StartServer(ctx); err != nil {
// Spec 042: classify the failure into a startup outcome enum.
recordStartupOutcome(cfg, actualConfigPath, classifyStartupError(err))
return fmt.Errorf("failed to start server: %w", err)
}
// Spec 042: clean start.
recordStartupOutcome(cfg, actualConfigPath, "success")

// Wait for context to be cancelled
<-ctx.Done()
Expand Down
52 changes: 52 additions & 0 deletions cmd/mcpproxy/startup_outcome.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package main

import (
"go.uber.org/zap"

"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
)

// classifyStartupError maps an error from the serve startup path to a Spec
// 042 startup-outcome enum value. The mapping mirrors classifyError() / exit
// codes so the telemetry signal aligns with the user-visible exit code.
func classifyStartupError(err error) string {
switch classifyError(err) {
case ExitCodePortConflict:
return "port_conflict"
case ExitCodeDBLocked:
return "db_locked"
case ExitCodeConfigError:
return "config_error"
case ExitCodePermissionError:
return "permission_error"
case ExitCodeSuccess:
return "success"
default:
return "other_error"
}
}

// recordStartupOutcome persists the last startup outcome to the config file.
// Spec 042 User Story 5. The next heartbeat reads this value into the payload
// as last_startup_outcome.
func recordStartupOutcome(cfg *config.Config, configPath, outcome string) {
if cfg == nil {
return
}
if cfg.Telemetry == nil {
cfg.Telemetry = &config.TelemetryConfig{}
}
if cfg.Telemetry.LastStartupOutcome == outcome {
return
}
cfg.Telemetry.LastStartupOutcome = outcome
if configPath == "" {
return
}
if err := config.SaveConfig(cfg, configPath); err != nil {
// Best-effort; telemetry must never block startup.
zap.L().Debug("Failed to persist last_startup_outcome",
zap.String("outcome", outcome),
zap.Error(err))
}
}
48 changes: 48 additions & 0 deletions cmd/mcpproxy/startup_outcome_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package main

import (
"errors"
"testing"

"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
)

func TestRecordStartupOutcomeMapping(t *testing.T) {
cases := []struct {
name string
err error
want string
}{
{"nil → success", nil, "success"},
{"port conflict text", errors.New("listen tcp: bind: address already in use"), "port_conflict"},
{"db locked text", errors.New("database is locked"), "db_locked"},
{"config error text", errors.New("invalid configuration: bad field"), "config_error"},
{"unrelated error", errors.New("kaboom"), "other_error"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := classifyStartupError(c.err)
if got != c.want {
t.Errorf("classifyStartupError(%q) = %q, want %q", c.err, got, c.want)
}
})
}
}

func TestRecordStartupOutcomePersists(t *testing.T) {
cfg := &config.Config{}
recordStartupOutcome(cfg, "", "success")

if cfg.Telemetry == nil {
t.Fatal("Telemetry should be initialized")
}
if cfg.Telemetry.LastStartupOutcome != "success" {
t.Errorf("LastStartupOutcome = %q", cfg.Telemetry.LastStartupOutcome)
}

// Idempotent: second call with same outcome leaves the field unchanged.
recordStartupOutcome(cfg, "", "success")
if cfg.Telemetry.LastStartupOutcome != "success" {
t.Errorf("LastStartupOutcome changed: %q", cfg.Telemetry.LastStartupOutcome)
}
}
52 changes: 46 additions & 6 deletions cmd/mcpproxy/telemetry_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,21 @@ import (
"os"

"github.com/spf13/cobra"
"go.uber.org/zap"

clioutput "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/telemetry"
)

// TelemetryStatus holds status data for display.
type TelemetryStatus struct {
Enabled bool `json:"enabled"`
AnonymousID string `json:"anonymous_id,omitempty"`
Endpoint string `json:"endpoint"`
EnvOverride bool `json:"env_override,omitempty"`
Enabled bool `json:"enabled"`
AnonymousID string `json:"anonymous_id,omitempty"`
Endpoint string `json:"endpoint"`
EnvOverride bool `json:"env_override,omitempty"`
EnvOverrideName string `json:"env_override_name,omitempty"`
}

// GetTelemetryCommand returns the telemetry management command.
Expand All @@ -39,10 +43,43 @@ Examples:
telemetryCmd.AddCommand(getTelemetryStatusCommand())
telemetryCmd.AddCommand(getTelemetryEnableCommand())
telemetryCmd.AddCommand(getTelemetryDisableCommand())
telemetryCmd.AddCommand(getTelemetryShowPayloadCommand())

return telemetryCmd
}

func getTelemetryShowPayloadCommand() *cobra.Command {
return &cobra.Command{
Use: "show-payload",
Short: "Print the next telemetry payload as JSON (no network call)",
Long: `Print the exact JSON heartbeat payload that mcpproxy would next
send to the telemetry endpoint, without making any network call. Counters in
the payload reflect the current in-memory state. Spec 042.

Use this command to audit what telemetry mcpproxy collects on your install.`,
RunE: runTelemetryShowPayload,
}
}

func runTelemetryShowPayload(_ *cobra.Command, _ []string) error {
cfg, err := loadTelemetryConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
configPath := config.GetConfigPath(cfg.DataDir)

// Build a non-running telemetry service. We never call Start, so no
// goroutine is launched and no network call is made.
svc := telemetry.New(cfg, configPath, httpapi.GetBuildVersion(), Edition, zap.NewNop())
payload := svc.BuildPayload()
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
fmt.Println(string(data))
return nil
}

func getTelemetryStatusCommand() *cobra.Command {
return &cobra.Command{
Use: "status",
Expand Down Expand Up @@ -82,8 +119,11 @@ func runTelemetryStatus(cmd *cobra.Command, _ []string) error {
status.AnonymousID = id
}

if os.Getenv("MCPPROXY_TELEMETRY") == "false" {
// Spec 042: env vars override config (DO_NOT_TRACK > CI > MCPPROXY_TELEMETRY).
if disabled, reason := telemetry.IsDisabledByEnv(); disabled {
status.EnvOverride = true
status.EnvOverrideName = string(reason)
status.Enabled = false
}

format := clioutput.ResolveFormat(globalOutputFormat, globalJSONOutput)
Expand Down Expand Up @@ -112,7 +152,7 @@ func runTelemetryStatus(cmd *cobra.Command, _ []string) error {
}
fmt.Printf(" %-14s %s\n", "Status:", enabledStr)
if status.EnvOverride {
fmt.Printf(" %-14s %s\n", "Override:", "MCPPROXY_TELEMETRY=false")
fmt.Printf(" %-14s %s\n", "Override:", status.EnvOverrideName)
}
if status.AnonymousID != "" {
fmt.Printf(" %-14s %s\n", "Anonymous ID:", status.AnonymousID)
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ class APIService {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
// Spec 042: telemetry surface header so the daemon can attribute
// requests to the web UI for the surface_requests counter. Version
// is intentionally a constant string — the daemon already reports
// its own build version separately and we don't want to leak the
// browser/UA fingerprint into telemetry.
'X-MCPProxy-Client': 'webui/web',
}

// Merge headers from options if they exist
Expand Down
40 changes: 38 additions & 2 deletions internal/cliclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,40 @@ type Client struct {
logger *zap.SugaredLogger
}

// clientVersion holds the build-time version reported in X-MCPProxy-Client.
// Set via SetClientVersion at process startup. Defaults to "dev" so tests
// run without an explicit setup step. Spec 042 User Story 1.
var clientVersion = "dev"

// SetClientVersion sets the version reported in the X-MCPProxy-Client header.
func SetClientVersion(v string) {
if v != "" {
clientVersion = v
}
}

// surfaceHeaderTransport wraps another http.RoundTripper to inject the
// X-MCPProxy-Client header on every outbound request. The header value is
// "cli/<version>". Spec 042 User Story 1.
type surfaceHeaderTransport struct {
base http.RoundTripper
}

func (t *surfaceHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Header.Get("X-MCPProxy-Client") == "" {
// Clone the header map so we don't mutate caller-owned state.
newHeaders := req.Header.Clone()
if newHeaders == nil {
newHeaders = http.Header{}
}
newHeaders.Set("X-MCPProxy-Client", "cli/"+clientVersion)
reqCopy := req.Clone(req.Context())
reqCopy.Header = newHeaders
req = reqCopy
}
return t.base.RoundTrip(req)
}

// APIError represents an error from the API that includes request_id for log correlation.
// T023: Added for CLI error display with request ID
type APIError struct {
Expand Down Expand Up @@ -103,8 +137,10 @@ func NewClientWithAPIKey(endpoint, apiKey string, logger *zap.SugaredLogger) *Cl
baseURL: baseURL,
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 5 * time.Minute, // Generous timeout for long operations
Transport: transport,
Timeout: 5 * time.Minute, // Generous timeout for long operations
// Spec 042: wrap transport so every request carries the
// X-MCPProxy-Client: cli/<version> header.
Transport: &surfaceHeaderTransport{base: transport},
},
logger: logger,
}
Expand Down
34 changes: 34 additions & 0 deletions internal/cliclient/header_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package cliclient

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"go.uber.org/zap"
)

func TestCLIClientSetsXMCPProxyClientHeader(t *testing.T) {
SetClientVersion("v9.9.9")
defer SetClientVersion("dev")

var captured string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = r.Header.Get("X-MCPProxy-Client")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

c := NewClient(server.URL, zap.NewNop().Sugar())
resp, err := c.DoRaw(context.Background(), http.MethodGet, "/api/v1/status", nil)
if err != nil {
t.Fatalf("DoRaw error: %v", err)
}
resp.Body.Close()

want := "cli/v9.9.9"
if captured != want {
t.Errorf("X-MCPProxy-Client = %q, want %q", captured, want)
}
}
8 changes: 7 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1163,11 +1163,17 @@ func OAuthConfigChanged(old, new *OAuthConfig) bool {
return false
}

// TelemetryConfig controls anonymous usage telemetry (Spec 036)
// TelemetryConfig controls anonymous usage telemetry (Spec 036, extended in Spec 042).
type TelemetryConfig struct {
Enabled *bool `json:"enabled,omitempty" mapstructure:"enabled"` // Default: true (opt-out)
AnonymousID string `json:"anonymous_id,omitempty" mapstructure:"anonymous-id"` // Auto-generated UUIDv4
Endpoint string `json:"endpoint,omitempty" mapstructure:"endpoint"` // Override for testing

// Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.
AnonymousIDCreatedAt string `json:"anonymous_id_created_at,omitempty" mapstructure:"anonymous-id-created-at"` // RFC3339; for annual rotation
LastReportedVersion string `json:"last_reported_version,omitempty" mapstructure:"last-reported-version"` // Upgrade funnel
LastStartupOutcome string `json:"last_startup_outcome,omitempty" mapstructure:"last-startup-outcome"` // success|port_conflict|db_locked|...
NoticeShown bool `json:"notice_shown,omitempty" mapstructure:"notice-shown"` // First-run notice flag
}

// IsTelemetryEnabled returns whether telemetry is enabled.
Expand Down
Loading
Loading