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 request already carries its JSON-RPC id; 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 result member, run through MCP::Client::Protocol::normalize-result so a legacy result arrives with the resultType a 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::Protocol carrying the wire code and verbatim data; the era probe reads both. Break with X::MCP::Client::Timeout when the budget runs out, with X::MCP::Client::ServerGone when the peer dies, with X::MCP::Client::TransportClosed after close, and with X::MCP::Client::Cancelled when $cancelled is 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 request differs 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;
}

MCP::Client v0.5.0

talk to an MCP server, in either protocol era

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

MCP::Server:ver<0.6.0+>:auth<zef:apogee>JSON::Fast:ver<0.19+>:auth<cpan:TIMOTIMO>Cro::HTTP:ver<0.8.11+>:auth<zef:cro>:api<0>MIME::Base64:ver<1.2.5+>:auth<zef:raku-community-modules>

Test Dependencies

Provides

  • MCP::Client
  • MCP::Client::Cache
  • MCP::Client::Correlator
  • MCP::Client::Exceptions
  • MCP::Client::Leases
  • MCP::Client::Leases::Table
  • MCP::Client::Policy
  • MCP::Client::Policy::Commands
  • MCP::Client::Policy::Floor
  • MCP::Client::Policy::Grants
  • MCP::Client::Policy::Rules
  • MCP::Client::Protocol
  • MCP::Client::Reasons
  • MCP::Client::Registry
  • MCP::Client::SSE
  • MCP::Client::Transport
  • MCP::Client::Transport::HTTP
  • MCP::Client::Transport::Stdio
  • MCP::Client::UnknownKeys

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.