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 PATH extension magic on Windows. npx is npx.cmd, and a .cmd shim cannot be executed without a shell — name the real executable, or the .cmd explicitly and accept that Windows will resolve it as a batch file only if the OS can.

  • NUL bytes are refused. A \0 in the command, an argument, or an environment entry cannot survive the syscall boundary intact, so it is rejected up front with X::MCP::Client::SpawnFailed rather 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 .cmd shim. 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::TransportClosed for 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-notification of 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-notification the transport was ever handed, which for MCP::Client is 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.

MCP::Client v0.5.0

talk to an MCP server, in either protocol era

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

MCP::Server:ver<0.6.0+>:auth<zef:apogee>JSON::Fast:ver<0.19+>:auth<cpan:TIMOTIMO>Cro::HTTP:ver<0.8.11+>:auth<zef:cro>:api<0>MIME::Base64:ver<1.2.5+>:auth<zef:raku-community-modules>

Test Dependencies

Provides

  • MCP::Client
  • MCP::Client::Cache
  • MCP::Client::Correlator
  • MCP::Client::Exceptions
  • MCP::Client::Leases
  • MCP::Client::Leases::Table
  • MCP::Client::Policy
  • MCP::Client::Policy::Commands
  • MCP::Client::Policy::Floor
  • MCP::Client::Policy::Grants
  • MCP::Client::Policy::Rules
  • MCP::Client::Protocol
  • MCP::Client::Reasons
  • MCP::Client::Registry
  • MCP::Client::SSE
  • MCP::Client::Transport
  • MCP::Client::Transport::HTTP
  • MCP::Client::Transport::Stdio
  • MCP::Client::UnknownKeys

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.