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
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,22 @@ mcpproxy token regenerate deploy-bot # Regenerate token secret

See [docs/features/agent-tokens.md](docs/features/agent-tokens.md) for complete reference.

### Telemetry CLI
```bash
mcpproxy telemetry status # Show telemetry status and anonymous ID
mcpproxy telemetry enable # Enable anonymous usage telemetry
mcpproxy telemetry disable # Disable telemetry (no data sent)
```

### Feedback CLI
```bash
mcpproxy feedback "message" # Submit bug report
mcpproxy feedback --category feature "Add SAML" # Feature request
mcpproxy feedback --category bug --email me@x.com "Crash" # With contact email
```

See [docs/features/telemetry.md](docs/features/telemetry.md) for telemetry details and privacy policy.

### CLI Output Formatting
```bash
mcpproxy upstream list -o json # JSON output for scripting
Expand Down Expand Up @@ -289,6 +305,7 @@ See [docs/socket-communication.md](docs/socket-communication.md) for details.
- `MCPPROXY_LISTEN` - Override network binding (e.g., `127.0.0.1:8080`)
- `MCPPROXY_API_KEY` - Set API key for REST API authentication
- `MCPPROXY_DEBUG` - Enable debug mode
- `MCPPROXY_TELEMETRY` - Set to `false` to disable anonymous telemetry (overrides config)
- `HEADLESS` - Run in headless mode (no browser launching)

See [docs/configuration.md](docs/configuration.md) for complete reference.
Expand Down Expand Up @@ -339,6 +356,7 @@ See [docs/configuration.md](docs/configuration.md) for complete reference.
| `POST /api/v1/servers/{id}/tools/approve` | Approve pending/changed tools (Spec 032) |
| `GET /api/v1/servers/{id}/tools/{tool}/diff` | View tool description/schema changes (Spec 032) |
| `GET /api/v1/servers/{id}/tools/export` | Export tool approval records (Spec 032) |
| `POST /api/v1/feedback` | Submit feedback/bug report (proxied to GitHub Issues) |
| `GET /events` | SSE stream for live updates |

**Authentication**: Use `X-API-Key` header or `?apikey=` query parameter.
Expand Down
121 changes: 121 additions & 0 deletions cmd/mcpproxy/feedback_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package main

import (
"context"
"encoding/json"
"fmt"
"time"

"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/telemetry"
)

var (
feedbackCategory string
feedbackEmail string
)

// GetFeedbackCommand returns the feedback submission command.
func GetFeedbackCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "feedback \"message\"",
Short: "Submit feedback (bug report, feature request, or general)",
Long: `Submit feedback to the MCPProxy team. Your message is sent to the
telemetry endpoint along with anonymous system context (version, OS, etc.).

Examples:
mcpproxy feedback "The search results could be more relevant" --category feature
mcpproxy feedback "Server crashes when adding OAuth server" --category bug
mcpproxy feedback "Great tool, thanks!" --category other --email me@example.com`,
Args: cobra.ExactArgs(1),
RunE: runFeedback,
}

cmd.Flags().StringVar(&feedbackCategory, "category", "other", "Feedback category: bug, feature, other")
cmd.Flags().StringVar(&feedbackEmail, "email", "", "Optional email for follow-up")

return cmd
}

func runFeedback(cmd *cobra.Command, args []string) error {
message := args[0]

// Validate inputs before sending
if !telemetry.ValidateCategory(feedbackCategory) {
return fmt.Errorf("invalid category %q: must be bug, feature, or other", feedbackCategory)
}
if err := telemetry.ValidateMessage(message); err != nil {
return fmt.Errorf("invalid message: %w", err)
}

cfg, err := loadFeedbackConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

logger, _ := zap.NewProduction()
defer logger.Sync()

cfgPath := config.GetConfigPath(cfg.DataDir)
svc := telemetry.New(cfg, cfgPath, version, Edition, logger)

req := &telemetry.FeedbackRequest{
Category: feedbackCategory,
Message: message,
Email: feedbackEmail,
}

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

resp, err := svc.SubmitFeedback(ctx, req)
if err != nil {
return fmt.Errorf("failed to submit feedback: %w", err)
}

format := clioutput.ResolveFormat(globalOutputFormat, globalJSONOutput)
switch format {
case "json":
data, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
default:
if resp.Success {
fmt.Println("Feedback submitted successfully!")
if resp.IssueURL != "" {
fmt.Printf("Track your feedback: %s\n", resp.IssueURL)
}
} else {
fmt.Printf("Feedback submission failed: %s\n", resp.Error)
}
}

return nil
}

func loadFeedbackConfig() (*config.Config, error) {
if configFile != "" {
cfg, err := config.LoadFromFile(configFile)
if err != nil {
return nil, err
}
if dataDir != "" {
cfg.DataDir = dataDir
}
return cfg, nil
}
cfg, err := config.Load()
if err != nil {
return nil, err
}
if dataDir != "" {
cfg.DataDir = dataDir
}
return cfg, nil
}
8 changes: 8 additions & 0 deletions cmd/mcpproxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ func main() {
// Add token command (Spec 028: Agent tokens)
tokenCmd := GetTokenCommand()

// Add telemetry command (Spec 036)
telemetryCmd := GetTelemetryCommand()

// Add feedback command (Spec 036)
feedbackCmd := GetFeedbackCommand()

// Add commands to root
rootCmd.AddCommand(serverCmd)
rootCmd.AddCommand(searchCmd)
Expand All @@ -187,6 +193,8 @@ func main() {
rootCmd.AddCommand(tuiCmd)
rootCmd.AddCommand(statusCmd)
rootCmd.AddCommand(tokenCmd)
rootCmd.AddCommand(telemetryCmd)
rootCmd.AddCommand(feedbackCmd)

// Setup --help-json for machine-readable help discovery
// This must be called AFTER all commands are added
Expand Down
190 changes: 190 additions & 0 deletions cmd/mcpproxy/telemetry_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package main

import (
"encoding/json"
"fmt"
"os"

"github.com/spf13/cobra"

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

// 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"`
}

