Development
Hook
Hooks
What trace-mcp runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
> /plugin marketplace add nikolai-vysotskyi/trace-mcp > /plugin install trace-mcp@trace-mcp
Ships with trace-mcp. Installing the plugin gets these hooks.
Where it lives
- hooks/trace-mcp-guard-md-tour.ps1GitHub
Read the script
# trace-mcp-guard-md-tour.ps1 v0.9.0 # Windows helper for trace-mcp-guard.cmd - implements the .md doc-tour # detection mirrored from the bash hook (v0.9). # # Called by trace-mcp-guard.cmd on PreToolUse Read events for .md files. # Behavior: # - If the file is NOT in a source-tree directory, exit 0 silently # (caller treats empty stdout as "allow"). # - If the file IS in a source-tree directory, increment a per-session # counter. When the counter reaches TRACE_MCP_GUARD_MD_HINT_THRESHOLD # (default 3), emit a full PreToolUse JSON with additionalContext # suggesting get_feature_context / get_task_context. Read still passes - # this is a hint, not a block. # # Inputs (environment variables): # TMG_FILE - absolute file path being read # TMG_SESSION - session id (per-session state lives under # $env:TEMP\trace-mcp-reads-<session>\) # TMG_ROOT - project root (pwd of the Claude Code session) $ErrorActionPreference = 'SilentlyContinue' $filePath = $env:TMG_FILE $sessionId = $env:TMG_SESSION if (-not $filePath -or -not $sessionId) { exit 0 } # Normalize backslashes so the same regexes work as in the bash hook. $norm = $filePath -replace '\\', '/' $isMd = $norm -match '(?i)\.md$' if (-not $isMd) { exit 0 } $inSource = $norm -match '(?i)/(src|lib|packages|apps?|server|client|pkg|internal|modules|services|pipelines|cmd|tests?|specs?|features?)/' $excluded = $norm -match '(?i)/(docs?|node_modules|vendor|dist|build|\.git|target|out)/' if (-not $inSource -or $excluded) { exit 0 } $tmp = $env:TEMP if (-not $tmp) { $tmp = [System.IO.Path]::GetTempPath().TrimEnd('\','/') } $readsDir = Join-Path $tmp ("trace-mcp-reads-" + $sessionId) if (-not (Test-Path $readsDir)) { New-Item -ItemType Directory -Path $readsDir -Force | Out-Null } $counterFile = Join-Path $readsDir '.md-tour-count' $count = 0 if (Test-Path $counterFile) { $raw = (Get-Content -LiteralPath $counterFile -Raw).Trim() [void][int]::TryParse($raw, [ref]$count) } $count = $count + 1 Set-Content -LiteralPath $counterFile -Value "$count" -NoNewline $threshold = 3 if ($env:TRACE_MCP_GUARD_MD_HINT_THRESHOLD) { [void][int]::TryParse($env:TRACE_MCP_GUARD_MD_HINT_THRESHOLD, [ref]$threshold) } if ($count -lt $threshold) { exit 0 } # Compute repo-relative path for the hint message. $rel = $filePath $root = $env:TMG_ROOT if ($root) { $rootWithSep = $root.TrimEnd('\','/') + [System.IO.Path]::DirectorySeparatorChar if ($filePath.StartsWith($rootWithSep, [System.StringComparison]::Ordinal)) { $rel = $filePath.Substring($rootWithSep.Length) } } $rel = $rel -replace '\\', '/' $hint = "trace-mcp guard: ${count}x .md reads inside source dirs this session - looks like a doc tour. For per-feature docs co-located with code, get_feature_context / get_task_context is usually faster than reading docs file-by-file. Reading $rel is allowed; this is a hint, not a block.`nAlternatives:`n- get_feature_context { ""description"": ""what these docs describe"" }`n- get_task_context { ""task"": ""what you are working on"" }`n- search { ""query"": ""keyword"", ""file_pattern"": ""**/*.md"" } - find specific doc by name" $payload = [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse' additionalContext = $hint } } $payload | ConvertTo-Json -Depth 4 -Compress exit 0 - hooks/trace-mcp-guard-read.ps1GitHub
Read the script
# trace-mcp-guard-read.ps1 v0.11.0 # Windows helper for trace-mcp-guard.cmd - implements the Read-handler repeat-read # dedup logic (per-session allowed read counter with mtime reset). # # Called by trace-mcp-guard.cmd on PreToolUse Read events. Writes one decision # string to stdout: # # ALLOW - allow the read (caller should exit 0 silently) # LIMIT:<n> - deny: already read <n> times this session (caller emits # the "Already read" deny JSON) # DENY_FIRST - deny: first-time friction cycle (caller emits the generic # "Use trace-mcp" deny JSON; retry will ALLOW) # # Inputs are taken from environment variables (simpler than cmd arg quoting): # TMG_FILE - absolute file path being read # TMG_SESSION - session id # TMG_ROOT - project root (pwd of the Claude Code session) # TMG_OFFSET - Read offset parameter (if set, targeted pre-Edit read -> ALLOW) # TMG_LIMIT - Read limit parameter (if set, targeted pre-Edit read -> ALLOW) # # This script is side-effecting: it writes state files under # $env:TEMP\trace-mcp-reads-<session>\<file-hash> ("count:mtime") # and creates/removes deny markers under # $env:TEMP\trace-mcp-guard-<session>\<file-hash> $ErrorActionPreference = 'SilentlyContinue' $filePath = $env:TMG_FILE $sessionId = $env:TMG_SESSION $projectRoot = $env:TMG_ROOT $tmgOffset = $env:TMG_OFFSET $tmgLimit = $env:TMG_LIMIT $tmp = $env:TEMP if (-not $tmp) { $tmp = [System.IO.Path]::GetTempPath().TrimEnd('\','/') } if (-not $filePath -or -not $sessionId) { Write-Output 'ALLOW' exit 0 } # Targeted pre-Edit reads (offset or limit present) - always allow. # Read-before-Edit must keep working even under strict enforcement. if ($tmgOffset -and $tmgOffset -ne '') { Write-Output 'ALLOW' exit 0 } if ($tmgLimit -and $tmgLimit -ne '') { Write-Output 'ALLOW' exit 0 } $REPEAT_READ_LIMIT = 2 function Sha256Hex([string]$text) { $sha = [System.Security.Cryptography.SHA256]::Create() try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($text) return [System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-','').ToLower() } finally { $sha.Dispose() } } $fileHash = Sha256Hex $filePath # Read state dir & path $readsDir = Join-Path $tmp ("trace-mcp-reads-" + $sessionId) if (-not (Test-Path $readsDir)) { New-Item -ItemType Directory -Path $readsDir -Force | Out-Null } $readState = Join-Path $readsDir $fileHash # Current mtime as ticks (cross-version stable) $curMtime = '0' if (Test-Path $filePath) { try { $curMtime = (Get-Item -LiteralPath $filePath).LastWriteTimeUtc.Ticks.ToString() } catch { $curMtime = '0' } } # Load previous state $prevCount = 0 $prevMtime = '' $hadState = $false if (Test-Path $readState) { $hadState = $true $raw = (Get-Content -LiteralPath $readState -Raw).Trim() $parts = $raw -split ':', 2 if ($parts.Length -ge 1) { [void][int]::TryParse($parts[0], [ref]$prevCount) } if ($parts.Length -ge 2) { $prevMtime = $parts[1] } } # Reset count on mtime change (Edit/Write happened). if ($curMtime -ne $prevMtime) { $prevCount = 0 } # Limit exceeded -> deny. if ($prevCount -ge $REPEAT_READ_LIMIT) { Write-Output ("LIMIT:" + $prevCount) exit 0 } # Consultation marker: if trace-mcp already touched this file, allow unconditionally # (still increments the counter so the limit eventually triggers). if ($projectRoot) { $projectHash = (Sha256Hex $projectRoot).Substring(0, 12) $relPath = $filePath $rootWithSep = $projectRoot.TrimEnd('\','/') + [System.IO.Path]::DirectorySeparatorChar if ($filePath.StartsWith($rootWithSep, [System.StringComparison]::Ordinal)) { $relPath = $filePath.Substring($rootWithSep.Length) } $relPath = $relPath -replace '\\', '/' $consultedHash = Sha256Hex $relPath # Markers live under the state home; %TEMP% is the pre-TRA-869 location and # is still checked because the server writes both while old hooks exist. $traceStateHome = $env:TRACE_MCP_DATA_DIR if (-not $traceStateHome) { $traceStateHome = Join-Path $env:USERPROFILE '.trace' } $stateConsultedFile = Join-Path (Join-Path (Join-Path $traceStateHome 'status') ("trace-mcp-consulted-" + $projectHash)) $consultedHash $consultedDir = Join-Path $tmp ("trace-mcp-consulted-" + $projectHash) $consultedFile = Join-Path $consultedDir $consultedHash if ((Test-Path $stateConsultedFile) -or (Test-Path $consultedFile)) { $newCount = $prevCount + 1 Set-Content -LiteralPath $readState -Value ("{0}:{1}" -f $newCount, $curMtime) -NoNewline Write-Output 'ALLOW' exit 0 } } # Already tracked this session (even after mtime reset) -> skip first-time friction. if ($hadState) { $newCount = $prevCount + 1 Set-Content -LiteralPath $readState -Value ("{0}:{1}" -f $newCount, $curMtime) -NoNewline Write-Output 'ALLOW' exit 0 } # First-time deny-marker cycle: first attempt denies, second attempt (retry) allows. $denyDir = Join-Path $tmp ("trace-mcp-guard-" + $sessionId) if (-not (Test-Path $denyDir)) { New-Item -ItemType Directory -Path $denyDir -Force | Out-Null } $denyMarker = Join-Path $denyDir $fileHash if (Test-Path $denyMarker) { Remove-Item -LiteralPath $denyMarker -Force Set-Content -LiteralPath $readState -Value ("1:" + $curMtime) -NoNewline Write-Output 'ALLOW' exit 0 } New-Item -ItemType File -Path $denyMarker -Force | Out-Null Write-Output 'DENY_FIRST' exit 0 - hooks/trace-mcp-guard.shGitHub
Read the script
#!/usr/bin/env bash # trace-mcp-guard v0.18 # REQUIRES: trace-mcp >= 1.32.7 (status JSON sentinel introduced in this version) # # v0.18 changes (TRA-1088 — find the sentinel from a subdirectory): # - Resolves the project root by walking up from the cwd to the nearest # ancestor a server has claimed, instead of demanding that the cwd itself # be the root the MCP client launched with. Working one directory down — # a checked-out repo, a monorepo package — made the guard report a live # session as dead and wave every Read/Grep through. # - `/` is never accepted as that ancestor: a registry row for `/` is a known # pathology (TRA-37) and sits on the walk of every path. # # v0.17 changes (TRA-763 — StateEngine discovery hint): # - Counts guarded tool calls per session and, on the 30th (default, # TRACE_MCP_STATE_HINT_TURNS), emits one advisory additionalContext # pointing at load_tools + trace_state_init. TRA-724 measured the # break-even for that loop at turns 25-28; the seven schemas are kept off # the default `minimal` preset for the short sessions that lose, which # left the feature unreachable in the long ones that win. # - Silent when the channel is dead, when TRACE_MCP_PRESET already # advertises the tools, when state.db has been written since the session # started, and after the one shot. Never blocks. # # v0.16 changes (TRA-869 — sentinels moved out of $TMPDIR): # - The heartbeat, status, consultation-marker and bypass paths are read from # <state home>/status/ first, $TMPDIR second. $TMPDIR is per-process: the # server is spawned by the MCP client and this hook by the agent harness, # and on macOS they routinely hold different values. Measured live: the # server refreshed /var/folders/.../T/trace-mcp-alive-<hash> every 5s while # the hook looked in /tmp/multica-task-<id>/, so every call reported # "trace-mcp server not running" and degraded to the Read/Grep fallback, # against a healthy connected session. # - The $TMPDIR fallback keeps a server older than that fix discoverable. # # v0.15.1 changes (TRA-845 — Bash branch had no liveness fallback): # - Read, Grep and Glob all degrade to allow-with-warning when trace-mcp is # unreachable (no heartbeat, stale heartbeat, stalled channel, transport # mismatch, manual or auto bypass). The Bash branch never checked any of # it, so with the daemon stopped `grep -rn foo src/`, `cat src/x.ts`, # `ls src/`, `git diff src/x.ts` and `cmd < src/x.ts` were hard-denied # while the tools they redirect to could not answer either. # - Each of those six deny sites now calls bash_fallback_if_unavailable # first. Ordinary Bash calls (builds, tests, git status) stay silent, and # the .env rule stays unconditional — it is a secrets rule, not a # navigation-cost tradeoff. # # v0.15 changes (guard v2 — TRA-711, navigation streak gate): # - The guard no longer intervenes on an isolated navigation call. TRA-705 # measured the trace path at 1.45x the cost of a bare grep agent on a light # navigation question with identical correctness (27/30 vs 27/30) — routing # that question through us is a measured regression, not a saving. The win # is on multi-step work (1.39x our way), so the guard now waits for the # session to actually be crawling. # - Navigation-class denies (Read/Grep/Glob/Bash code exploration/git # show|diff|log -p) now fire from the TRACE_MCP_GUARD_NAV_MIN'th (default 3) # navigation attempt within TRACE_MCP_GUARD_NAV_WINDOW seconds (default # 300). Below that the hook exits silently. # - Relationship questions ("who calls X", "what breaks if I change Y", # "which tests cover Z") bypass the gate and are routed from the first # call — that is the shape where the advantage is measured. The # UserPromptSubmit hook (v0.3.0) sets the flag and resets the streak on # each new user prompt. # - Security rules (.env) and Agent(Explore) are unaffected: they are not # navigation-cost tradeoffs. # # v0.14 changes (fixes TRA-152 — recursive/pathless grep-cat bypass): # - The Bash grep/rg/find/cat/head/tail/etc. code-exploration rule required # a code-file extension at the very END of the command string ($-anchored # CODE_EXT_RE). That never matched recursive/pathless forms with no # filename at all (`grep -rn foo src/`, `grep -rln -i avif app/`), nor # commands where the filename isn't the last token (`cat src/App.tsx | # wc -l`) — the dominant real-world shape of these commands. # - Fix: the rule now also fires on a known source-tree directory argument # (same SOURCE_DIR_RE heuristic the ls/find rule already used), and the # extension check itself no longer requires the match to be at the end # of the command. # # v0.13 changes (transport-aware liveness — fixes GH #297): # - Status JSON now carries `transport` ("stdio" | "http" — which command # produced the sentinel). If PROJECT_ROOT/.mcp.json declares a different # transport for trace-mcp, the heartbeat is proof of the WRONG process # being alive (e.g. a leftover `serve-http` while the client is # configured for stdio `serve`) — treated as dead instead of trusted. # - `mcp_sessions_active == 0` now marks the channel dead unconditionally # (previously only checked when tool_calls_total > 0, which meant "no # client ever connected" was invisible to the stall detector). # - Requires trace-mcp server writing the `transport` field (schema 2); # older status JSON without it simply skips the transport check. # # v0.11 changes (enforcement tier — TRACE_MCP_ENFORCE): # - New env var TRACE_MCP_ENFORCE with three values: # advisory (DEFAULT): warn on stderr, allow the tool call (exit 0). # strict: hard-deny via permissionDecision:deny JSON. # The denial message names the trace-mcp route to use. # off: - hooks/trace-mcp-hidden-run.ps1GitHub
Read the script
# trace-mcp-hidden-run v0.1.0 (Windows) # Hidden launcher shim for trace-mcp Claude Code hooks. # # Problem: registering a hook as `cmd /c "...cmd"` makes Windows allocate a # visible console window for every invocation. During agentic editing the # PostToolUse reindex hook fires on every Edit/Write/MultiEdit, so dozens of # console windows flash per minute (issue #230). # # Fix: register hooks as # powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass \ # -WindowStyle Hidden -File "<dir>\trace-mcp-hidden-run.ps1" "<dir>\<hook>.cmd" # PowerShell launched with -WindowStyle Hidden owns a hidden console; the child # cmd inherits that hidden console instead of allocating a fresh visible one, so # nothing flashes. This shim runs the existing, tested .cmd hook logic verbatim # via `cmd /c`, forwarding stdin (the PostToolUse JSON payload), relaying # stdout/stderr, and propagating the exit code. # # Managed by trace-mcp - do not edit by hand. Re-run `trace-mcp init` to refresh. #Requires -Version 5.1 $ErrorActionPreference = 'SilentlyContinue' # The target .cmd hook to run is passed as the first positional argument. $target = $args[0] if (-not $target) { # No target given: nothing to run. Exit cleanly so a stray registration # never blocks a tool call. exit 0 } # Read the hook payload (PostToolUse/PreToolUse JSON etc.) from stdin so it can # be piped through to the child .cmd, which parses tool_name / file_path from it. $stdin = [Console]::In.ReadToEnd() # Run the .cmd via cmd.exe /c inside this hidden console. Piping $stdin forwards # the JSON payload; stdout/stderr are inherited so any hook output (e.g. the # SessionStart wake-up context) reaches Claude Code unchanged. $stdin | & cmd.exe /c "`"$target`"" exit $LASTEXITCODE - hooks/trace-mcp-launcher.ps1GitHub
Read the script
# trace-mcp-launcher v0.6.16 (Windows) # Stable shim backend: resolves node + cli.js at runtime from launcher.env, # with a probe fallback for nvm-windows/nvs/Volta/system installs. # Managed by trace-mcp - do not edit by hand. Re-run `trace-mcp init` to refresh. #Requires -Version 5.1 $ErrorActionPreference = 'Stop' # Determine $TraceHome: # 1. Explicit TRACE_MCP_HOME or TRACE_MCP_DATA_DIR override. # 2. Sibling directory of this shim: installed at <TraceHome>\bin\trace.cmd # and trace-mcp-launcher.ps1. If launcher.env or .config.json exists in # the parent directory, use it. This survives modified USERPROFILE or isolated homes. # 3. $USERPROFILE\.trace # 4. $USERPROFILE\.trace-mcp (legacy) $TraceHome = '' if ($env:TRACE_MCP_HOME) { $TraceHome = $env:TRACE_MCP_HOME } elseif ($env:TRACE_MCP_DATA_DIR) { $TraceHome = $env:TRACE_MCP_DATA_DIR } else { if ($PSScriptRoot) { $candidate = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) if ((Test-Path -LiteralPath (Join-Path $candidate 'launcher.env') -PathType Leaf) -or (Test-Path -LiteralPath (Join-Path $candidate '.config.json') -PathType Leaf)) { $TraceHome = $candidate } } if (-not $TraceHome -and $env:USERPROFILE) { $defaultTrace = Join-Path $env:USERPROFILE '.trace' if ((Test-Path -LiteralPath $defaultTrace -PathType Container) -or -not (Test-Path -LiteralPath (Join-Path $env:USERPROFILE '.trace-mcp') -PathType Container)) { $TraceHome = $defaultTrace } else { $TraceHome = Join-Path $env:USERPROFILE '.trace-mcp' } } } if (-not $TraceHome) { $TraceHome = Join-Path $env:USERPROFILE '.trace' } $env:TRACE_MCP_HOME = $TraceHome $env:TRACE_MCP_DATA_DIR = $TraceHome $ConfigPath = Join-Path $TraceHome 'launcher.env' $LogPath = Join-Path $TraceHome 'launcher.log' # Rotate once per invocation, before the first append (TRA-702). Mirrors # rotate_log in trace-mcp-launcher.sh. Bounds the log at 2 x the limit across # both generations; without it the file only ever grew. $LogMaxBytes = 5242880 try { # Inside the try on purpose: $ErrorActionPreference is 'Stop', so a # non-numeric override would throw on the cast and abort the whole shim # before it ever execs node - a logging knob must never cost a start. if ($env:TRACE_MCP_LOG_MAX_BYTES) { $LogMaxBytes = [int64]$env:TRACE_MCP_LOG_MAX_BYTES } $existing = Get-Item -LiteralPath $LogPath -ErrorAction SilentlyContinue if ($existing -and $existing.Length -gt $LogMaxBytes) { Move-Item -LiteralPath $LogPath -Destination "$LogPath.1" -Force -ErrorAction SilentlyContinue } } catch { # Never abort on rotation failure. } function Write-LauncherLog { param([string]$Message) try { $stamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') Add-Content -Path $LogPath -Value "[$stamp] $Message" -ErrorAction SilentlyContinue } catch { # Never abort on log failure. } } function Die { param([string]$Message) Write-LauncherLog "ERROR: $Message" [Console]::Error.WriteLine("trace-mcp launcher: $Message") [Console]::Error.WriteLine('Recovery: npm i -g trace-mcp && trace-mcp init') [Console]::Error.WriteLine(' (or set TRACE_MCP_NODE_OVERRIDE / TRACE_MCP_CLI_OVERRIDE)') exit 127 } # cli.js is built for the `engines.node` range in package.json. An older node # does not fail loudly - it dies on a SyntaxError the MCP client can only report # as "failed to connect", which is why the major is checked before we exec. # Parsed, not cast: a bare [int] cast of an out-of-range value throws under # $ErrorActionPreference = 'Stop' and would abort the launcher outright. $NodeMinMajor = 22 if ($env:TRACE_MCP_NODE_MIN_MAJOR) { $parsedMin = 0 if ([int]::TryParse($env:TRACE_MCP_NODE_MIN_MAJOR, [ref]$parsedMin) -and $parsedMin -gt 0) { $NodeMinMajor = $parsedMin } } # --- 1. Parse config safely (no Invoke-Expression, whitelist keys) --- $NodePath = '' $CliPath = '' $UsingOverride = $false $UsingNodeOverride = $false # Every file this shim reads is a hint, never a requirement: launcher.env, # pkg-roots, .npmrc. Under $ErrorActionPreference = 'Stop' a read that throws - # a file locked by another writer, an I/O error on a mapped drive - escapes the # whole launcher, so the client gets neither the recovery message nor the probe # fallback and loses trace-mcp for the session. An unreadable hint must degrade # to "no hint" (TRA-797). function Read-LauncherLines { param([string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return @() } try { return @([System.IO.File]::ReadAllLines($Path)) } catch { return @() } } $configLines = Read-LauncherLines $ConfigPath if ($configLines.Count -gt 0) { foreach ($line in $configLines) { $trimmed = $line.TrimStart() if (-not $trimmed -or $trimmed.StartsWith('#')) { continue } $idx = $trimmed.IndexOf('=') if ($idx -le 0) { continue } $key = $trimmed.Substring(0, $idx).Trim() $val = $trimmed.Substring($idx + 1).Trim() # Strip exactly one pair of surrounding double-quotes if present. if ($val.Length -ge 2 -and $val.StartsWith('"') -and $val.EndsWith('"')) { $val = $val.Substring(1, $val.Length - 2) } switch ($key) { 'TRACE_MCP_NODE' { $NodePath = $val } 'TRACE_MCP_CLI' { $CliPath = $val } # TRACE_MCP_NODE_MAJOR and TRACE_MCP_VERSION ignored # (informational only; older configs may still carry them) } } } # --- 2. Env overrides --- # $UsingOverride gates persistence (never bake an override into the config); # $UsingNodeOverride gates the version check, and only the node override may # waive that. if ($env:TRACE_MCP_NODE_OVERRIDE) { $NodePath = $env:TRACE_MCP_NODE_OVERRIDE $UsingOverride = $true $UsingNodeOverride = $true } if ($en - hooks/trace-mcp-launcher.shGitHub
Read the script
#!/bin/bash # trace-mcp-launcher v0.6.16 # Stable shim: MCP clients invoke this path forever; it resolves node + cli.js # at runtime from a config file written by `trace-mcp init`, with a probe # fallback for when the config is stale (e.g. Node was reinstalled, or the # global package moved to a different npm prefix). # # Managed by trace-mcp — do not edit by hand. Re-run `trace-mcp init` to refresh. set -u # HOME is not guaranteed. A systemd unit with `User=` but no `Environment=HOME`, # a container ENTRYPOINT, a bare launchd job, an `env -i` wrapper — all of them # spawn the MCP client without it, and every bare `$HOME` below is then a hard # abort under `set -u`: exit 1, a raw bash error into the client's stderr, no # recovery message, no log line, no probe. The probe loss is the worse half: # `node_candidates` expands its whole `for` list before the first iteration, so # `/opt/homebrew/bin/node` — listed *before* the `$HOME` entry — is never even # emitted, and a machine with a perfectly good node reports "node binary not # found" (TRA-1163, reproduced both ways). # # One guard here rather than `${HOME:-}` at ~15 call sites: the failure is the # class, not any one path. Bash expands `~` from the passwd database when HOME # is unset, which is exactly the value the missing variable should have had. # # The `unset` is load-bearing, not tidiness. HOME set to the EMPTY STRING is a # real spawn shape (a launcher that exports every variable it knows, known or # not), and `~` consults the passwd database ONLY when HOME is unset — with # HOME="" it expands to the current value, i.e. stays empty, and every path # below resolves against `/`: `$HOME/.trace` becomes `/.trace`, which is # unwritable for a normal user and, in a container running as root, is # silently CREATED at the filesystem root. # # If passwd has no home either, refuse to root paths at `/` — a nonexistent # directory makes the probe fall through to its npm-prefix sources and, at # worst, die with the recovery message, which is the honest outcome. # # Exported, because cli.js resolves its own state directory from it and an # unset HOME there is a second outage hiding behind this one. if [ -z "${HOME:-}" ]; then unset HOME HOME=~ [ -n "$HOME" ] || HOME=/nonexistent fi export HOME # The shim inherits the MCP client's PATH, and a client started in a project # directory routinely carries that repository's `node_modules/.bin` on it. Every # helper this script runs (date, sed, head, ls, realpath, mv, ...) would # otherwise be resolvable to a repo-controlled executable. Pin PATH to system # directories for our own work and hand the client's PATH back to node right # before exec — the server itself needs it to find git, LSP servers and npm. CLIENT_PATH="$PATH" PATH=/usr/bin:/bin:/usr/sbin:/sbin export PATH # Determine TRACE_HOME: # 1. Explicit TRACE_MCP_HOME or TRACE_MCP_DATA_DIR override always wins. # 2. Sibling directory of this shim: the shim is installed at <TRACE_HOME>/bin/trace # or <TRACE_HOME>/bin/trace-mcp. If launcher.env exists in that parent directory, # use it. This survives MCP clients spawned with an isolated or modified HOME # (e.g. Antigravity, containers, launchd, Claude Code --isolated). # 3. $HOME/.trace (the standard default). # 4. $HOME/.trace-mcp (pre-TRA-611 legacy home). if [ -n "${TRACE_MCP_HOME:-}" ]; then TRACE_HOME="$TRACE_MCP_HOME" elif [ -n "${TRACE_MCP_DATA_DIR:-}" ]; then TRACE_HOME="$TRACE_MCP_DATA_DIR" else SHIM_FILE="" if [ -n "${BASH_SOURCE[0]:-}" ]; then SHIM_FILE="${BASH_SOURCE[0]}" elif [ -n "${0:-}" ]; then SHIM_FILE="$0" fi if [ -n "$SHIM_FILE" ]; then REAL_SHIM="$SHIM_FILE" # Dereference symlinks so invoking via ~/.trace-mcp/bin/trace-mcp (which symlinks # to ~/.trace/bin/trace) resolves to the canonical install directory holding # launcher.env even when ~/.trace-mcp has no config of its own. while [ -L "$REAL_SHIM" ]; do target="$(readlink "$REAL_SHIM" 2>/dev/null)" || break case "$target" in /*) REAL_SHIM="$target" ;; *) REAL_SHIM="$(dirname "$REAL_SHIM")/$target" ;; esac done SHIM_DIR="$(dirname "$REAL_SHIM")" if [ -d "$SHIM_DIR" ]; then CANDIDATE_HOME="$(cd "$SHIM_DIR/.." 2>/dev/null && pwd -P)" if [ -n "$CANDIDATE_HOME" ] && { [ -f "$CANDIDATE_HOME/launcher.env" ] || [ -f "$CANDIDATE_HOME/.config.json" ]; }; then TRACE_HOME="$CANDIDATE_HOME" fi fi fi if [ -z "${TRACE_HOME:-}" ]; then TRACE_HOME="$HOME/.trace" if [ ! -d "$TRACE_HOME" ] && [ -d "$HOME/.trace-mcp" ]; then TRACE_HOME="$HOME/.trace-mcp" fi fi fi # Export so cli.js (and src/global.ts) connects to the same state directory export TRACE_MCP_HOME="$TRACE_HOME" export TRACE_MCP_DATA_DIR="$TRACE_HOME" CONFIG="$TRACE_HOME/launcher.env" LOG="$TRACE_HOME/launcher.log" # One global node_modules root per line, appended by each install. PKG_ROOTS_FILE="$TRACE_HOME/pkg-roots" # Where the desktop app last saw itself, written by the app on every launch # (packages/app/src/main/install-path.ts). The only pointer to an app-only # install that survives the app being moved. APP_LOCATION_FILE="$TRACE_HOME/app-location.json" # Rotate once per invocation, before the first append (TRA-702). The shim runs # once per MCP client launch and writes a couple of lines, so a size check here # costs one stat and bounds the file at 2 x LOG_MAX_BYTES across both # generations. Without it launcher.log only ever grew — 9.7 MB observed. LOG_MAX_BYTES=${TRACE_MCP_LOG_MAX_BYTES:-5242880} # The override is user input. `[ x -gt y ]` on a non-numeric operand prints # "integer expression expected" straight to the MCP client's stderr, which is # exactly the leak TRA-797 closed elsewhere — bound it here, as the shim # already does for NODE_MIN_MAJOR below. case "$LOG_MAX_BYTES" in ''|*[!0-9]*) LOG_MAX_BYTES=5242880 ;; esac rotate_log() { [ -f "$LOG" ] || return 0 # stat is not portable between GNU and BSD - hooks/trace-mcp-mirror.shGitHub
- hooks/trace-mcp-precompact.shGitHub
- hooks/trace-mcp-reindex.shGitHub
- hooks/trace-mcp-session-end.shGitHub
- hooks/trace-mcp-session-start.shGitHub
- hooks/trace-mcp-stop.shGitHub
- hooks/trace-mcp-user-prompt-submit.shGitHub
- hooks/trace-mcp-worktree.shGitHub
All 14 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withtrace-mcp
Framework-aware code intelligence MCP server — 88 framework integrations, 81 languages, 72.7% fewer input tokens to review a pull request, comprehension at parity
Get the whole plugin
Stats
178
Stars
21
Forks
Active
Maintenance
TypeScript
Language
MIT
License
15m ago
Last commit
5mo ago
Created
Repo: nikolai-vysotskyi/trace-mcp

