I have been building a personal agent as an MCP server in Elixir. MCP is the protocol agents and editors use to reach external tools, and the common way to speak it is over the standard streams of a subprocess. Most of the app is ordinary work: a tool registry, JSON Schema validation on the arguments, a handful of providers. The transport is where it got interesting, because it is the one place where my usual OTP reflexes give the wrong answer.
A server that answered once
The symptom was the kind I dislike most. I wire the server into my MCP client,
it shows up in the tool list, it answers a request, and then it is simply
gone. No error frame, and no crash report either: the runtime wrote one to
standard error, where my client throws it away. The spec allows exactly that,
since a client may ignore the server’s stderr entirely. A server that was
there and isn’t.
One request did it:
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":{"nested":"boom"}}}
name is supposed to be a string. Here it’s an object. My code handed it
straight to the registry, got nothing back, and built a helpful error message
the way you’d build any error message:
case Registry.fetch(params["name"]) do
{:ok, tool} -> call_tool(id, tool, params["arguments"])
:error -> error(id, -32_602, "unknown tool: #{params["name"]}")
end
Interpolation calls String.Chars, and maps don’t implement it:
** (Protocol.UndefinedError) protocol String.Chars not implemented for Map.
This protocol is implemented for: Atom, BitString, Date, DateTime, …
That raise happened outside the rescue I had around tool invocation, so it took down the process. The process in question was the one holding standard input.
Nothing to restart
In a Phoenix app this is a non-event. A raise inside a controller kills the request process, the client gets a 500, and the next request is served as if nothing happened. The restart boundary absorbs it. That’s the whole deal we sign up for with OTP: write the happy path, let the supervisor deal with the rest.
The stdio transport doesn’t offer that deal. The MCP
specification
describes the arrangement plainly: the client launches the server as a
subprocess, and the server reads JSON-RPC messages from stdin and writes
JSON-RPC messages to stdout. The process holds the connection itself, with
nothing in between. Killing it doesn’t lose a request, it loses the stream.
Putting a supervisor above the loop changes nothing useful. A fresh loop would read from a stream whose previous reader died mid-session, against a client that has no idea any of this happened. In my app the loop isn’t supervised at all. It’s the mix task’s own process, and putting a tree above it would be decoration.
Recovery does exist, one level up. The spec says that if the server process
exits unexpectedly the client should restart it. But that restarts the entire
OS subprocess, and with a handshake-based revision like the 2025-11-25 one I
implement, it means redoing initialize and dropping everything in flight.
That is the coarsest recovery available, and from the user’s chair it looks
exactly like what I saw: a server that vanished.
So the reflex I’d apply anywhere else in Elixir has nowhere to land here.
Guarding shapes, and why that isn’t enough
The obvious first fix is to guard the shape:
def handle(%{"method" => "tools/call", "id" => id, "params" => params}) when is_map(params) do
case params["name"] do
name when is_binary(name) ->
case Registry.fetch(name) do
{:ok, tool} -> call_tool(id, tool, params["arguments"])
:error -> error(id, -32_602, "unknown tool: #{name}")
end
other ->
error(id, -32_602, "invalid params: name must be a string, got #{inspect(other)}")
end
end
Two things changed: is_binary(name) before the registry ever sees the value,
and inspect/1 rather than raw interpolation for anything that came off the
wire. The unknown tool message still interpolates, and that’s fine now,
because nothing reaches that branch unless the guard has already proven the
name is a binary. I kept both changes, and I’d write them again.
They also don’t solve the problem. They fix the one shape I happened to see. A
params that’s a string, a params that’s a list, a number where the tool
name belongs, an arguments that’s a list. Each of those is another clause,
and I’d be enumerating hostile JSON forever with the identical failure waiting
behind whichever form I forgot. The bug wasn’t the missing guard. The bug was
that a missed guard was fatal.
A rescue inside the loop
The fix that holds is structural, and it’s short:
defp safe_handle(request) do
Server.handle(request)
rescue
error -> {:reply, internal_error(request_id(request), error)}
end
Placement carries all the meaning. The rescue lives inside the loop, wrapped
around the single call that touches untrusted data. Wrapping it around the
loop instead would catch the exception on its way out and end the session just
as thoroughly. Here, any exception at all, anticipated or not, becomes a
-32603 frame, and loop/2 goes straight back to reading the next line:
defp loop(input, output) do
case IO.read(input, :line) do
:eof ->
:ok
{:error, _reason} ->
:ok
line ->
line |> String.trim() |> dispatch(output)
loop(input, output)
end
end

