Stdio
NAME
MCP::Client::Transport::Stdio - JSON-RPC over a child process's stdin and stdout
SYNOPSIS
use MCP::Client;
use MCP::Client::Transport::Stdio;
my $wire = MCP::Client::Transport::Stdio.new(
command => 'my-mcp-server',
args => ['--stdio'],
env => { MY_API_KEY => %*ENV<MY_API_KEY> // '' },
on-stderr => -> Str:D $line { note "[server] $line" },
on-log => -> Str:D $message { note "[stdio] $message" },
);
my $mcp = MCP::Client.new(transport => $wire);
say $mcp.call-tool('echo', { text => 'hi' })<content>[0]<text>;
$mcp.close;
DESCRIPTION
The transport almost every MCP server in the wild speaks: the server is a child process, one JSON-RPC message per line goes down its stdin, one JSON-RPC message per line comes back up its stdout, and stderr is a log nobody was supposed to have to read until something went wrong.
That single shared pipe is the whole design problem. Answers come back in whichever order the server finished them, notifications arrive between them belonging to whichever request provoked them, and a server that dies takes every outstanding request with it. The correlator (MCP::Client::Correlator) owns the first two; this class owns the third, and is written on the assumption that the server will misbehave.
There is no shell
$command is executed directly through Proc::Async. Nothing is ever passed
to sh or cmd.exe, so there is no quoting to get wrong, no glob expansion,
no $(...), and no injection: an argument containing ; rm -rf / is one
argument containing that text. Two consequences worth knowing:
No
PATHextension magic on Windows.npxisnpx.cmd, and a.cmdshim cannot be executed without a shell ā name the real executable, or the.cmdexplicitly and accept that Windows will resolve it as a batch file only if the OS can.NUL bytes are refused. A
\0in the command, an argument, or an environment entry cannot survive the syscall boundary intact, so it is rejected up front withX::MCP::Client::SpawnFailedrather than silently truncating what the child receives.
The environment
By default the child inherits this process's environment and %env is layered
on top of it, because a server that cannot see PATH or HOME usually cannot
start. Pass :!inherit-env for a server that should see %env and nothing
else:
# PATH, HOME and friends, plus the token
MCP::Client::Transport::Stdio.new(:command<srv>, env => { TOKEN => 'abc' });
# exactly one variable, and nothing else
MCP::Client::Transport::Stdio.new(:command<srv>, env => { TOKEN => 'abc' }, :!inherit-env);
Failure, and how it reaches you
Every way this can go wrong has a typed exception and a bounded wait; nothing here can hang:
The command will not start ā a missing executable, a working directory that does not exist, a
.cmdshim.X::MCP::Client::SpawnFailed, raised on the first request (the spawn result arrives in milliseconds, so "the first request" is not a wait) and on every request after it.The child dies with work outstanding ā
X::MCP::Client::ServerGone, carrying the exit code, the signal, and the tail of the child's stderr, which for a misconfigured server is the entire diagnosis. Everything outstanding fails together, and requests made afterwards fail immediately rather than waiting for a server that is not coming back.A request outlives its budget ā
X::MCP::Client::Timeout. The connection survives: the request stops being tracked, a late answer for it is dropped, and the next request goes out normally.closeāX::MCP::Client::TransportClosedfor anything still in flight, and for anything attempted afterwards.A line that is not JSON-RPC ā dropped, reported through
:&on-log, and otherwise ignored. Servers print banners, deprecation warnings and progress bars to stdout; one of them must never kill a session.
{
CATCH {
when X::MCP::Client::ServerGone {
note "server died (exit {.exit-code // '?'}): {.stderr-tail}";
}
when X::MCP::Client::Timeout { note 'still connected, just slow' }
}
$mcp.call-tool('slow', {});
}
Notifications
A stdio server has one channel for everything, so a notification arrives with no indication of which request it belongs to. This transport delivers each one exactly once, to whichever of these applies first:
the
&on-notificationof the single outstanding request, when exactly one request is in flight ā the common case, and the only one where the attribution is knowable;otherwise the first
&on-notificationthe transport was ever handed, which forMCP::Clientis a connection-wide sink and therefore the right place for a notification that cannot be attributed.
Everything is also published on notifications, a Supply a direct user of
the transport can tap without going through a client:
$wire.notifications.tap: -> %note { say "server said %note<method>" };
Server-initiated requests
A legacy-era server may turn round and ask the client something (sampling,
elicitation, roots). Those arrive as JSON-RPC requests on the same pipe, and
this transport answers every one of them with -32601 METHOD_NOT_FOUND: the
2026-07-28 era does that work through the multi round-trip loop in
MCP::Client, which is where the caller's hooks live, and inventing a second
path for it here would put the same decision in two places. The refusal is
reported through :&on-log.
Shutting down
close is deliberate and bounded: stdin is closed (which is how a well-behaved
MCP server is asked to stop), the child is given :$kill-grace seconds to
exit, and is then SIGKILLed. Whatever is still outstanding fails with
X::MCP::Client::TransportClosed. It is idempotent, and safe on a connection
whose child died an hour ago.