Detect stale stdio MCP server with preview_build_info tool (#147)#150
Merged
Detect stale stdio MCP server with preview_build_info tool (#147)#150
Conversation
The integration-test skill validated behavior via mcp__previewsmcp__* tool calls, but those calls hit the stdio MCP server Claude Code spawned at session start — not the freshly-built binary. swift build overwrites the on-disk binary while the resident process keeps running the old code, so a contributor could edit code, run the skill, see green, and ship a regression. The build step never exercised the change. Add a new MCP tool, preview_build_info, that reports the running process's start time alongside the binary path's current mtime. Any swift build after process start advances the mtime, so the handler can report stale=true regardless of whether the rebuild changed the commit SHA, dirty state, or anything else. The integration-test skill now calls preview_build_info as Step 2 and aborts with a restart-Claude-Code instruction when stale=true. Step 0 also hardens the UDS daemon reset: kill-daemon then status, abort if a daemon survives. The daemon is load-bearing for Step 7's preview_list calls on non-SPM examples, so a residual stale daemon is no longer tolerated. The split-brain between the stdio server (spawned via .mcp.json) and the UDS daemon (auto-spawned by CLI subcommands) — separate processes, separate session state — is now documented in AGENTS.md. The plan considered baking --dirty into GeneratedVersion via the build plugin, but Plugins/GenerateVersion/GenerateVersion.swift pins inputFiles: [gitHeadURL] and so the plugin only re-runs when HEAD moves. Uncommitted edits don't move HEAD; the dirty bit would have been a placebo. Mtime/start-time comparison sidesteps the plugin caching issue entirely and is a strict superset (catches same-SHA rebuilds too). ProcessStartup.time is captured at PreviewsMCPApp.main entry rather than lazily inside the handler — the daemon can spend seconds listening before the first MCP request arrives, and during that window swift build could replace the binary, falsely reporting fresh. BuildInfoTests locks in the load-bearing OS behavior: that stat on the binary path advances after utimensat post-spawn while the running process retains its earlier start time. If a future linker or sandbox change ever decoupled this, the test surfaces it instead of the tool silently lying. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…g error - ServeCommand.runStdio now logs pid + ISO8601 start time, mirroring the daemon path. The whole point of preview_build_info is the stdio server, so operators correlating stale=true reports get the same breadcrumb on the path that actually matters. - handlePreviewBuildInfo's silent fallback in the structured-content catch now logs the encoding error. Reachable only if BuildInfoResult stops being Codable — a programmer error worth seeing rather than silently degrading to text-only output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Apr 30, 2026
Closed
obj-p
added a commit
that referenced
this pull request
May 1, 2026
`DaemonClient.spawnDaemon` resolved the binary to launch via `ProcessInfo.processInfo.arguments[0]`. argv[0] is unreliable: it can be relative (`./previewsmcp`, resolved against the current working directory), bare when PATH-resolved by the shell, or rewritten by the caller via `execve`. The version-mismatch respawn logic from #142 made this load-bearing — every transparent daemon respawn re-runs `spawnDaemon`, so a fragile self-path shows up as a respawn loop or a spurious "binaryNotFound" against a CLI that's actually on disk. `MCPServer.swift` already had a working `_NSGetExecutablePath`-backed helper for `preview_build_info` (added in #150), with a comment explicitly noting that #100 covers migrating the daemon path to it. This change extracts that helper into module-internal `SelfPath.swift` and points both call sites at it. The kernel records the executing binary's path at `execve` time; this primitive reads it back regardless of CWD or what the caller put in argv[0]. Tests cover the property argv[0] lacks: the resolved path is absolute, exists, is identical across calls, and is invariant under chdir. The suite is `.serialized` because the chdir test mutates process-global state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
obj-p
added a commit
that referenced
this pull request
May 1, 2026
`DaemonClient.spawnDaemon` resolved the binary to launch via `ProcessInfo.processInfo.arguments[0]`. argv[0] is unreliable: it can be relative (`./previewsmcp`, resolved against the current working directory), bare when PATH-resolved by the shell, or rewritten by the caller via `execve`. The version-mismatch respawn logic from #142 made this load-bearing — every transparent daemon respawn re-runs `spawnDaemon`, so a fragile self-path shows up as a respawn loop or a spurious "binaryNotFound" against a CLI that's actually on disk. `MCPServer.swift` already had a working `_NSGetExecutablePath`-backed helper for `preview_build_info` (added in #150), with a comment explicitly noting that #100 covers migrating the daemon path to it. This change extracts that helper into module-internal `SelfPath.swift` and points both call sites at it. The kernel records the executing binary's path at `execve` time; this primitive reads it back regardless of CWD or what the caller put in argv[0]. Tests cover the property argv[0] lacks: the resolved path is absolute, exists, is identical across calls, and is invariant under chdir. The suite is `.serialized` because the chdir test mutates process-global state. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
preview_build_infothat reports the running server's binary path, on-disk mtime, and process start time, along withstale = binaryMtime > processStartTime.integration-testskill: Step 0 hard-resets the UDS daemon (kill-daemon + status verify), and Step 2 callspreview_build_infoafterswift buildand aborts with a restart-Claude-Code instruction whenstale: true.AGENTS.md.Why
The integration-test skill called
mcp__previewsmcp__*tools to validate behavior, but those calls hit the stdio MCP server Claude Code spawned at session start — not the freshly-built.build/debug/previewsmcp.swift buildoverwrites the binary while the resident process keeps running the old code, so a contributor could edit code, run the skill, see green, and ship a regression. The build step never exercised the change.Mtime/start-time comparison was chosen over baking
--dirtyintoGeneratedVersion:Plugins/GenerateVersion/GenerateVersion.swiftpinsinputFiles: [gitHeadURL], so the plugin only re-runs when HEAD moves. Uncommitted edits don't move HEAD; the dirty bit would have been a placebo. Mtime sidesteps that and is a strict superset (catches same-SHA rebuilds too).What changed
Sources/PreviewsCLI/MCPToolSchemas.swift— newpreviewBuildInfotool case + schema (no params).Sources/PreviewsCLI/MCPServer.swift— dispatch + handler that resolves the binary via_NSGetExecutablePath, stats mtime, compares toProcessStartup.time.Sources/PreviewsCLI/PreviewsMCPApp.swift—enum ProcessStartup { static let time = Date() }referenced at app entry to force lazy-init at app birth.Sources/PreviewsCLI/ServeCommand.swift— daemon boot log includes ISO8601 start time sostale=truereports correlate to actual restart events.Sources/PreviewsCLI/DaemonProtocol.swift— newBuildInfoResultDTO..claude/skills/integration-test/SKILL.md— Step 0 hardening, Step 2 staleness gate, two-process model preface.AGENTS.md— new "Stdio server vs UDS daemon" subsection.Tests/MCPIntegrationTests/BuildInfoTests.swift— fresh-server roundtrip + deterministic mtime-mutation lock-in (usesutimensatfor sub-second precision).Out of scope
_NSGetExecutablePath(Self-path resolution uses argv[0]; switch to authoritative API #100). The handler uses it locally; the spawn path is unchanged.Test plan
swift buildclean.BuildInfoTests(2 tests): fresh-serverstale=false; post-utimensatstale=trueandbinaryMtime > preMtimestrictly.MacOSMCPTests(7 tests) — verified existing MCP surface is unaffected.VersionHandshakeTests(3 tests) — verified the daemon respawn handshake is unaffected.tools/call preview_build_inforeturns the structured payload with valid ISO8601 timestamps andstale: false.🤖 Generated with Claude Code