Skip to content

Detect stale stdio MCP server with preview_build_info tool (#147)#150

Merged
obj-p merged 2 commits intomainfrom
issue-147-stale-binary
Apr 30, 2026
Merged

Detect stale stdio MCP server with preview_build_info tool (#147)#150
obj-p merged 2 commits intomainfrom
issue-147-stale-binary

Conversation

@obj-p
Copy link
Copy Markdown
Owner

@obj-p obj-p commented Apr 30, 2026

Summary

  • Adds a new MCP tool preview_build_info that reports the running server's binary path, on-disk mtime, and process start time, along with stale = binaryMtime > processStartTime.
  • Updates the integration-test skill: Step 0 hard-resets the UDS daemon (kill-daemon + status verify), and Step 2 calls preview_build_info after swift build and aborts with a restart-Claude-Code instruction when stale: true.
  • Documents the split-brain (stdio server vs UDS daemon — separate processes, separate session state) in 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 build overwrites 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 --dirty into GeneratedVersion: Plugins/GenerateVersion/GenerateVersion.swift pins inputFiles: [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 — new previewBuildInfo tool case + schema (no params).
  • Sources/PreviewsCLI/MCPServer.swift — dispatch + handler that resolves the binary via _NSGetExecutablePath, stats mtime, compares to ProcessStartup.time.
  • Sources/PreviewsCLI/PreviewsMCPApp.swiftenum 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 so stale=true reports correlate to actual restart events.
  • Sources/PreviewsCLI/DaemonProtocol.swift — new BuildInfoResult DTO.
  • .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 (uses utimensat for sub-second precision).

Out of scope

Test plan

  • swift build clean.
  • BuildInfoTests (2 tests): fresh-server stale=false; post-utimensat stale=true and binaryMtime > preMtime strictly.
  • MacOSMCPTests (7 tests) — verified existing MCP surface is unaffected.
  • VersionHandshakeTests (3 tests) — verified the daemon respawn handshake is unaffected.
  • Manual smoke: stdio JSON-RPC tools/call preview_build_info returns the structured payload with valid ISO8601 timestamps and stale: false.
  • Run the integration-test skill end-to-end after this lands to confirm Step 0 + Step 2 fire as designed.

🤖 Generated with Claude Code

obj-p and others added 2 commits April 29, 2026 22:27
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>
@obj-p obj-p merged commit b5bb6aa into main Apr 30, 2026
4 checks passed
@obj-p obj-p deleted the issue-147-stale-binary branch April 30, 2026 12:22
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant