NAME

Net::Async::MCP::Server::Transport::Stdio - Stdio transport for Net::Async::MCP::Server

VERSION

version 0.003

SYNOPSIS

use Net::Async::MCP::Server::Transport::Stdio;

my $transport = Net::Async::MCP::Server::Transport::Stdio->new(
    server => $server,
);
$transport->handle_requests;

DESCRIPTION

Stdio transport for MCP server using newline-delimited JSON-RPC over stdin/stdout.

Each line is a JSON-RPC 2.0 message. Requests and notifications are read from stdin, responses written to stdout. The transport is stateless — there is no session, no handshake, and no shared state between requests. The protocol version, client capabilities, and identity are carried inline in each request's _meta field (see Net::Async::MCP::Server).

This transport is suitable for MCP clients that communicate via standard input/output, such as command-line AI tools.

PROTOCOL

Each JSON-RPC message is a single line terminated by a newline character. Request format:

{"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}

Response format:

{"jsonrpc": "2.0", "id": 1, "result": {...}}

Error format:

{"jsonrpc": "2.0", "id": 1, "error": {"code": -32022, "message": "Unsupported protocol version", "data": {"supported": ["2026-07-28"]}}}

Notifications from the client are accepted and discarded (handle() returns undef, nothing is written to stdout).

server

my $server = $transport->server;

Returns the associated MCP server instance.

read_handle

my $fh = $transport->read_handle;

The filehandle JSON-RPC requests are read from, \*STDIN unless a read_handle was passed to the constructor or to configure. Overriding it lets the transport be driven from a pipe — which is how it is tested, without spawning a process. It must be a real OS handle: "handle_requests" reads it through an IO::Async::Stream, so an in-memory filehandle will not do.

write_handle

my $fh = $transport->write_handle;

The filehandle responses are written to, \*STDOUT unless a write_handle was passed to the constructor or to configure. Unlike "read_handle" this one has two write paths, picked once on entry to "handle_requests":

  • A handle with an OS file descriptor (fileno >= 0: \*STDOUT, a pipe, a socket) is written through an IO::Async::Stream, so a full pipe never blocks the event loop; the data waits in the stream's buffer until the client drains it. Registering the handle with the loop puts it into non-blocking mode (O_NONBLOCK), and that flag lives on the open file description, not on the Perl handle: it stays set after "handle_requests" returns and is shared with every process holding the same descriptor, such as the parent shell's terminal. Anything else printing to that handle afterwards can see EAGAIN.

  • A handle without one (an in-memory filehandle, fileno of -1) cannot be registered with a loop and keeps the plain, blocking print.

Either way responses leave in the order they were produced. Anything still sitting in the handle's own PerlIO buffer when "handle_requests" starts is flushed first, so it cannot overtake or trail the stream's writes. On entry autoflush is also turned on for it. That is a change to a handle the caller may own and keep using, but a buffered response is a response the client never sees in time.

handle_requests

$transport->handle_requests;

Enters the main loop, reading JSON-RPC requests from "read_handle" and writing responses to "write_handle". This method blocks until the read handle is closed (EOF), which is the spec's graceful-shutdown signal. Per MCP 2026-07-28 the transport is stateless: each line is an independent request and the server carries no session across calls.

It blocks the caller, but it does not block the event loop. Reading happens in an IO::Async::Stream registered as a child of this transport, and the wait for EOF is a wait on a Future, so every other notifier in the same loop — timers, other transports — keeps running while requests are being read. If the transport is not a member of a loop yet, one is created and the transport added to it.

The read handle is left open on EOF; the caller owns STDIN and may still want it once the transport has shut down.

Responses are written as they are produced, not when a buffer happens to fill (see "write_handle" for the two write paths and the O_NONBLOCK side effect). On EOF the stream's write buffer is flushed before this method returns, so a response queued when stdin closed is not lost; if the client is not draining its end of the pipe, that wait is a wait on a Future, which leaves the rest of the loop running, but it does not end until the client reads. A flush that fails because the client went away is ignored.

SEE ALSO

Net::Async::MCP::Server, IO::Async::Notifier.

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-net-async-mcp-server/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.