NAME
Net::Async::MCP::Transport::Stdio - Stdio MCP transport via subprocess JSON-RPC
VERSION
version 0.007
SYNOPSIS
# Usually created automatically by Net::Async::MCP
use IO::Async::Loop;
use Net::Async::MCP;
my $loop = IO::Async::Loop->new;
my $mcp = Net::Async::MCP->new(
command => ['npx', '@anthropic/mcp-server-web-search'],
);
$loop->add($mcp);
DESCRIPTION
Net::Async::MCP::Transport::Stdio communicates with an external MCP server process via stdin/stdout using newline-delimited JSON-RPC 2.0. The subprocess is managed as an IO::Async::Process child notifier.
This transport works with any MCP server that supports the stdio transport, regardless of implementation language (Perl, Node.js, Python, Go, etc.).
Requests are matched to responses by their JSON-RPC id field. Each pending request is represented by a Future that resolves when the matching response arrives. If the subprocess exits unexpectedly, all pending futures are failed with an error message including the exit code.
The subprocess speaks on its own as well as in answer: a line carrying no id is a server-initiated notification - notifications/progress and notifications/message during a long running tools/call above all - and is delivered to "on_notification" the moment it is read, rather than waiting for a response it is not part of.
This transport is selected automatically by Net::Async::MCP when constructed with a command argument.
env
my $transport = Net::Async::MCP::Transport::Stdio->new(
command => [ 'npx', '-y', '@modelcontextprotocol/server-github' ],
env => { GITHUB_TOKEN => $token, NODE_OPTIONS => undef },
);
Optional. A HashRef laid over this process's %ENV for the subprocess: a key with a value sets that variable, a key whose value is undef removes it. Everything else in %ENV is inherited as it stands. Without it the subprocess inherits %ENV unchanged.
cwd
Optional. The working directory the subprocess starts in. Without it the subprocess inherits this process's current directory. A directory that does not exist fails the start the same way a command that cannot be executed does - see "send_request".
Both env and cwd describe the process as it is started, which happens when the transport is added to a loop; configuring either afterwards changes nothing for a process that is already running.
timeout
my $transport = Net::Async::MCP::Transport::Stdio->new(
command => [ 'my-mcp-server' ],
timeout => 120,
);
Optional. The limit in seconds on how long a request waits for its answer. Unset by default, and then a request waits for as long as the subprocess lives: an MCP tools/call may legitimately run for minutes, and only a caller that knows its own upper bound can pick one. A request still unanswered when the limit runs out is cancelled - its pending entry dropped and notifications/cancelled written to the subprocess, as "send_request" describes for a cancel - and its Future fails with MCP stdio request METHOD timed out after Ns in category timeout, the category Net::Async::HTTP gives a timed out HTTP request.
As in the HTTP transport, 0 is a limit of zero seconds rather than "no limit": leave it undef to have none. The limit applies to every request sent after it is set, and each request keeps the one it was sent with.
on_stderr
my $transport = Net::Async::MCP::Transport::Stdio->new(
command => [ 'my-mcp-server' ],
on_stderr => sub {
my ( $transport, $line ) = @_;
warn "mcp: $line\n";
},
);
Invoked once for every line the subprocess writes to its stderr, with the line decoded from UTF-8 and without its line ending. The MCP stdio binding leaves stderr to the server for free-form logging, so nothing here treats a line as an error. Without a handler the lines are still read - a server whose stderr nobody drains stalls once the pipe is full - and dropped.
Either way the last 20 non-empty lines are kept, see "stderr_tail", and quoted in the failure of every request the subprocess leaves unanswered when it exits, because that is usually where it said why.
on_request
Invoked for every request the server sends to the client - a line with an id and a method but neither a result nor an error - with the decoded JSON-RPC request. A handler that returns a HashRef holding a result or an error has it written back as the response to that request; anything else leaves the request unanswered. Without a handler every such request is dropped.
Net::Async::MCP sets this to a handler of its own: a server of a handshake-based revision may ask its client for a ping, and the client answers it there. What to answer is the protocol's to decide, and writing the answer is this transport's.
send_request
my $future = $transport->send_request($method, \%params);
Encodes a JSON-RPC request and writes it as a newline-terminated JSON line to the subprocess stdin. Returns a Future that resolves to the result value when the matching response is read from stdout, or fails with an error if the server returns a JSON-RPC error or the process exits.
Everything the subprocess writes before that response is read as it arrives. A line carrying no id is a notification the server sent of its own accord and is delivered to "on_notification" the moment it lands, so the notifications/progress and notifications/message of a long running call reach a caller while the call is still running, rather than after it. A line with an id but neither a result nor an error is a server-initiated request, which goes to "on_request" and is dropped where that does not answer it.
A JSON-RPC error fails the Future with more than its message. Future's failure convention is ( $message, $category, @details ), so the failure reads ( "MCP error $code: $message", 'mcp', $error ): in scalar context ->failure is the message and nothing has changed, and in list context the raw JSON-RPC error object comes with it.
my ( $message, $category, $error ) = $future->failure;
if (($category // '') eq 'mcp') {
my $code = $error->{code}; # -32601, -32602, ...
my $supported = $error->{data}{supported};
}
The mcp category marks a genuine JSON-RPC error from the server and nothing else. The failures this transport raises on its own - a request sent after the subprocess has exited, a request still pending when it does, and a request still pending when the transport leaves its loop - carry their message alone, so a caller that finds no category knows there is no server error object behind it. The one exception is a request that ran out of "timeout", which fails in category timeout.
An error that is no JSON-RPC error object - a string, a number, an array, a hash without a code - still fails the request, with MCP stdio error response: TEXT carrying the value or its JSON, and no category, since there is no error object to hand over. A "error": null next to a result is a success, as JSON-RPC 1.0 wrote it; without one it fails with MCP stdio invalid response: a null error and no result. A server answering in a shape it should not never leaves the request waiting.
Fails immediately if the subprocess has already exited. A request still pending when it exits fails with MCP server process exited (code N) - or (signal N) - followed by the last lines the subprocess wrote to its stderr, joined with | , where it wrote any. A subprocess that never ran - a command that cannot be executed, a "cwd" that does not exist - fails every request with MCP server failed to start: cannot run PROGRAM: REASON instead, rather than leaving it to wait for an answer that cannot come.
Removing the transport from its IO::Async::Loop while the request is still pending fails it with MCP server process left the loop before the request was answered. The process leaves the loop with the transport that owns it, so no answer can reach this client any more, and a Future that would wait for one forever is better ended with the reason it will not arrive. The message differs from the one a pending "close" is failed with, so a caller holding both can tell which loss it is looking at.
Cancelling the returned Future cancels the request: the pending entry is dropped, so a response that still arrives for it is discarded, and a notifications/cancelled notification naming that request in requestId is written to the subprocess stdin. No reason is sent, since ->cancel carries no argument to put there. Nothing is written if the future is already done or failed, or if the subprocess has exited: a cancellation never writes into a dead pipe.
A request that runs into "timeout" is cancelled the same way, with a reason saying so, and its Future fails in category timeout rather than being cancelled.
This is the stdio form of cancellation. On Streamable HTTP a request is cancelled by closing its response stream instead, so Net::Async::MCP::Transport::HTTP sends no such notification. Note that an MCP server is free to ignore the notification and finish the request anyway; cancelling only guarantees that this client stops caring about the answer.
Accepts the same optional trailing name/value options as the other transports, header_params among them, and ignores all but one: they describe how a request is mirrored into HTTP headers, of which a JSON-RPC line on stdin has none. The exception is resolve_on_notification, which names the notification a server answers a request with in place of a response - how a subscriptions/listen is answered. This transport cannot settle a request from a notification: the answer would arrive at "on_notification" like any other, leaving the request waiting for a response the server is not going to send. A request carrying that option therefore fails on the spot rather than hang. See "send_request" in Net::Async::MCP::Transport::HTTP.
send_notification
my $future = $transport->send_notification($method, \%params);
Encodes a JSON-RPC notification (no id field, no response expected) and writes it to the subprocess stdin. Returns an immediately resolved Future.
Fails immediately if the subprocess has already exited.
close
my $future = $transport->close;
Shuts the subprocess down in the order the MCP stdio binding gives, and returns a Future that resolves when it has exited: its stdin is closed first, which a server reads as its client being done; if it is still running 2 seconds later it is sent SIGTERM, and 2 seconds after that SIGKILL. A server that exits on the end of its input - as servers are expected to - is therefore never signalled at all. If the process has already exited, returns an immediately resolved Future; calling it again while the shutdown is still under way returns the same Future as the first call.
Removing the transport from its IO::Async::Loop while that Future is still pending fails it with MCP server process left the loop before it exited. The process leaves the loop with the transport that owns it, so its exit can no longer be observed from here, and a Future that would wait for it forever is better ended with the reason it will not arrive. Wait for the close before removing the transport where the exit itself matters.
is_alive
my $alive = $transport->is_alive;
Returns true while the subprocess can still carry requests, and false once it has exited or "close" has been called. Used by "ping" in Net::Async::MCP for its transport-level liveness check.
mirrors_header_params
my $mirrors = $transport->mirrors_header_params;
Always false: a JSON-RPC line on stdin has no headers to mirror tool arguments annotated with x-mcp-header into, so "call_tool" in Net::Async::MCP resolves none and never fetches a tool list to do it.
on_notification
my $transport = Net::Async::MCP::Transport::Stdio->new(
command => [ 'my-mcp-server' ],
on_notification => sub {
my ( $transport, $notification ) = @_;
warn "$notification->{method}\n";
},
);
Invoked for every server-initiated notification read from the subprocess stdout, with the decoded JSON-RPC notification as it stood on the wire - method and, where the notification has any, params. The notifications/progress of a running tools/call is what a caller usually waits for here, and it is only worth anything while the call is still running, which is why it is an event and not part of the Future the call resolves with.
Set through new or configure like any IO::Async::Notifier event, or by a subclass implementing a method of this name. Notifications are dropped while nothing handles them: a server sends them whether or not this client asked, and there is nothing sensible to do with one no caller wants.
stderr_tail
my @lines = $transport->stderr_tail;
The last 20 non-empty lines the subprocess wrote to its stderr, oldest first, decoded from UTF-8 and without their line endings. Kept whether or not an "on_stderr" handler is set, and still there after the subprocess has exited, which is when they are usually wanted.
SEE ALSO
Net::Async::MCP - Main client module that uses this transport
Net::Async::MCP::Transport::InProcess - Alternative transport for in-process Perl servers
IO::Async::Process - Subprocess management used internally
IO::Async::Notifier - Base class
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-net-async-mcp/issues.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <getty@cpan.org>
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.