Skip to content
9 changes: 9 additions & 0 deletions cmd/mcpproxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,15 @@ func loadConfig(cmd *cobra.Command) (*config.Config, error) {

// Load configuration - use LoadFromFile if config file specified, otherwise use Load
if configFile != "" {
// A named config that has never been written is a FIRST RUN, not an
// error: the relocated tray instance of GH #936 spawns its core with
// --data-dir <root> --config <root>/mcp_config.json against a root
// nothing has used before. The implicit path has always created its
// default here; the explicit one used to exit with "no such file or
// directory" instead, which made that whole flow unusable.
if _, ensureErr := config.EnsureConfigFile(configFile, dataDir); ensureErr != nil {
return nil, ensureErr
}
cfg, err = config.LoadFromFile(configFile)
} else {
cfg, err = config.Load()
Expand Down
51 changes: 51 additions & 0 deletions docs/tray-debug.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,61 @@ This guide explains how to control the mcpproxy tray during development and auto

| Variable | Scope | Default | Purpose |
|----------|-------|---------|---------|
| `MCPPROXY_HOME` | macOS tray | `~/.mcpproxy` | Relocates the whole instance root: socket, config, database, autostart sidecar, lifecycle journal. |
| `MCPPROXY_SOCKET_PATH` | macOS tray | `<root>/mcpproxy.sock` | Points the tray at one specific core's socket, wherever the root is. |
| `MCPPROXY_TRAY_SKIP_CORE` | Tray | unset | Prevents the tray from launching the core binary. |
| `MCPPROXY_CORE_URL` | Tray | `http://localhost:8080` | Overrides the core API endpoint the tray connects to. |
| `MCPPROXY_DISABLE_OAUTH` | Core | unset | Disables OAuth popups and tray-driven login prompts. |

## Running a Second (Dev/QA) macOS Tray Instance

The native macOS tray resolves its paths through `homeDirectoryForCurrentUser`, which **ignores `$HOME`**. Without an override, a tray built from a branch and run out of a scratch bundle still reads and writes the real `~/.mcpproxy` — which means QA runs cannot go in parallel (they contend for one socket) and the autostart sidecar overwrites the user's real login-item state on every launch.

`MCPPROXY_HOME` moves the whole instance in one step. It is unset in normal use, and with it unset every path resolves exactly where it always did.

```bash
# Keep the root SHORT: sockaddr_un caps the socket path at 103 bytes, and a
# deep scratch directory silently fails to bind. The tray logs a warning if
# the resolved path is over the limit.
export MCPPROXY_HOME=/tmp/mcpproxy-qa
/path/to/dev/mcpproxy.app/Contents/MacOS/MCPProxy
```

With it set:

| File | Default | With `MCPPROXY_HOME=/tmp/mcpproxy-qa` |
|------|---------|----------------------------------------|
| Core socket | `~/.mcpproxy/mcpproxy.sock` | `/tmp/mcpproxy-qa/mcpproxy.sock` |
| Config opened by **Open Config File** | `~/.mcpproxy/mcp_config.json` | `/tmp/mcpproxy-qa/mcp_config.json` |
| Autostart sidecar | `~/.mcpproxy/tray-autostart.json` | `/tmp/mcpproxy-qa/tray-autostart.json` |
| Lifecycle journal | `~/.mcpproxy/tray-lifecycle.jsonl` | `/tmp/mcpproxy-qa/tray-lifecycle.jsonl` |
| Spawned core | `mcpproxy serve` | `mcpproxy serve --data-dir /tmp/mcpproxy-qa --config /tmp/mcpproxy-qa/mcp_config.json` |

Notes and limits:

- A brand-new root needs nothing prepared. The core creates the config file named by `--config` when it does not exist yet (printing `INFO: Created default configuration file at …`), so `export MCPPROXY_HOME=/tmp/mcpproxy-qa` and launching the tray is the whole setup. An existing file is never touched.
- The core also reads its autostart sidecar out of the data directory it was given, so a QA instance reports its own login-item state rather than the real install's.
- `MCPPROXY_SOCKET_PATH` still wins over the root. Use it to attach to a core somebody else started; use `MCPPROXY_HOME` to own a whole instance.
- **Not** relocated: `~/Library/Logs/mcpproxy` (the core's log directory, which follows the core's own rules) and the app's preferences domain — `cfprefsd` honours neither `$HOME` nor this variable, so give a dev bundle a distinct bundle id if you need separate defaults.
- The first-run dialog is a `UserDefaults` flag, so it lives in the preferences domain rather than the instance root; a fresh bundle id will show it again. Its "Launch at login" checkbox is pre-checked, so clicking through it in a dev instance registers a real login item.

## Attributing a Tray or Core Exit

Every start and stop is recorded in `<instance root>/tray-lifecycle.jsonl`, one JSON object per line, and mirrored to the macOS unified log at Notice level (`log show --predicate 'subsystem == "com.smartmcpproxy.mcpproxy"'`) — Notice because the Info and Debug tiers are purged within hours, which is precisely why an earlier silent exit could not be attributed.

```bash
tail -5 ~/.mcpproxy/tray-lifecycle.jsonl | python3 -m json.tool --json-lines
```

Recorded events: `appLaunched`, `appTerminating`, `signalReceived`, `coreLaunched`, `coreTerminated`, `coreExited`, `updateCheck`. Each carries a free-text reason, the uptime of the tray process, and a pid.

The rules worth knowing when reading one:

- Every shutdown records **who asked**. A shutdown nobody claimed is written as `unattributed (no initiator claimed this shutdown)` rather than as something plausible.
- `SIGTERM`, `SIGINT` and `SIGHUP` are caught, recorded, and then routed through the normal quit path — handled off the main thread, so a wedged main thread cannot swallow them. If the app has not gone five seconds later, the signal's default action is restored and re-raised: catching a termination signal delays the exit, it never prevents it. `SIGKILL` and a jetsam kill cannot be caught by anyone.
- Which is why the **next** launch reports an unaccounted-for previous run: if the previous run recorded no `appTerminating` at all, the new `appLaunched` record says so and names the last thing the dead run did. That marker is the signature of a SIGKILL-class death (jetsam, `pkill`, power loss) or a crash outside the app's handlers. It is not the *last* record that decides this — an ordinary clean exit writes `appTerminating` first and then `coreTerminated` for the core it tears down.
- The journal is trimmed to its newest 500 records at launch.

## Use Cases

### Debugging the Core and Tray Separately
Expand Down
108 changes: 108 additions & 0 deletions internal/config/ensure_config_file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package config

import (
"os"
"path/filepath"
"testing"
)

// A relocated tray instance (GH #936) spawns its core with BOTH flags:
//
// mcpproxy serve --data-dir <root> --config <root>/mcp_config.json
//
// against a root that has never been used before, so the named config does not
// exist yet. LoadFromFile is deliberately strict about a missing explicit path
// — it is also the hot-reload entry point, where "the file vanished" must never
// mean "reset to defaults" — so the serve entry point seeds the file first.
// Without that seeding the documented flow was dead on arrival: the core exited
// immediately with "failed to load config file …: no such file or directory".
func TestAFreshInstanceRootIsSeededAndThenLoads(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, ConfigFileName)

created, err := EnsureConfigFile(path, root)
if err != nil {
t.Fatalf("EnsureConfigFile: %v", err)
}
if !created {
t.Fatal("a config that did not exist must be reported as created")
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("config file was not created at %s: %v", path, err)
}

cfg, err := LoadFromFile(path)
if err != nil {
t.Fatalf("the seeded config must load: %v", err)
}
if cfg.DataDir != root {
t.Fatalf("seeded config should own the instance root, got DataDir=%q want %q",
cfg.DataDir, root)
}
}

// The seeding must be a no-op for an existing install: this runs on every
// `serve` with an explicit --config, and overwriting there would erase every
// server the user has.
func TestEnsureConfigFileNeverTouchesAnExistingFile(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, ConfigFileName)
original := []byte(`{"listen":"127.0.0.1:9999","mcpServers":[]}`)
if err := os.WriteFile(path, original, 0o600); err != nil {
t.Fatalf("write: %v", err)
}

created, err := EnsureConfigFile(path, root)
if err != nil {
t.Fatalf("EnsureConfigFile: %v", err)
}
if created {
t.Fatal("an existing config must not be reported as created")
}

after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(after) != string(original) {
t.Fatalf("existing config was rewritten:\n got %s\nwant %s", after, original)
}
}

