Transport
NAME
MCP::Client::Transport - the seam between MCP::Client and a wire
DESCRIPTION
MCP::Client knows the protocol; a transport knows how to get a message to a
server and an answer back. Everything era-specific, everything about caching,
multi round-trips, capabilities and typed results lives in the client. A
transport does four things and nothing else: send a request and promise an
answer, send a notification, close, and say whether it is still usable.
That division is what makes the client testable. A scripted double that does
this role is indistinguishable from a spawned subprocess as far as
MCP::Client is concerned, so the era probe, the multi round-trip loop and
the whole typed API can be exercised without a server anywhere in sight.
The contract
Doing this role means signing up to more than four method names. The parts a
transport must get right, and that MCP::Client relies on absolutely:
The client owns the ids. Every message handed to
requestalready carries its JSON-RPCid; a transport correlates on that id and must never renumber. The multi round-trip loop depends on it ā a retry is a new id by protocol requirement, and the client is the only party that knows a retry is happening.The promise carries the result, not the response. Keep it with the
resultmember, run throughMCP::Client::Protocol::normalize-resultso a legacy result arrives with theresultTypea modern one would have had. Never keep it with the whole JSON-RPC envelope.A JSON-RPC error breaks the promise. Break it with an
X::MCP::Client::Protocolcarrying the wirecodeand verbatimdata; the era probe reads both. Break withX::MCP::Client::Timeoutwhen the budget runs out, withX::MCP::Client::ServerGonewhen the peer dies, withX::MCP::Client::TransportClosedafterclose, and withX::MCP::Client::Cancelledwhen$cancelledis kept. The probe treats those four as fatal and everything else as evidence about the server's era, so a transport that invents its own exception types will have them read as "this server is ancient".Notifications go to
&on-notification, never to the promise. It is called with the parsed notification hash (method,params) and must never be allowed to break the read loop: wrap the call.Signatures match exactly. An implementation whose
requestdiffers by so much as a trait becomes a second multi candidate rather than an override, and the role's stub ā which dies ā stays reachable. Copy the signatures below verbatim.
EXAMPLES
The whole role, as a null transport that answers everything with an empty complete result:
use MCP::Client::Transport;
use MCP::Client::Protocol;
use MCP::Client::Exceptions;
class NullTransport does MCP::Client::Transport {
has Bool $!open = True;
method request(%msg, :&on-notification, Promise :$cancelled, :$timeout --> Promise) {
return Promise.broken(X::MCP::Client::TransportClosed.new) unless $!open;
Promise.kept(normalize-result({}));
}
method notify(%msg --> Nil) { }
method close(--> Nil) { $!open = False }
method alive(--> Bool) { $!open }
}
my $client = MCP::Client.new(transport => NullTransport.new);
A sketch of the real thing, showing where each part of the contract lands:
method request(%msg, :&on-notification, Promise :$cancelled, :$timeout --> Promise) {
my $answer = $!correlator.register(
%msg<id>, method => %msg<method>, timeout => ($timeout // $!default-timeout),
);
$!lock.protect: { %!listeners{%msg<id>} = &on-notification if &on-notification };
$!proc.print(format-message(%msg));
with $cancelled {
.then({ $!correlator.cancel(%msg<id>) });
}
$answer;
}
# ... on the read loop, for every inbound line:
my %in = parse-inbound($line);
if %in<kind> eq 'response' && (%in<error>:exists) {
$!correlator.reject(%in<id>, X::MCP::Client::Protocol.new(
detail => %in<error><message> // 'server error',
code => %in<error><code>,
data => %in<error><data>,
));
}
elsif %in<kind> eq 'response' {
$!correlator.resolve(%in<id>, normalize-result(%in<result>));
}
elsif %in<kind> eq 'notification' {
# Never let a subscriber take the read loop down with it.
try $_(%in) for self!listeners;
}