=head1 NAME AnyEvent::MCP::Client::JSONL - MCP byte streams and stdio subprocesses =head1 SYNOPSIS my $client = AnyEvent::MCP::Client::JSONL->new ( command => [$program, @args], env => { PERL_ANYEVENT_MCP_MULTIBROKER_PROTOCOL => "stdio" }, ); =head1 DESCRIPTION Use this transport when the MCP server is a subprocess or an already connected byte stream. It reads one JSON-RPC message per line, so stdout from a child server must contain protocol messages only. Diagnostic output belongs on stderr. A single stray banner on stdout can break the connection. An argument array executes the program directly; a command string runs through a shell. Prefer the array form when no shell syntax is needed. The process starts on the first connection, not when the client object is constructed. =head1 METHODS =head2 $client = AnyEvent::MCP::Client::JSONL->new (%config) C is an argument array for direct execution, or a string explicitly run through C. The child inherits stderr. C contains child environment overrides. No command runs until connecting. C contains AnyEvent::Handle options, excluding the filehandle and read/error/EOF callbacks managed here. C bounds an incoming JSON line, defaulting to 16 MiB. C is available for application identification but does not load transport defaults. The library does not infer a subprocess command or environment. =head2 $client->attach ($fh_in, $fh_out) Uses existing handles instead of a command. C<$fh_out> defaults to C<$fh_in> for a bidirectional socket. The client takes ownership. Attach before sending requests. Closing an attached connection requires attaching new handles before reconnecting. Closing a subprocess connection sends SIGTERM and keeps its child watcher alive until the child has been reaped. It does not restart an outstanding call. =cut package AnyEvent::MCP::Client::JSONL; use common::sense; use parent "AnyEvent::MCP::Client"; use AnyEvent::Handle (); use AnyEvent::Util (); use JSON::XS (); use Scalar::Util qw(weaken); sub attach { my ($self, $fh_in, $fh_out) = @_; $self->close if $self->{read_handle}; weaken $self; $self->{read_handle} = AnyEvent::Handle->new ( %{$self->{handle} || {}}, fh => $fh_in, rbuf_max => $self->{max_size} || 16 * 1024 * 1024, on_read => undef, on_error => sub { $self->close ($_[2]) if $self }, on_eof => sub { $self->close ("MCP stream closed") if $self }, ); $self->{write_handle} = $fh_out ? AnyEvent::Handle->new ( %{$self->{handle} || {}}, fh => $fh_out, on_read => undef, on_error => sub { $self->close ($_[2]) if $self }, on_eof => sub { $self->close ("MCP output closed") if $self }, ) : $self->{read_handle}; $self->_read; } sub _open { my ($self, $done) = @_; return $done->(1, undef) if $self->{read_handle}; return $done->(0, { transport => 1, message => "No command or attached stream" }) unless $self->{command}; my $ok = eval { local %ENV = (%ENV, %{$self->{env} || {}}); my ($parent, $child) = AnyEvent::Util::portable_socketpair; die "Cannot create MCP socketpair: $!\n" unless defined $parent && defined $child; my $pid; my $cv = AnyEvent::Util::run_cmd ( ref $self->{command} eq "ARRAY" ? $self->{command} : ["/bin/sh", "-c", $self->{command}], "<" => $child, ">" => $child, '$$' => \$pid, close_all => 1, ); close $child; $self->{pid} = $pid; $self->attach ($parent); weaken $self; $cv->cb (sub { my $status = $_[0]->recv; if ($self && $self->{pid} == $pid) { delete $self->{pid}; delete $self->{child}; $self->close ("MCP child exited (status $status)"); } }); $self->{child} = $cv; 1 }; $done->($ok ? (1, undef) : (0, { transport => 1, message => "$@" })); } sub _read { my ($self) = @_; weaken $self; $self->{read_handle}->push_read (line => sub { my ($handle, $line) = @_; return unless $self; my $ok = eval { $self->_receive (JSON::XS::decode_json $line); 1 }; return $self->close ("$@") unless $ok; $self->_read if $self->{read_handle} && !$handle->destroyed; }); } sub _send { my ($self, $message, $done) = @_; my $ok = eval { my $handle = $self->{write_handle} or die "MCP stream is not connected\n"; $handle->push_write (JSON::XS::encode_json ($message) . "\n"); 1 }; $done->($ok ? (1, undef) : (0, { transport => 1, message => "$@" })); } sub _shutdown { my ($self) = @_; my $read = delete $self->{read_handle}; my $write = delete $self->{write_handle}; $read->destroy if $read; $write->destroy if $write && $write != $read; if (my $pid = delete $self->{pid}) { kill TERM => $pid; } delete $self->{child}; } 1