// Nothing to do without an explicit path — the implicit path already creates
// its own default inside the data directory (see Load).
func TestEnsureConfigFileIgnoresAnEmptyPath(t *testing.T) {
created, err := EnsureConfigFile("", t.TempDir())
if err != nil {
t.Fatalf("EnsureConfigFile: %v", err)
}
if created {
t.Fatal("an empty path creates nothing")
}
}

// The counterweight to the seeding: LoadFromFile itself stays strict. It is
// also the hot-reload path (internal/runtime/lifecycle.go,
// internal/runtime/configsvc), where a config file that has gone missing must
// fail the reload and keep the running config — never silently become a fresh
// default that drops every server the user had.
func TestLoadFromFileStillRefusesAMissingExplicitPath(t *testing.T) {
missing := filepath.Join(t.TempDir(), "nope", ConfigFileName)
if _, err := LoadFromFile(missing); err == nil {
t.Fatal("LoadFromFile must not invent a config for a path that is not there")
}
}

// The parent may not exist yet either: MCPPROXY_HOME can name a directory that
// has never been created.
func TestEnsureConfigFileCreatesTheInstanceRootItself(t *testing.T) {
root := filepath.Join(t.TempDir(), "mcpproxy-qa")
path := filepath.Join(root, ConfigFileName)

if _, err := EnsureConfigFile(path, root); err != nil {
t.Fatalf("EnsureConfigFile: %v", err)
}
if _, err := LoadFromFile(path); err != nil {
t.Fatalf("the seeded config must load: %v", err)
}
}
48 changes: 48 additions & 0 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,54 @@ func CreateSampleConfig(path string) error {
return SaveConfig(cfg, path)
}