// GetTelemetryCommand returns the telemetry management command.
func GetTelemetryCommand() *cobra.Command {
telemetryCmd := &cobra.Command{
Use: "telemetry",
Short: "Manage anonymous usage telemetry",
Long: `Manage anonymous usage telemetry for MCPProxy.

Telemetry sends anonymous, non-identifiable usage statistics to help
improve MCPProxy. No personal data, tool names, or server details are
ever transmitted.

Examples:
mcpproxy telemetry status # Show telemetry status
mcpproxy telemetry enable # Enable telemetry
mcpproxy telemetry disable # Disable telemetry`,
}

telemetryCmd.AddCommand(getTelemetryStatusCommand())
telemetryCmd.AddCommand(getTelemetryEnableCommand())
telemetryCmd.AddCommand(getTelemetryDisableCommand())

return telemetryCmd
}

func getTelemetryStatusCommand() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show telemetry status",
RunE: runTelemetryStatus,
}
}

func getTelemetryEnableCommand() *cobra.Command {
return &cobra.Command{
Use: "enable",
Short: "Enable anonymous telemetry",
RunE: runTelemetryEnable,
}
}

func getTelemetryDisableCommand() *cobra.Command {
return &cobra.Command{
Use: "disable",
Short: "Disable anonymous telemetry",
RunE: runTelemetryDisable,
}
}

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

status := TelemetryStatus{
Enabled: cfg.IsTelemetryEnabled(),
Endpoint: cfg.GetTelemetryEndpoint(),
}

if id := cfg.GetAnonymousID(); id != "" {
status.AnonymousID = id
}

if os.Getenv("MCPPROXY_TELEMETRY") == "false" {
status.EnvOverride = true
}

format := clioutput.ResolveFormat(globalOutputFormat, globalJSONOutput)
switch format {
case "json":
data, err := json.MarshalIndent(status, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
case "yaml":
formatter, err := clioutput.NewFormatter("yaml")
if err != nil {
return err
}
output, err := formatter.Format(status)
if err != nil {
return err
}
fmt.Println(output)
default:
fmt.Println("Telemetry Status")
enabledStr := "Enabled"
if !status.Enabled {
enabledStr = "Disabled"
}
fmt.Printf(" %-14s %s\n", "Status:", enabledStr)
if status.EnvOverride {
fmt.Printf(" %-14s %s\n", "Override:", "MCPPROXY_TELEMETRY=false")
}
if status.AnonymousID != "" {
fmt.Printf(" %-14s %s\n", "Anonymous ID:", status.AnonymousID)
}
fmt.Printf(" %-14s %s\n", "Endpoint:", status.Endpoint)
}

return nil
}

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

if cfg.Telemetry == nil {
cfg.Telemetry = &config.TelemetryConfig{}
}
enabled := true
cfg.Telemetry.Enabled = &enabled

configPath := config.GetConfigPath(cfg.DataDir)
if err := config.SaveConfig(cfg, configPath); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}

fmt.Println("Telemetry enabled.")
if os.Getenv("MCPPROXY_TELEMETRY") == "false" {
fmt.Println("Warning: MCPPROXY_TELEMETRY=false environment variable is set and will override this setting.")
}
return nil
}

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

if cfg.Telemetry == nil {
cfg.Telemetry = &config.TelemetryConfig{}
}
disabled := false
cfg.Telemetry.Enabled = &disabled

configPath := config.GetConfigPath(cfg.DataDir)
if err := config.SaveConfig(cfg, configPath); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}

fmt.Println("Telemetry disabled.")
return nil
}

func loadTelemetryConfig() (*config.Config, error) {
if configFile != "" {
cfg, err := config.LoadFromFile(configFile)
if err != nil {
return nil, err
}
if dataDir != "" {
cfg.DataDir = dataDir
}
return cfg, nil
}
cfg, err := config.Load()
if err != nil {
return nil, err
}
if dataDir != "" {
cfg.DataDir = dataDir
}
return cfg, nil
}
Loading
Loading