feat: package as a standalone binary with burrito + tinfoil - #68
Conversation
The path pointed at the old Code/active/ layout. Without it the server resolves against the project directory.
Publishes self-contained binaries for macOS, Linux, and Windows on tagged releases, so the stdio server can be installed without an Elixir toolchain or a checkout. Argument parsing moves from a hand-rolled System.argv/0 match to a Cheer command tree. System.argv/0 returns [] inside a Burrito-wrapped binary, which failed silently: --transport stdio fell through to the :http default and started Bandit while the MCP client waited for a handshake. argv now goes through HexpmMcp.CLI.argv/0, which prefers Burrito.Util.Args.argv/0 when running standalone. Cheer also supplies --help, --version, and choice validation, and returns rather than halting, so the parse result drives the supervision tree from Application.start/2. The standalone binary defaults to stdio; every other mode keeps the http default the Fly deployment relies on. The Burrito wrap step is gated so one release entry serves both the Fly image and the binaries. Tinfoil.Build runs `mix release` with no name and reads burrito_out/<app>_<target>, so a second release entry is not an option. Burrito is held at ~> 1.5.0. In 1.6.0 the launcher passes "-s elixir start_cli" as separate argv tokens rather than one, which actually invokes Elixir's CLI; it then claims --version and --help and treats the first remaining argument as a script path, so `hexpm_mcp --transport stdio` dies with "No file named --transport". This reproduces on tinfoil_demo, so it is not specific to this project. The generated workflow's toolchain versions are pinned rather than inferred, working around joshrotenberg/tinfoil#105 and #106, and the publish step carries a manual --replace until joshrotenberg/tinfoil#103 adds an attach mode. Homebrew and Scoop are left disabled; both need a tap-push credential on this repo.
Replaces the smuggle-config-through-the-handler pattern and the hand-rolled Burrito argv shim with the upstream equivalents added in joshrotenberg/cheer#135 and #137. Cheer.parse/3 resolves and validates argv without invoking a handler, which is the right shape when arguments configure a supervision tree instead of driving a unit of work. It also distinguishes :handled from a handler that returns :ok, so "help was printed" is no longer inferred from our own return convention. Cheer.argv/0 replaces the local Burrito.Util.running_standalone?/0 check for reading argv. The local check remains only for choosing the default transport. Requires cheer 0.2.1, which is not yet published. CI will not resolve deps until it is. run/2 is retained as a thin wrapper over the same conversion because a leaf command without it warns and CI compiles with --warnings-as-errors. See joshrotenberg/cheer#140.
joshrotenberg/cheer#142 added a parse_only marker, so a command tree consumed only through Cheer.parse/3 no longer warns about a missing run/2. Removes the wrapper kept solely to satisfy --warnings-as-errors.
|
Picked up the rest of the cheer work.
command "hexpm_mcp" do
about "MCP server for hex.pm and hexdocs.pm"
version @version
parse_only()
...
endAll five cheer issues opened from this work are closed upstream (#131, #132, Still waiting on a cheer release. Its |
…g it joshrotenberg/tinfoil#103 landed attach mode and a release-published trigger, so the generated workflow no longer needs the hand-applied --replace that deleted and recreated the release release-please had just written. The workflow is now generated verbatim. Also drops the explicit zig pin. joshrotenberg/tinfoil#105 fixed the inference that always fell back, so tinfoil reads the version from Burrito.get_versions/0 and correctly pins 0.15.2 for the burrito 1.5.x we are held at. elixir and otp stay pinned, matching ci.yml and the Dockerfile rather than tracking whichever runtime generated the workflow. Note the workflow does not yet fire: release-please creates the tag and release with the default GITHUB_TOKEN, and GitHub does not start workflow runs from GITHUB_TOKEN-created events, so neither trigger tinfoil offers reaches it. See joshrotenberg/tinfoil#114.
A wrapped binary exited 1 and wrote an erl_crash.dump every time the MCP client
went away.
Anubis stops its stdio transport with :normal on EOF, which is the right reading
of a client disconnect. But the transport is a permanent child, so the
supervisor restarts it, the replacement reads EOF immediately, and the cycle
repeats until the restart intensity is exceeded. The supervisor then gives up,
the application exits, and because releases are built with start_permanent:
true, a terminating permanent application takes the node down abnormally.
Observed from the binary:
[info] MCP transport event: eof (x4)
[notice] Application hexpm_mcp exited: shutdown
Kernel pid terminated (application_controller)
Crash dump is being written to: erl_crash.dump...done
An MCP client reads that as a crash on every ordinary disconnect, and it is
where the stray erl_crash.dump in the repo root came from.
HexpmMcp.MCP.StdioLifecycle monitors the transport and turns the first clean
stop into System.halt(0), ahead of the restart storm. Only :normal and
:shutdown are treated this way, so a transport that dies for any other reason
is still left to the supervisor.
Verified against a real Burrito binary running in Linux: exit 0, no crash dump,
and the handshake unaffected.
Adds coverage for the command tree and the non-halting lifecycle branches.
… trigger joshrotenberg/tinfoil#114 added trigger: :workflow_call, so the generated workflow is now invoked directly by release-please.yml, alongside the existing publish-hex and deploy jobs. Neither event trigger could ever have worked here. release-please creates both the tag and the GitHub Release with the default GITHUB_TOKEN, and GitHub does not start workflow runs from events that token creates, so on: push: tags and on: release: [published] would both have sat silently idle. This mirrors how deploy.yml is already wired. The tag is passed explicitly, since a workflow_call run has no release event of its own to read it from. Requires tinfoil 0.2.21 for --attach, --tag, and the trigger; not yet published.
48a5856 to
d5821c5
Compare
cheer 0.2.1 and tinfoil 0.2.21 are published, so the branch no longer needs CHEER_PATH or TINFOIL_PATH to build. Regenerated the release workflow and installer scripts against the released tinfoil; only the version header differs from what the checkout produced.
The binaries job added in #68 broke Release Please at startup. release.yml declares id-token: write and attestations: write for build provenance, and a reusable workflow cannot request more than its caller was granted, so the call made GitHub reject the entire workflow file. The only diagnostic offered is "This run likely failed because of a workflow file issue". Grants both to the caller.
Packages hexpm-mcp as a self-contained single-file binary, so the stdio server
can be installed without an Elixir toolchain or a checkout.
Before, the README told people to clone the repo and run
mix run --no-halt -- --transport stdio. Now:curl -fsSL https://raw.githubusercontent.com/joshrotenberg/hexpm-mcp/main/scripts/install.sh | sh{ "mcpServers": { "hexpm": { "command": "hexpm_mcp", "args": ["--transport", "stdio"] } } }Tagged releases build for macOS (arm64, x86_64), Linux (arm64, x86_64 musl), and
Windows (x86_64), attaching archives plus a combined
checksums-sha256.txttothe release release-please already created.
Argument parsing
HexpmMcp.Applicationparsed--transportout ofSystem.argv/0, which returns[]inside a Burrito-wrapped binary. The failure was silent:--transport stdiofell through to the
:httpdefault, so the binary would start Bandit on 8765while the MCP client waited forever for a handshake.
HexpmMcp.CLIis now a Cheer command tree consumed throughCheer.parse/3,which validates argv without invoking a handler. That suits a server, where
arguments configure a supervision tree rather than drive a unit of work:
{:ok, HexpmMcp.CLI, args}starts children:handledmeans help or version was printed, halt 0{:error, :usage}halts 2argv comes from
Cheer.argv/0. It,parse/3, andparse_onlywere all addedupstream in response to this work (joshrotenberg/cheer#131, #132, #140).
Default transport is
:stdiowhen running standalone and:httpotherwise, sothe Fly deployment is untouched.
Clean exit on client disconnect
A wrapped binary exited 1 and wrote an
erl_crash.dumpevery time the MCPclient went away.
Anubis stops its stdio transport with
:normalon EOF, correctly reading that asa disconnect. But the transport is a permanent child, so the supervisor restarts
it, the replacement immediately reads EOF, and after four cycles the restart
intensity is exceeded. The supervisor gives up, the application exits, and
because releases are built with
start_permanent: true, a terminating permanentapplication takes the node down abnormally:
Every client would have read an ordinary shutdown as a crash. This is also where
the stray
erl_crash.dumpin the repo root came from.HexpmMcp.MCP.StdioLifecyclemonitors the transport and turns the first cleanstop into
System.halt(0), ahead of the restart storm. Only:normaland:shutdownqualify, so a transport that dies for any other reason is still leftto the supervisor.
This is only reachable from a real binary talking to a real client, not from CI
or any source-path test.
One release entry, gated wrap step
Tinfoil.Buildrunsmix releasewith no release name and readsburrito_out/<app>_<target>, so the release must stay singular and namedhexpm_mcp. Adding&Burrito.wrap/1unconditionally would break theDockerfile's
mix release, which has no Zig.Gating on
BURRITO_TARGETalone does not work: mix.exs is evaluated at Mixstartup, before
tinfoil.buildsets the variable in-process, so it always readsniland the release silently assembles unwrapped. The gate checks the invokedtask too (joshrotenberg/tinfoil#107).
mix tinfoil.build --target xBURRITO_TARGET=x mix releasemix release(Dockerfile)Release wiring
release-please.ymlcalls the generated workflow directly, next to the existingpublish-hexanddeployjobs:Neither event trigger would ever have fired. release-please creates the tag and
the release with the default
GITHUB_TOKEN, and GitHub does not start workflowruns from events that token creates, so
on: push: tagsandon: release: [published]would both have sat silently idle.trigger: :workflow_callwas added upstream for this (joshrotenberg/tinfoil#114), and itmirrors how
deploy.ymlis already wired. The tag is passed explicitly since aworkflow_callrun has no release event of its own.Homebrew and Scoop are left disabled; both need a tap-push credential
(
COMMITTER_TOKEN) on this repo. Enabling them is one config block each.Burrito held at 1.5.x
1.6.0 shipped the Zig 0.16 rewrite in burrito-elixir/burrito#225, which split the
launcher's
"-s elixir start_cli"from a single argv token into three. Thattoken was previously never parsed by
erl, so Elixir's CLI never ran. Now itdoes, and it claims
--versionand--helpfor itself and treats the firstremaining plain argument as a script path.
Reproduced on an otherwise untouched
tinfoil_demobuilt against 1.6.0:This was reported on the PR before it merged and shipped anyway.
Verification
Built and exercised a real Burrito binary (
linux_arm64, burrito 1.5.0, Zig0.15.2) in Linux, since Zig 0.15.2 cannot link against the macOS 26 SDK on the
dev host:
Also confirmed: no-arg defaulting to stdio,
--help, and exit 0 with cleanstderr and no crash dump on stdin close.
Plain
mix releasestill produces_build/prod/rel/hexpm_mcpand noburrito_out/, so the Fly image is unaffected.format,
compile --warnings-as-errors,credo --strict, dialyzer, and 120 testsall pass against the published cheer 0.2.1 and tinfoil 0.2.21.
Upstream issues opened from this work
All closed and released.
Cheer.argv/0, #132Cheer.parse/3, #133 server cookbook entry, #134 publicexit_code/1, #140parse_onlyalias Tinfoil.Burrito, #106 incompatible elixir/otp pair, #107BURRITO_TARGETinvisible to mix.exs, #114trigger: :workflow_call