// EnsureConfigFile seeds a default configuration at an explicitly named path
// that does not exist yet, and reports whether it created one.
//
// It exists for the first run of an instance whose config path is named on the
// command line rather than discovered — the relocated tray instance of GH #936
// spawns its core with BOTH `--data-dir <root>` and `--config
// <root>/mcp_config.json`, and on a fresh root that file has never been
// written. LoadFromFile refuses a missing explicit path, so without this the
// core exited on the spot with "no such file or directory" and the instance
// could never come up.
//
// Why here and not inside LoadFromFile: LoadFromFile is also the HOT-RELOAD
// entry point (internal/runtime/lifecycle.go, internal/runtime/configsvc). A
// config file that disappears under a running proxy must fail the reload and
// leave the live config alone; creating a default there would replace every
// server the user has with nothing. Seeding is a startup decision, so it is
// made at startup, once.
//
// dataDir is the directory the caller will run out of (the `--data-dir` flag
// when one was given); the seeded file records it, exactly as the implicit path
// records its own. An empty dataDir leaves the field unset, so the loader's
// usual default applies.
func EnsureConfigFile(path, dataDir string) (created bool, err error) {
if path == "" {
return false, nil
}
if _, statErr := os.Stat(path); statErr == nil {
return false, nil
} else if !os.IsNotExist(statErr) {
return false, fmt.Errorf("failed to inspect config file %s: %w", path, statErr)
}

if dir := filepath.Dir(path); dir != "" {
if err := os.MkdirAll(dir, 0700); err != nil {
return false, fmt.Errorf("failed to create config directory %s: %w", dir, err)
}
}

seed := &Config{DataDir: dataDir}
if err := createDefaultConfigFile(path, seed); err != nil {
return false, fmt.Errorf("failed to create default config file %s: %w", path, err)
}
// Same notice the implicit path prints, for the same reason: a typo in
// --config would otherwise create a blank instance in silence.
fmt.Fprintf(os.Stderr, "INFO: Created default configuration file at %s\n", path)
return true, nil
}

// Helper function to get current time (useful for testing)
var now = time.Now

