NAME

Net::Async::MCP::Transport::Stdio - Stdio MCP transport via subprocess JSON-RPC

VERSION

version 0.004

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.

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 this client does not answer and drops.

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.

Fails immediately if the subprocess has already exited.

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.

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;

Sends SIGTERM to the subprocess and returns a Future that resolves when the process exits. If the process has already exited, returns an immediately resolved Future.

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.

SEE ALSO

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.