I want to be clear that I don’t write code like this by default. “No
speculative error handling” is one of the rules I’m least willing to bend, and
a bare rescue around a call is normally a smell: it swallows information and
lets real bugs hide. What makes this one different is the failure domain. It
genuinely is “anything at all”, because the input is arbitrary JSON produced
by another program, and the cost of being wrong a single time is the whole
session.
Future me would delete it during a cleanup, so the reasoning lives next to the code, pointing back at the constraint I set for the project on day one:
# This is the loop's last line of defense, not speculative error handling: no
# shape guard we add to Server.handle/1 can ever be exhaustive, and a single
# exception escaping here would kill the whole stdio process — the exact
# "server appears then vanishes" failure the brief calls the project's most
# important constraint.
Keeping stdout clean
The same constraint has a second face, and this one bites before you write any
tools at all. The spec is normative about it: the server must not write
anything to its stdout that is not a valid MCP message, messages are
delimited by newlines and must not contain embedded ones, and stderr is
explicitly yours for logging.
Which means the default Elixir setup is wrong out of the box, because the
default logger handler writes to standard output. One line in config.exs
settles it:
config :logger, :default_handler, config: [type: :standard_error]
That settles the framework’s own output. My debugging output is another
matter. A single IO.puts left over from an afternoon of poking around at a
provider is enough to corrupt a frame, and the failure surfaces as a
client-side parse error far away from the line that caused it. Vigilance is a
poor control for that, so I promoted it to a lint rule. Credo already ships
the check, it’s just off by default:
{Credo.Check.Refactor.IoPuts, []},
And because that logger line looks like noise in a diff and would survive exactly one careless tidy-up, a test logs on purpose and asserts the protocol stream stayed pure:
Logger.error("this must not appear on the protocol stream")
:ok = Stdio.run(input: input, output: output)
{_in, written} = StringIO.contents(output)
for line <- String.split(written, "\n", trim: true) do
assert {:ok, _decoded} = Jason.decode(line), "non-JSON line on stdout: #{inspect(line)}"
end
Testing a loop that owns the standard streams
None of this would be testable if the transport reached for the real streams,
so the devices are injectable. run/1 opens a StringIO on each side and
hands them to the loop:
defp run(lines) do
{:ok, input} = StringIO.open(Enum.join(lines, "\n") <> "\n")
{:ok, output} = StringIO.open("")
:ok = Stdio.run(input: input, output: output)
{_in, written} = StringIO.contents(output)
written |> String.split("\n", trim: true) |> Enum.map(&Jason.decode!/1)
end
With that in place the original bug becomes a table entry rather than a story. Every malformed frame I could think of is paired with a valid follow-up request in the same stream:
{"params.name is a nested object",
~s({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":{"nested":"boom"}}}),
:invalid_params},
The follow-up is the actual assertion. An error frame for the bad line proves
very little on its own, since a process can emit one and die immediately
after. What proves the loop survived is that request id 2 got its answer:
Finished in 0.06 seconds (0.00s async, 0.06s sync)
Result: 24 passed
What I took from it
The question I now ask of a process is what it owns that a restart can’t rebuild. A GenServer holding derived state can crash all day; the supervisor gives you back something equivalent. A process holding the only handle to a stream another program opened is a different animal, and supervision has nothing to hand you.
That question is also why the next part of this app is worth writing about. The agent drives a headless browser through a Node sidecar over a Port, which means an OS process that outlives a crash unless somebody reclaims it. Same question, with an operating system process at stake instead of a stream.
Have comments or want to discuss this topic?
Send an email to ~bounga/bounga.org-discuss@lists.sr.ht