Expand Down
13 changes: 10 additions & 3 deletions internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -988,8 +988,15 @@ func (s *Server) writeSuccess(w http.ResponseWriter, data interface{}) {
func (s *Server) handleGetStatus(w http.ResponseWriter, _ *http.Request) {
// Get routing mode from config
routingMode := config.RoutingModeRetrieveTools
if cfg, err := s.controller.GetConfig(); err == nil && cfg != nil && cfg.RoutingMode != "" {
routingMode = cfg.RoutingMode
// …and the data directory, which is where the tray's autostart sidecar
// lives. It is not always ~/.mcpproxy — MCPPROXY_HOME relocates the whole
// instance root, tray and core together (GH #936).
autostartDataDir := ""
if cfg, err := s.controller.GetConfig(); err == nil && cfg != nil {
if cfg.RoutingMode != "" {
routingMode = cfg.RoutingMode
}
autostartDataDir = cfg.DataDir
}

response := map[string]interface{}{
Expand Down Expand Up @@ -1035,7 +1042,7 @@ func (s *Server) handleGetStatus(w http.ResponseWriter, _ *http.Request) {
// — this endpoint is read-only). autostart_enabled reads the tray-owned
// sidecar with its 1h TTL; nil on Linux / tray not running / malformed.
response["launch_source"] = string(telemetry.DetectLaunchSourceOnce())
response["autostart_enabled"] = telemetry.DefaultAutostartReader().Read()
response["autostart_enabled"] = telemetry.AutostartReaderForDataDir(autostartDataDir).Read()

s.writeSuccess(w, response)
}
Expand Down
15 changes: 15 additions & 0 deletions internal/telemetry/autostart.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ type AutostartReader struct {
// (by design), so the returned reader will simply always yield nil — the
// heartbeat's AutostartEnabled field stays JSON-null, matching data-model.md.
func DefaultAutostartReader() *AutostartReader {
return AutostartReaderForDataDir("")
}

// AutostartReaderForDataDir returns the reader for the instance rooted at
// dataDir, falling back to ~/.mcpproxy when dataDir is empty.
//
// The tray writes its sidecar into the SAME root it hands the core as
// --data-dir, and MCPPROXY_HOME relocates that root (GH #936). A reader that
// always looked in ~/.mcpproxy therefore reported the real install's login-item
// state for a QA instance that has nothing to do with it, while the sidecar the
// QA tray actually wrote was never read by anything.
func AutostartReaderForDataDir(dataDir string) *AutostartReader {
if dataDir != "" {
return &AutostartReader{Path: filepath.Join(dataDir, autostartSidecarName)}
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
// Fall back to a non-existent path — Read will return nil.
Expand Down
62 changes: 62 additions & 0 deletions internal/telemetry/autostart_datadir_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package telemetry

import (
"os"
"path/filepath"
"runtime"
"testing"
)

// The tray writes its autostart sidecar under the instance root, which
// MCPPROXY_HOME relocates (GH #936). A core told `--data-dir <root>` therefore
// has to read <root>/tray-autostart.json — reading ~/.mcpproxy instead makes a
// QA instance report the real install's login-item state as its own, and leaves
// the sidecar the tray actually wrote as dead data.
func TestAutostartReaderForDataDirReadsTheInstanceRootSidecar(t *testing.T) {
if runtime.GOOS == "linux" {
t.Skip("no tray sidecar on Linux by design; Read() always yields nil")
}
root := t.TempDir()
writeSidecar(t, filepath.Join(root, autostartSidecarName), `{"enabled":true}`)

got := AutostartReaderForDataDir(root).Read()
if got == nil {
t.Fatal("reader ignored the data directory and found no sidecar")
}
if !*got {
t.Fatalf("sidecar says enabled:true, reader says %v", *got)
}
}

// …and the relocated reader must not fall back to the production sidecar when
// the instance root has none: "this instance has no sidecar" is `unknown`, not
// "whatever the real install says".
func TestAutostartReaderForDataDirDoesNotFallBackToTheRealHome(t *testing.T) {
root := t.TempDir()
r := AutostartReaderForDataDir(root)
if want := filepath.Join(root, autostartSidecarName); r.Path != want {
t.Fatalf("reader path = %q, want %q", r.Path, want)
}
if got := r.Read(); got != nil {
t.Fatalf("no sidecar in the instance root must read as unknown, got %v", *got)
}
}

// An empty data directory keeps the historical behaviour: ~/.mcpproxy.
func TestAutostartReaderForDataDirFallsBackToHomeWhenUnset(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home directory")
}
r := AutostartReaderForDataDir("")
if want := filepath.Join(home, ".mcpproxy", autostartSidecarName); r.Path != want {
t.Fatalf("reader path = %q, want %q", r.Path, want)
}
}

func writeSidecar(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write sidecar: %v", err)
}
}
11 changes: 9 additions & 2 deletions internal/telemetry/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -864,8 +864,15 @@ func (s *Service) buildHeartbeat() HeartbeatPayload {
payload.AutostartEnabled = s.autostartReader.Read()
} else {
// Lazy-init the default reader on first heartbeat. The reader is
// safe to reuse across heartbeats (1h TTL cache inside).
s.autostartReader = DefaultAutostartReader()
// safe to reuse across heartbeats (1h TTL cache inside). It reads the
// sidecar inside THIS instance's data directory: the tray writes it
// into the same root it hands the core, and MCPPROXY_HOME moves both
// (GH #936).
dataDir := ""
if s.config != nil {
dataDir = s.config.DataDir
}
s.autostartReader = AutostartReaderForDataDir(dataDir)
payload.AutostartEnabled = s.autostartReader.Read()
}

Expand Down
Loading
Loading