Repository navigation
Description
Relocated from dezren39/darwin-nix#136 on 2026-10-28 (placement audit): mcpx's single source lives in developing-today/code (pkgs/mcpx); re-home tracked at #309. Original body below; issue references inside it are historical (dezren39/nix numbering).
Parent: #97
Summary
The notes list a wish-list of further environments (alpine-slim, FROM scratch, distroless, flatpak, fedora-kde, silverblue/kinoite, ubuntu, windows-11) and justify it with "basically everything should work because static binary". This issue checks that claim. mcpx itself can be a static binary; what stands in the way is elsewhere:
- The shipped artifacts are not static -- by choice, not by necessity. The default nix package is built with the nixpkgs cgo default and installed behind a C wrapper with nix-store paths baked in; Phase 5: tag event bodies must travel with the event #182's
ContainerfilekeepsARG CGO_ENABLED=1deliberately. ACGO_ENABLED=0build is fully static and works (verified in Phase 5: tag event bodies must travel with the event #182,docs/container.md§4). Which artifacts should be static is a policy decision (decision 1 below), not a feasibility question. execneeds a JS runtime, which no minimal image has -- and bun, deno and git are dynamically linked distribution binaries, so a staticmcpxbeside them does not make an image static.- mcpx does not compile for Windows today.
It then assigns each environment a tier, says what it needs (a copied binary or an installer, and any extra config), and says which runner provides it.
Original notes
- testing
- a general goal is to ensure all features have one or more integration tests
- for both binary && app entrypoint (consider binary a superset)
- all tests should pass for nix flake & container
- ideally would like if worked:
- alpine-slim/FROM scratch/distroless/flatpak
- fedora-kde,silverblue/kinoite,ubuntu
- windows-11
- basically everything should work because static binary
- non-vanilla may need extra config
- installers make assumptions and require permissions
Current state: is the binary static? (checked on main d0b7e01; #182's measurements added at 05c78b2)
| Fact | Evidence |
|---|---|
nix builds with CGO_ENABLED=1 |
buildGoModule uses CGO_ENABLED = args.env.CGO_ENABLED or go.CGO_ENABLED (nixpkgs c7def04, pkgs/build-support/go/module.nix:225). Go 1.26's default is 1 except on wasi/ppc64-BE (pkgs/development/compilers/go/1.26.nix:81-90). package.nix sets nothing. Confirmed on the installed darwin build: go version -m <store>/bin/.mcpx-wrapped prints CGO_ENABLED=1 and otool -L lists libSystem, libresolv, CoreFoundation, Security |
Under cgo (the default nix build), Linux net (DNS resolver) and os/user link libc dynamically |
mcpx imports net (daemon, runner) and os/user (internal/logging/trace.go:9). So the default nix Linux binary is dynamically linked against nix's glibc, with a /nix/store/... ELF interpreter, and will not start on a system without that store path. Not checked on Linux in this issue: file result/bin/.mcpx-wrapped on x86_64-linux is the one-line check. This is a property of the build flags, not of mcpx: see the next row |
With CGO_ENABLED=0, mcpx is fully static and works |
#182 built it both ways through the Containerfile's ARG CGO_ENABLED (linux/arm64): ldd reports "not a dynamic executable"; --version, ls, call and a bun exec all work; the size is the same to ~100 bytes (14,811,298 vs 14,811,401). os/user and net fall back to their pure-Go implementations; nothing else in mcpx needs cgo (docs/container.md §4, Containerfile:22-39). The only os/user call is user.Current() for the trace record's user fields, and its error is tolerated (internal/logging/trace.go:143-146) |
The installed bin/mcpx is a C wrapper |
makeBinaryWrapper (package.nix postInstall): a 50 KB Mach-O on darwin that execs .mcpx-wrapped with PATH suffixed by nix-store git deno bun node. Copying result/bin/mcpx into another image copies a wrapper pointing at paths that do not exist there |
| Pure-Go SQLite | modernc.org/sqlite, chosen to avoid a C toolchain (package.nix comment). This is not the obstacle |
meta.platforms = lib.platforms.unix |
package.nix. Windows is not a nix target |
Windows does not compile (by reading; one-line check: GOOS=windows go build ./cmd/mcpx) |
syscall.SysProcAttr{Setpgid: true} and syscall.Kill(-pid, ...) in internal/runner/runner.go:413-432 and internal/mcpclient/stdio.go:69,202-207, and Setsid in internal/cli/client.go:312, have no build tags. None of these exist in Go's Windows syscall. Only the TTY code is split (internal/cli/tty_unix.go/tty_other.go) |
The claim, corrected: a CGO_ENABLED=0 build (or -tags netgo,osusergo) of the unwrapped binary is static on Linux -- verified, not assumed (#182) -- so the non-exec surfaces should run anywhere with a Linux kernel. That covers ls, call, serve, the daemon, /v1, /mcp, and api --spec. exec/run do not, because they spawn deno, bun or node. The static-binary argument covers the daemon and CLI surfaces only. Nothing in mcpx "prevents" a static build; the open question is which shipped artifacts should be static, below.
The runtime caveat (exec)
| Runtime | glibc | musl (alpine) | static / scratch |
|---|---|---|---|
| deno | single binary, links glibc | no musl build. denoland/deno:alpine copies glibc from gcr.io/distroless/cc and patchelfs deno to use it (https://github.com/denoland/deno_docker/blob/main/alpine.dockerfile; upstream answer in denoland/deno_docker#428) |
needs glibc: distroless/cc is the smallest working base |
| bun | yes | musl builds are published (bun-linux-x64-musl). Version floor to verify |
no |
| node | yes | official node:*-alpine images |
no |
So "FROM scratch/distroless-static" means non-exec surfaces only, unless a runtime is added, which then makes the image distroless/cc or larger. #182 confirmed the same from the other side: in its debian image bun and git are dynamically linked distribution binaries, so a static mcpx beside them "saves nothing at all" until everything in the image is static or exec is dropped (docs/container.md §4).
Environments, tiered
| Environment | What works | Delivery | Extra config ("non-vanilla") | CI | Tier |
|---|---|---|---|---|---|
| ubuntu (host) | everything | nix or copied static binary + runtimes | none | ubuntu-latest hosted |
1 |
| nix flake / flake image (#134) | everything | nix | none | ubuntu-latest |
1 |
| macOS (host) | everything | nix | none | macos-latest hosted |
2 |
| debian-slim / ubuntu container | everything | copied static binary + runtimes | none | container: on ubuntu |
2 |
↳ #182's pkgs/mcpx/Containerfile (exists) |
everything, with bun only (MCPX_SCRIPT_RUNTIME=bun) |
builds mcpx in a Go stage (CGO_ENABLED=1 by default, glibc-linked) rather than copying a static one; git + ca-certificates; 413 MB |
none | nothing builds or runs it yet (#134 container-user) |
2 |
| distroless/cc | everything with deno only | copied static binary + deno | script.runtime=deno |
container: |
2 |
| alpine-slim | non-exec. exec with node or bun (musl) or with deno + glibc shim | copied static binary + apk add nodejs |
script.runtime=node; no socket for node (it uses TCP, #35) |
container: |
2 |
FROM scratch / distroless-static |
non-exec only | copied static binary; needs /tmp and a writable state dir, plus CA certs for the registry. No /etc/passwd is harmless: user.Current() fails and the trace record just omits the user (internal/logging/trace.go:143) |
daemon.inline maybe (no init to reap; daemon.inline's description names that container as a use case, registry.go:377-388); MCPX_STATE_DIR |
container: |
2 |
| fedora (kde) | everything | copied binary, or an rpm later | none. KDE is irrelevant to a CLI except the TUI's terminal (Konsole) | container: fedora:latest |
3 |
| silverblue / kinoite | everything, inside a toolbox. On the host: /usr is read-only, so copy to ~/.local/bin (works) or layer an rpm with rpm-ostree install (needs a reboot and root) |
copied binary to ~/.local/bin |
none, if runtimes come from toolbox or homebrew | not simulable by a container (ostree host). A fedora-toolbox image covers the toolbox case. The real host needs a VM, self-hosted |
3 |
| flatpak | awkward fit: flatpak packages desktop apps, and mcpx is a CLI plus daemon that spawns host tools (MCP servers, runtimes) | a flatpak manifest | sandbox: the socket must live in $XDG_RUNTIME_DIR/app/<id>/ or be exposed with --filesystem=xdg-run/mcpx; host MCP servers only via flatpak-spawn --host (needs --talk-name=org.freedesktop.Flatpak, which effectively disables the sandbox) |
flatpak-builder on ubuntu |
3 |
| windows-11 | nothing until it compiles | copied .exe, or an installer (winget/scoop) |
see below | windows-latest hosted |
3, then 2 once it compiles |
"installers make assumptions and require permissions": the tier 1-2 set needs no installer. It uses nix, or a copied binary into ~/.local/bin or the image. Installers appear only for:
- rpm-ostree layering (root, reboot);
- flatpak (a portal/permission model);
- Windows (winget/scoop: per-user vs machine-wide, SmartScreen, Defender scans of a new unsigned
.exe).
Recommendation: ship and test "copy the static binary" as the universal path, and treat each installer as its own tier 3 target with its own issue when one is wanted.
Windows, in detail
- Build: split
runner.go,stdio.go,client.goprocess handling into_unix.go/_windows.go. Windows has no process groups in the POSIX sense. The equivalent is a Job Object (CREATE_NEW_PROCESS_GROUP+JobObjectwithKILL_ON_JOB_CLOSE), andSetsiddetach becomesDETACHED_PROCESS. This is not a build-tag exercise: process-group kill is how mcpx stops an MCP server and everything it spawned, and without a Job Object a Windows mcpx leaks children -- the failurescripts/stress.shexists to catch (from Phase 2: group action codec does not cover friendship or policy #163).internal/pool/pool.go:736also checks liveness withsyscall.Signal(0), meaningless on Windows. - Sockets: AF_UNIX exists on Windows 10 1803+ and Windows 11 (https://devblogs.microsoft.com/commandline/af_unix-comes-to-windows/), and Go supports
net.Listen("unix", ...)there. Whether deno's and bun's unix-socket clients work on Windows is unverified. Node already uses TCP (fix(deps): update rust crate axum to 0.6.20 #35), so a TCP-only mode (daemon.port+MCPX_ENDPOINT) is the fallback. - Paths:
XDG_*fallbacks become%LOCALAPPDATA%. ThemaxSocketPathlogic (internal/daemon/paths.go:96at 05c78b2) differs on Windows (sun_path is 108 there too, but paths are longer and use backslashes).os.Getuid()inprivateRuntimeDir(paths.go:148) returns -1 on Windows. - Signals:
signal.NotifyContext(..., SIGINT, SIGTERM)(cmd/mcpx/main.go:53). SIGTERM is never delivered on Windows. The HTTP shutdown route will not help as written:handleShutdownstops the daemon by sending SIGTERM to its own process (internal/daemon/server.go:774-780), and on Windowsos.Process.Signalsupports onlyos.Kill. Shutdown has to become a context cancel. - e2e:
e2e_test.go:590usessyscall.Signal(0)for liveness,:2704and:2717(added by Phase 5: tag event bodies must travel with the event #182) usesyscall.Kill(pid, 0), andexec_test.go:107-108usessyscall.Stat_t. All need build tags.
GitHub-hosted vs self-hosted
Hosted: ubuntu-latest/ubuntu-24.04-arm, macos-latest, windows-latest. container: and services: run only on Linux runners (https://docs.github.com/en/actions/writing-workflows/choosing-where-your-workflow-runs/running-jobs-in-a-container). So every distro and minimal image above is a Linux container: job. Silverblue/kinoite on a real ostree host, and a real KDE session, need a VM or a self-hosted runner. Flatpak can be built and run headless on ubuntu.
Approaches
Decision 1 -- which artifacts are static (policy)
What CGO_ENABLED=0 costs, for users rather than for images: pure-Go os/user parses /etc/passwd instead of asking NSS (LDAP/sssd users lose only the user name in trace records, since trace.go:143 tolerates the error), and the pure-Go resolver stops honouring nsswitch.conf and the host resolver's configuration (on Linux; this is the whole trade-off. On macOS CGO_ENABLED=0 changes nothing: since Go 1.20 net and os/user there call the system resolver and user database without cgo, https://go.dev/doc/go1.20#cgo). It matters for hostnames mcpx resolves itself: daemon.endpoint, HTTP MCP servers, the registry. Inside a container neither matters.
- A -- flip the default nix package to
CGO_ENABLED=0on Linux. One artifact, static everywhere. But it changes DNS and user lookup for every nix user on a Linux desktop to fix a problem only containers and tarballs have, and the installedbin/mcpxis still a wrapper with nix-store paths, so the nix package does not become copyable anyway. Not recommended. - B -- static only through the container build arg (
docker build --build-arg CGO_ENABLED=0, already wired in Phase 5: tag event bodies must travel with the event #182). No new package, nothing changes for nix users. But there is no static artifact outside an image -- nothing for amd host fails at evaluation: no outputHashes for p2panda-core-0.7.1 #158's release tarball, and nothing for the tier-2 images below to copy except by rebuilding in each Dockerfile. - C -- keep the default nix package as it is; add a Linux
mcpx-staticoutput (recommended).env.CGO_ENABLED = 0, no wrapper, Linux only (darwin cannot be fully static and keeps the system resolver). It is the artifact every container entry copies and amd host fails at evaluation: no outputHashes for p2panda-core-0.7.1 #158 ships in a tarball, with a test thatgo version -mreportsCGO_ENABLED=0andfilereports "statically linked". The Containerfile's default (ARG CGO_ENABLED=1) is the GIT architect's to set; C does not depend on it. Recommended because it gives the static artifact to the places that need it without changing name resolution for anyone who installed the ordinary package.
Decision 2 -- how far to go
A -- attempt the whole list as tier 1 (least recommended)
Every entry gates merges. CI time would exceed any budget (#95), Windows fails until a port is done, and silverblue needs self-hosted infrastructure. The first red flatpak job would train everyone to ignore CI.
B -- containers only
Add the Linux container: entries (alpine, distroless, scratch, fedora, ubuntu) as tier 2 nightly. Declare flatpak, silverblue and Windows out of scope. This is cheap and covers most real deployments. It drops a user-stated wish (windows-11) without a plan.
C -- tiers, with a static build first (recommended)
- Ship the static artifact chosen in decision 1 (C: a Linux
mcpx-staticoutput,env.CGO_ENABLED = 0, no wrapper) with its test (go version -mreportsCGO_ENABLED=0,filereports "statically linked"). Phase 5: tag event bodies must travel with the event #182 already showed the build works; this makes it a product. It is the artifact every container entry copies. - Tier 1 (gates merges): ubuntu host, nix. Already in Update Rust crate rand to v0.9.3 [SECURITY] #134.
- Tier 2 (nightly): macOS, debian-slim (including Phase 5: tag event bodies must travel with the event #182's Containerfile), distroless/cc, alpine, scratch. Each runs the Update Rust crate rand to v0.9.3 [SECURITY] #134 labels its surface supports. For example, scratch runs everything except tests labelled
needsRuntime, so the label system from Update Rust crate rand to v0.9.3 [SECURITY] #134 and Update dependency argoproj-labs/argocd-autopilot to v0.4.20 #137 gainsneedsRuntime:<deno|bun|node|any>-- andneedsGitfor therepo/worktreescopes, unless the GIT architect's native git resolution removes that dependency (from Phase 2: removal: weak is stored but not built #161). Where an image lacks what a test needs, prefer asserting mcpx's own refusal over skipping:no JavaScript runtime foundis a feature, a crash is a bug, and only a run that expects the failure can tell them apart (from Phase 2: removal: weak is stored but not built #161). - Tier 3 (best-effort or manual, on release): fedora, toolbox, flatpak, silverblue VM, and Windows. The Windows port is its own design issue (process groups, detach, socket vs TCP). It becomes tier 2 once
GOOS=windows go buildpasses, and a cheapgo vet-level cross-compile check can go into tier 1 right away to stop it getting worse.
Recommended because it turns an unverified claim into a checked property, orders the work by users reached per unit of effort, and keeps every wish-list item on a path, not dropped.
Make the tiers mean something with entry and exit rules (from #163): tier 1 admits a platform where a bug has been found or an artifact that ships; tier 2 admits a plausible user environment that runs unattended and passes its surface; a tier-2 leg with no finding for two months is demoted; each tier-3 item gets one paragraph now saying what it would cost and what it would prove, so "ideally would like if worked" becomes a decision someone can revisit rather than a list re-argued every quarter. #163 also argued macOS belongs in tier 1, not 2: #35 is the only cross-OS bug on record, and a parallel leg costs queue time, not wall clock. Decide against #95's budget (#140).
Open questions
IsNow decision 1 above (recommended: aCGO_ENABLED=0acceptable for the default Linux nix package too, or only for a-staticvariant?-staticvariant; feasibility was settled by Phase 5: tag event bodies must travel with the event #182).- What is bun's first release with musl builds, and does the pinned bun (1.3.11) have one? Does bun's unix-socket
fetchwork on musl? - Is a Windows port wanted at all, or is WSL2 (which is ubuntu, tier 1) the supported Windows story? If WSL2 is the answer, "windows-11" becomes a doc page plus a WSL2 smoke job.
- Flatpak: is there a real user for it, given the sandbox fights the whole design (spawning host processes)? Proposal: tier 3 "documented as unsupported" unless a user appears.
- Should the static image default to
daemon.inline=true, since there is no init to reap children? meta.platforms = lib.platforms.unix: narrow it tolinux ++ darwinto match what is tested, or keep the BSD claim as an aspiration (from Phase 2: group action codec does not cover friendship or policy #163)?- Who triages a nightly failure? A nightly nobody reads is worse than none; Bump unsafe-libyaml from 0.2.8 to 0.2.10 in /sources/pavex/examples/realworld #99 should name the owner (from Phase 2: group action codec does not cover friendship or policy #163).
macos-latestis arm64, so a macOS leg is also a second architecture;ubuntu-24.04-armis the Linux arm64 leg (Update Rust crate rand to v0.9.3 [SECURITY] #134).
Ease · Impact · Related
Ease XL as a whole (static build and tier-2 containers: M; Windows port: L; silverblue/flatpak: M each, self-hosted) · Impact med (tier 2), low-med (tier 3)
Depends on / related: #134 (container image and #182's Containerfile, labels, matrix entries), #137 (which real servers each image must carry), #133 (binary override to test a copied binary), #158 (the release tarball ships the static artifact), #95 (tiers map to PR/nightly/release workflows), #49, #35 (the Linux/macOS split that motivates cross-OS runs), #99.
Security/auth deferred; see tracker.
Acceptance criteria
- The extended-platforms question is answered with evidence: which artifacts ship static, and the alpine/scratch/container legs specified in the body run or are explicitly deferred with reasons recorded on this issue.
- The work described in the Summary/Scope above is delivered via a branch + PR (no direct push to main); verification runs in GitHub Actions CI, not on the sandbox.
- Evidence (CI links, timings, or probe output as applicable) is recorded on this issue before it is closed.
Activity
developing-today commentedon Oct 28, 2026
CODE-912
dezren39 commentedon Oct 28, 2026
History carried over from dezren39/darwin-nix#136 (verbatim, oldest first):
@dezren39 · 2026-09-30T06:51:54Z
Overlaps #163, written concurrently by a second worker on the same slice. Both are kept until reconciled; see the tracker (#97) for the differences and which one to keep.
@dezren39 · 2026-09-30T07:21:10Z
Integration pass. Placed in the DAG rooted at #181.
@dezren39 · 2026-09-30T12:03:28Z
Issues pass (2026-09-30, at 05c78b2).
netandos/userlink libc" are true of the default nix build, not of what mcpx can be: Phase 5: tag event bodies must travel with the event #182 builtCGO_ENABLED=0and got a fully static, working binary (ldd: not a dynamic executable;--version/ls/call/execpass; same size). Summary item 1, the evidence table and "the claim, corrected" now say so; the old sentences would have sent an implementer after a non-problem.CGO_ENABLED=0acceptable for the default package or only a-staticvariant?" is now Decision 1 (policy), A/B/C: flip the default (A, not recommended), container build arg only (B), keep the default and add a Linuxmcpx-staticoutput (C, recommended), with the NSS/DNS trade-off stated. The onlyos/useruse is the trace's user fields and its error is tolerated (internal/logging/trace.go:143). The old "how far to go" A/B/C is Decision 2, unchanged except step 1 now ships the Decision 1 artifact.e2e_test.go:590, new:2704/:2717;paths.go:96,148) and addedpool.go:736.meta.platforms, nightly triage owner. From closed Phase 2: removal: weak is stored but not built #161: theneedsGitlabel and "assert the refusal, not a skip".@dezren39 · 2026-09-30T12:08:14Z
Issues pass (2026-09-30, architect follow-up). The
CGO_ENABLED=0cost paragraph implied the macOS default build differs from a cgo-off build. It does not: since Go 1.20,netandos/useron macOS do not use cgo in either mode (https://go.dev/doc/go1.20#cgo). The NSS/nsswitchtrade-off in Decision 1 is Linux-only; the sentence now says so.@dezren39 · 2026-09-30T12:25:15Z
Issues pass (2026-09-30), heads-up. PR #194 (open, mcpx: build pure-Go (CGO_ENABLED=0)) sets
env.CGO_ENABLED = 0inpkgs/mcpx/package.nixfor every nix build. That is this issue's Decision 1, option A (flip the default nix package), which the body currently ranks below C (a separate Linuxmcpx-staticoutput). The PR's reason is different from the ones weighed here: a C toolchain in the derivation trips the cc-wrapper purity check on a symlinked/nixstore. It says nothing about the Linux NSS trade-off (/etc/passwdinstead of NSS/LDAP; the resolver bypasses NSS modules). On macOS the flag changes nothing (Go 1.20+). If #194 merges, Decision 1 is settled as A: record that here, keep the NSS caveat in the docs (#147), and #158's release tarball simply ships the default build.@dezren39 · 2026-09-30T13:00:59Z
Issues pass (2026-09-30). Heads-up, no body change: PR #194 ("mcpx: build pure-Go (CGO_ENABLED=0)") is open and decides this issue's central question by flipping the default.
That is now a policy choice rather than a feasibility one — #182 verified by cross-compiling that
GOOS=linux CGO_ENABLED=0produces a fully static, working 21 MB ELF, so nothing in mcpx blocks a static build;os/userandnetpull libc only under cgo. The trade this issue documents still applies and should be read before #194 merges: pure-Goos/userreads/etc/passwdrather than NSS/LDAP, and the pure-Go resolver ignoresnsswitch.confand the macOS resolver, which is irrelevant in a container and relevant on a corporate laptop with split-DNS VPN. On macOSCGO_ENABLED=0changes nothing since Go 1.20. Andmcpx execneeds a JavaScript runtime whatever the binary is, so a static binary is not a self-contained one —package.nix:96wraps it with aPATHsuffix of git, deno, bun and node regardless.#181's evidence section and #180's A2 row both carry the correction.
dezren39 commentedon Oct 28, 2026
Intake review
🤖 Agent note · state: filed
43 remaining items
dezren39 commentedon Nov 3, 2026
🤖 Agent note · handed off
dezren39 commentedon Nov 3, 2026
🤖 Agent note · picked up for queue task 7821 (OpenChamber scheduled lane); revalidating current scope and ownership.
dezren39 commentedon Nov 3, 2026
🤖 Agent note · triaged (queue task 7821; verified against
origin/main80b06f8).Verdict: still live as a longer-term platform tracker, but its Tier 2 acceptance is delivered. PR #2705 merged as
9ffa739a: the container defaults toCGO_ENABLED=0, docs record which artifacts are static, and the tier-2 script/workflow runs the five specified Alpine, scratch, and distroless legs. The Alpine + Node.js exec leg is explicitly excluded due toERR_NO_TYPESCRIPT; #2699 records the repro and acceptance and is currently OPEN/in progress, so I did not create duplicate work.The remaining Fedora, Flatpak, Silverblue/Kinoite, and Windows work is explicitly deferred to separate issues when wanted (PR #2705 body). No new implementation packet is warranted without a scoped request; keep #1987 open as the deferred tracker.
dezren39 commentedon Nov 3, 2026
🤖 Agent note · triage queue task 7821 completed; #1987 remains open for the explicitly deferred Tier 3 platform remainder. Evidence is in the triage note above.