Skip to content

feat: package as a standalone binary with burrito + tinfoil - #68

Merged
joshrotenberg merged 8 commits into
mainfrom
feat/burrito-tinfoil-packaging
Jul 24, 2026
Merged

feat: package as a standalone binary with burrito + tinfoil#68
joshrotenberg merged 8 commits into
mainfrom
feat/burrito-tinfoil-packaging

Conversation

@joshrotenberg

@joshrotenberg joshrotenberg commented Jul 24, 2026

Copy link
Copy Markdown
Owner

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.txt to
the release release-please already created.

Argument parsing

HexpmMcp.Application parsed --transport out of System.argv/0, which returns
[] inside a Burrito-wrapped binary. The failure was silent: --transport stdio
fell through to the :http default, so the binary would start Bandit on 8765
while the MCP client waited forever for a handshake.

HexpmMcp.CLI is now a Cheer command tree consumed through Cheer.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
  • :handled means help or version was printed, halt 0
  • {:error, :usage} halts 2

argv comes from Cheer.argv/0. It, parse/3, and parse_only were all added
upstream in response to this work (joshrotenberg/cheer#131, #132, #140).

Default transport is :stdio when running standalone and :http otherwise, so
the Fly deployment is untouched.

Clean exit on client disconnect

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, correctly reading that as
a 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 permanent
application takes the node down abnormally:

[notice] Application hexpm_mcp exited: shutdown
Kernel pid terminated (application_controller)
Crash dump is being written to: erl_crash.dump...done

Every client would have read an ordinary shutdown as a crash. This is also 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 qualify, so a transport that dies for any other reason is still left
to 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.Build runs mix release with no release name and reads
burrito_out/<app>_<target>, so the release must stay singular and named
hexpm_mcp. Adding &Burrito.wrap/1 unconditionally would break the
Dockerfile's mix release, which has no Zig.

Gating on BURRITO_TARGET alone does not work: mix.exs is evaluated at Mix
startup, before tinfoil.build sets the variable in-process, so it always reads
nil and the release silently assembles unwrapped. The gate checks the invoked
task too (joshrotenberg/tinfoil#107).

invocation result
mix tinfoil.build --target x wrapped
BURRITO_TARGET=x mix release wrapped
mix release (Dockerfile) plain assemble

Release wiring

release-please.yml calls the generated workflow directly, next to the existing
publish-hex and deploy jobs:

  binaries:
    needs: release-please
    if: needs.release-please.outputs.release_created == 'true'
    uses: ./.github/workflows/release.yml
    with:
      tag: ${{ needs.release-please.outputs.tag_name }}
    secrets: inherit

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 workflow
runs from events that token creates, so on: push: tags and
on: release: [published] would both have sat silently idle. trigger: :workflow_call was added upstream for this (joshrotenberg/tinfoil#114), and it
mirrors how deploy.yml is already wired. The tag is passed explicitly since a
workflow_call run 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. That
token was previously never parsed by erl, so Elixir's CLI never ran. Now it
does, and it claims --version and --help for itself and treats the first
remaining plain argument as a script path.

Reproduced on an otherwise untouched tinfoil_demo built against 1.6.0:

$ tinfoil_demo --version
Elixir 1.20.2 (compiled with Erlang/OTP 29)     # the app's version never prints

$ tinfoil_demo hello world
No file named hello                             # exit 1

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, Zig
0.15.2) in Linux, since Zig 0.15.2 cannot link against the macOS 26 SDK on the
dev host:

$ hexpm_mcp --version
hexpm_mcp 0.3.4

$ hexpm_mcp --transport stdio < handshake.jsonl
{"id":1,"jsonrpc":"2.0","result":{"capabilities":{...},"serverInfo":{"name":"hexpm-mcp","version":"0.3.4"}}}
{"id":2,"jsonrpc":"2.0","result":{"tools":[...]}}

$ hexpm_mcp --transport bogus ; echo $?
error: --transport must be one of: stdio, http
2

Also confirmed: no-arg defaulting to stdio, --help, and exit 0 with clean
stderr and no crash dump on stdin close.

Plain mix release still produces _build/prod/rel/hexpm_mcp and no
burrito_out/, so the Fly image is unaffected.

format, compile --warnings-as-errors, credo --strict, dialyzer, and 120 tests
all pass against the published cheer 0.2.1 and tinfoil 0.2.21.

Upstream issues opened from this work

All closed and released.

  • cheer: #131 Cheer.argv/0, #132 Cheer.parse/3, #133 server cookbook entry, #134 public exit_code/1, #140 parse_only
  • tinfoil: #103 attach mode, #104 NIF scan false positive, #105 Zig inference shadowed by alias Tinfoil.Burrito, #106 incompatible elixir/otp pair, #107 BURRITO_TARGET invisible to mix.exs, #114 trigger: :workflow_call

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.
@joshrotenberg

Copy link
Copy Markdown
Owner Author

Picked up the rest of the cheer work.

parse_only() from joshrotenberg/cheer#142 removes the last workaround in this
branch: the run/2 wrapper that existed only because a leaf command without one
warns, and CI compiles with --warnings-as-errors. The command tree now
declares its intent and carries no handler at all.

command "hexpm_mcp" do
  about "MCP server for hex.pm and hexdocs.pm"
  version @version
  parse_only()
  ...
end

All five cheer issues opened from this work are closed upstream (#131, #132,
#133, #134, #140). Re-verified against the local checkout: compile
--warnings-as-errors clean, credo --strict clean, 105 tests pass, and the
stdio handshake still returns a correct initialize result and tool list.

Still waiting on a cheer release. Its mix.exs is on 0.2.0 with no new tag, so
~> 0.2.1 has nothing to resolve to and CI here cannot fetch deps yet.

…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.
@joshrotenberg
joshrotenberg force-pushed the feat/burrito-tinfoil-packaging branch from 48a5856 to d5821c5 Compare July 24, 2026 22:57
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.
@joshrotenberg
joshrotenberg marked this pull request as ready for review July 24, 2026 23:46
@joshrotenberg
joshrotenberg merged commit 683267e into main Jul 24, 2026
2 checks passed
@joshrotenberg
joshrotenberg deleted the feat/burrito-tinfoil-packaging branch July 24, 2026 23:48
joshrotenberg added a commit that referenced this pull request Jul 24, 2026
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.
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