NAME
Net::Async::MCP - Async MCP (Model Context Protocol) client for IO::Async
VERSION
version 0.007
SYNOPSIS
use IO::Async::Loop;
use Net::Async::MCP;
use Future::AsyncAwait;
my $loop = IO::Async::Loop->new;
# In-process transport (Perl MCP::Server in same process)
use MCP::Server;
my $server = MCP::Server->new(name => 'MyServer');
$server->tool(
name => 'echo',
description => 'Echo text',
input_schema => {
type => 'object',
properties => { message => { type => 'string' } },
required => ['message'],
},
code => sub { return "Echo: $_[1]->{message}" },
);
my $mcp = Net::Async::MCP->new(server => $server);
$loop->add($mcp);
# Stdio transport (external MCP server subprocess)
my $mcp_stdio = Net::Async::MCP->new(
command => ['npx', '@anthropic/mcp-server-web-search'],
);
$loop->add($mcp_stdio);
# HTTP transport (remote MCP server)
my $mcp_http = Net::Async::MCP->new(
url => 'https://example.com/mcp',
headers => { Authorization => "Bearer $token" },
);
$loop->add($mcp_http);
# All transports share the same async API:
async sub main {
await $mcp->initialize;
my $tools = await $mcp->list_tools;
# [{name => 'echo', description => '...', inputSchema => {...}}]
my $result = await $mcp->call_tool('echo', { message => 'Hello' });
# {content => [{type => 'text', text => 'Echo: Hello'}], isError => \0}
await $mcp->shutdown;
}
main()->get;
DESCRIPTION
Net::Async::MCP is an asynchronous client for the MCP (Model Context Protocol) built on IO::Async. It connects to MCP servers via pluggable transports:
InProcess - Direct calls to an MCP::Server instance in the same process. See Net::Async::MCP::Transport::InProcess.
Stdio - Subprocess communication over stdin/stdout using newline-delimited JSON-RPC. Works with any MCP server implementation (Perl, Node.js, Python, etc.). See Net::Async::MCP::Transport::Stdio.
HTTP - Streamable HTTP transport for remote MCP servers. Supports both JSON and SSE responses. See Net::Async::MCP::Transport::HTTP.
All methods return Future objects and work with Future::AsyncAwait. Call "initialize" first before using any other MCP methods. The client speaks both eras of the protocol: the modern revision 2026-07-28, whose handshake is a single server/discover and whose requests carry the client's protocol version, capabilities and info in _meta, and the legacy revisions 2025-11-25 back to 2024-11-05 with their initialize handshake, which is what servers built on today's SDKs speak. Unless told otherwise through "protocol_version", "initialize" finds out which one the server speaks.
Cancelling the Future of a request cancels the request itself - the transport drops it and, over Stdio, tells the server - on perl 5.24 and later. On older perls Future::AsyncAwait does not hand a cancel on to the future an async sub is waiting on: the caller's future is cancelled, but the request stays pending in the transport until its answer, its timeout or the end of the connection.
protocol_version
my $version = $mcp->protocol_version;
Returns (or via configure/constructor argument protocol_version sets) the MCP protocol revision this client speaks on the wire, such as '2026-07-28'. After "initialize" it is the revision the handshake agreed on. Defaults to the newest revision this client builds the requests of.
Naming one is a pin, and decides which handshake "initialize" performs. Left unset, the client speaks either era and finds out which one the server speaks - see "initialize". Pinned to one of the legacy revisions this client speaks - 2025-11-25, 2025-06-18, 2025-03-26 or 2024-11-05 - it goes straight to the initialize handshake and asks for that revision, without probing first. Pinned to anything else, 2026-07-28 above all, it speaks the modern revision only: a server/discover carrying that version, and no falling back to initialize if the server refuses it.
In the modern revision it is sent on every request inside _meta. A server that does not speak this revision answers with UNSUPPORTED_PROTOCOL_VERSION (-32022) and names the ones it does. Where one of those is a modern revision this client speaks too - one whose request shapes it builds, which today is exactly one - the request goes out again in it, and this attribute keeps it, so every following request carries the agreed revision from the start.
Which revisions those are, and which of them is the default, is this distribution's own to say. An installed MCP::Server has no part in it: its PROTOCOL_VERSION is the revision that library implements, and taking that for the default would put a new version string on requests still built in the old revision the moment the library learns a newer one - trading a clear "unsupported protocol version" for a confusing "method not found".
Nothing beyond that one retry: a refusal that offers no usable revision, and a second refusal after the switch, both reach the caller as the server's own error with its code and data, because the revisions it named are in there and in nothing this client could put in its place.
is_legacy
await $mcp->initialize;
say 'legacy server, ', $mcp->protocol_version if $mcp->is_legacy;
True once "initialize" has settled on a legacy revision - one with the initialize handshake - and false before that and on a modern connection. The era is a property of the server, so it is decided by the handshake once and kept for as long as this client lives.
What differs on a legacy connection: requests carry no io.modelcontextprotocol/* keys in _meta (a _meta the caller passes travels as it is), the input_required round trip and the renegotiation of a refused protocol version are modern-only and do not happen, "subscriptions_listen" fails, and a ping the server sends is answered while every other request it sends is refused with -32601.
Over HTTP a legacy connection is a session: see "Legacy sessions" in Net::Async::MCP::Transport::HTTP for the headers it carries. A server may end the session at any time, which it says by answering 404; the request that ran into it is then sent once more in a new session, started with a new initialize and notifications/initialized as the specification asks. Requests that run into the same ended session share one new session, and requests made while it is being started wait for it. A request whose new session the server ends straight away as well fails with MCP METHOD: the server ended the new session as well (...) in category mcp_session_expired.
discover_timeout
my $mcp = Net::Async::MCP->new(
command => [ 'npx', '-y', 'some-mcp-server' ],
discover_timeout => 30,
);
Returns (or via configure/constructor argument discover_timeout sets) how many seconds the server/discover probe of "initialize" waits for an answer from a stdio server before taking it to be legacy and falling back to the initialize handshake. Defaults to 10. Set it to 0 to wait for as long as it takes.
The trade-off is real and has no right answer: a legacy server may answer an unknown request with nothing at all, and a modern server that is slow to start - an npx that first has to fetch its package - looks exactly the same until it answers. Too short a limit takes a slow modern server for a legacy one, and its initialize is then refused; too long a limit makes every start of a silent legacy server wait that long. A server whose era is known is better pinned through "protocol_version", which skips the probe altogether.
Only the stdio transport waits on it. Over HTTP every request is answered with a status, and the InProcess transport answers on the spot.
A "timeout" limits the probe as well, being a limit on every request, and over stdio its running out counts the same as this one: no answer, so the server is taken to be legacy. Whichever of the two is shorter decides, and a discover_timeout of 0 leaves the probe to "timeout" alone.
instructions
my $text = $mcp->instructions;
The free-form usage instructions for a model that the server sent with its handshake - instructions of the server/discover or the initialize result - or undef where it sent none. Only available after "initialize".
client_capabilities
my $caps = $mcp->client_capabilities;
$mcp->configure(client_capabilities => { sampling => {} });
Returns (or via configure/constructor argument client_capabilities sets) the HashRef of client capabilities sent on every request inside _meta. Defaults to {}, an empty declaration, which is what a client that never touches this attribute keeps sending.
Setting this is a promise, not a hint. A conforming server may not send an inputRequest for a capability the client did not declare, so the empty default is precisely what keeps a server from asking this client for anything it cannot do. Declaring sampling, elicitation or roots here tells the server it may ask, and what answers it is "on_input_request" that gives, so declare a capability only once that handler serves it. This client holds both ends of the promise: an input request for a capability that is not declared here is refused as a server violation rather than passed on to the handler.
headers
my $headers = $mcp->headers;
$mcp->configure(headers => { Authorization => "Bearer $token" });
Returns (or via configure/constructor argument headers sets) a HashRef of extra headers sent with every HTTP request, which is how a server behind OAuth is reached: nothing else in this client sets an Authorization. Configuring them after the client has joined a loop works too, so a token can be rotated on a live client.
They cannot take over a header the MCP binding derives from the request body - see "new" in Net::Async::MCP::Transport::HTTP for why that is a rejection rather than an override. Only the HTTP transport sends headers at all; the InProcess and Stdio transports ignore this.
timeout
my $timeout = $mcp->timeout;
Returns (or via configure/constructor argument timeout sets) the wall-clock limit in seconds on a single request. Unset by default, because an MCP tools/call may legitimately run for minutes and a default would break such a setup. Note that 0 is a limit of zero seconds rather than "no limit".
Over HTTP it limits each request the transport sends, and a request that runs into it fails with Timed out in category timeout, as Net::Async::HTTP fails it; "stall_timeout" is the one there that guards against a hung server without a limit of this kind. Over stdio it is the only guard against a server that lives but never answers: without it such a request waits for as long as the subprocess runs. A stdio request that runs into it is cancelled - the server is sent notifications/cancelled for it - and fails with MCP stdio request METHOD timed out after Ns, in category timeout as well, so one check serves both transports. See "timeout" in Net::Async::MCP::Transport::Stdio.
Either way the limit is per JSON-RPC request: a call that takes input_required round trips or a renegotiated version sends several, and each of them gets the whole limit. The InProcess transport ignores it.
stall_timeout
my $stall_timeout = $mcp->stall_timeout;
Returns (or via configure/constructor argument stall_timeout sets) how many seconds an HTTP request may go without a single byte moving before it is given up on. Set it to 0 to switch it off. Undef here means it was never configured and Net::Async::MCP::Transport::HTTP's default of 60 seconds applies. HTTP transport only.
on_notification
my $mcp = Net::Async::MCP->new(
url => 'https://example.com/mcp',
on_notification => sub {
my ( $mcp, $notification ) = @_;
warn "$notification->{method}\n";
},
);
Invoked for every server-initiated notification the transport reads, 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 long tools/call is the usual reason to want one, and it is only worth anything while that call is still running, which is why it is an event and not part of the Future the call resolves with.
Set it through new or configure like any other event, or implement a method of this name in a subclass; configuring it on a client that is already in a loop reaches the transport it built. The first argument is this client, the same as "on_input_request" and every other event here gets, even though it is the transport that receives the notification and starts the call.
A handler set directly on a transport object rather than here is that transport's own event and is called with the transport - see "on_notification" in Net::Async::MCP::Transport::HTTP and "on_notification" in Net::Async::MCP::Transport::Stdio.
The HTTP and Stdio transports both deliver what their server sends, and a handler set here reaches whichever of the two this client built. What reaches them differs: the HTTP transport reads notifications off the response stream of a request it is running, while the Stdio transport reads whatever the subprocess writes, whether a request is in flight or not. The InProcess transport has nothing a notification could arrive over - it is a direct call and its return value - so setting this on an in-process client is silently without effect.
on_subscription_end
my $mcp = Net::Async::MCP->new(
url => 'https://example.com/mcp',
on_subscription_end => sub {
my ( $mcp, $subscription_id ) = @_;
},
);
Invoked when the stream a subscription runs on ends on its own, with the subscription id as its second argument - the same id "subscriptions_listen" handed back and "subscriptions_stop" takes. The first argument is this client, the same as "on_notification" and every other event here gets, even though it is the transport that watches the stream and starts the call.
A subscription is answered by its acknowledgement, and the Future of "subscriptions_listen" is settled there and then; the stream then runs for as long as the subscription does. So the only way a subscription can come to the caller's attention again is its end - and there are two kinds. An end the caller caused itself, through "subscriptions_stop", happens because it asked for it and is not reported. An end that comes from the server's side - the server closing the stream, the connection failing underneath it, a gateway giving up on it - reaches a caller holding nothing but an already-settled Future, and that is the end this event reports, the moment the stream ends. A caller waiting on "subscriptions_stop" for false, to learn the same thing, would learn it only when it thought to ask.
Set it through new or configure like any other event, or implement a method of this name in a subclass; configuring it on a client that is already in a loop reaches the transport it built. A handler set directly on a transport object rather than here is that transport's own event and is called with the transport - see "on_subscription_end" in Net::Async::MCP::Transport::HTTP.
Only Net::Async::MCP::Transport::HTTP can fire it: it is the only transport with a stream a subscription runs on, and "subscriptions_listen" says as much. Setting this on a Stdio or InProcess client is therefore silently without effect.
env
my $mcp = Net::Async::MCP->new(
command => [ 'npx', '-y', '@modelcontextprotocol/server-github' ],
env => { GITHUB_PERSONAL_ACCESS_TOKEN => $token },
);
A HashRef laid over %ENV for the server subprocess: a key with a value sets that variable, a key whose value is undef removes it. Stdio transport only - see "env" in Net::Async::MCP::Transport::Stdio.
cwd
The working directory the server subprocess starts in. Stdio transport only - see "cwd" in Net::Async::MCP::Transport::Stdio.
on_stderr
my $mcp = Net::Async::MCP->new(
command => [ 'my-mcp-server' ],
on_stderr => sub {
my ( $mcp, $line ) = @_;
warn "mcp: $line\n";
},
);
Invoked for every line the server subprocess writes to its stderr, decoded and without its line ending, with this client as the first argument. Stderr is the server's free-form log, never an error; the last 20 lines of it are quoted in the failure of every request the subprocess leaves unanswered when it exits, whether or not this is set. Stdio transport only - see "on_stderr" in Net::Async::MCP::Transport::Stdio.
on_input_request
my $mcp = Net::Async::MCP->new(
server => $server,
client_capabilities => { elicitation => {} },
on_input_request => sub {
my ( $mcp, $method, $params ) = @_;
# $method is 'elicitation/create', 'sampling/createMessage', ...
return { action => 'accept', content => { ok => \1 } };
},
);
Invoked for every input request a server embeds in a result, and returns the response to it - either the response structure itself, or a Future that will resolve with one, which is what lets a handler go and ask a human. The $params are the request's own, whatever the asked-for method takes: a message and requestedSchema for elicitation/create, the messages for sampling/createMessage, and so on.
One handler serves every kind of input request rather than one attribute per capability, because the set of methods a server may ask for is the specification's to grow, and a caller branching on $method keeps working when it does.
Set it through new or configure, or implement a method of this name in a subclass - it is an ordinary IO::Async::Notifier event, so both work. Handlers are called one after another, in the order of the request keys, so a handler that asks a user two questions asks them one at a time.
Its return value goes on the wire as it is, under the key the server gave the request, and must be a HashRef; { action => 'accept', content => {...} } is what a server expects for an elicitation/create. See "Input required results" for the round trip this handler is one half of.
Input required results
A server that needs something from the client before it can finish answers with an input_required result instead of a final one - resultType set to input_required, optionally inputRequests naming what it wants, and optionally an opaque requestState. SEP-2322 made this the way sampling, elicitation and roots reach a client: not as requests the server sends, but as a result the client answers by asking the same question again.
Every method of this client walks that round trip itself and returns the final result, so a caller never sees an input_required:
inputRequestsare handed one by one to "on_input_request", and its answers travel back asinputResponsesunder the very keys the server used.requestStateis mirrored back untouched. It is sealed and bound by the server, so this client never parses, inspects or edits it, and a result that carries none is retried without one.A result with a
requestStateand noinputRequestsis a server asking for nothing but the call again, and is retried straight away without troubling the handler.
Four things fail the Future instead, loudly, because each of them would otherwise leave the caller with a result that looks final and is not:
An
inputRequestsentry for a capability that "client_capabilities" does not declare. A conforming server may not ask for one, so this is named as the server violation it is rather than passed to the handler.An
inputRequestswith no "on_input_request" to answer it.More than eight
input_requiredresults in a row on one request. A server may legitimately ask twice, but one that never arrives at a result has to be given up on somewhere.An
input_requiredresult carrying neither of the two, which leaves nothing to answer and nothing to send back.
server_info
my $info = $mcp->server_info;
Returns the server info hashref from the handshake - result._meta of a modern server/discover, serverInfo of a legacy initialize. Contains at minimum name and version keys. Only available after "initialize" has been called.
server_capabilities
my $caps = $mcp->server_capabilities;
Returns the server capabilities hashref from the handshake response, modern or legacy. Only available after "initialize" has been called.
initialize
my $result = await $mcp->initialize;
Performs the MCP handshake. Must be called before any other MCP method. This client speaks both eras of the protocol, and which handshake this is depends on the server:
Modern - revision
2026-07-28. The handshake is a singleserver/discoverrequest carrying the client's protocol version and "client_capabilities" in_meta, and the server answers with its capabilities and, inresult._meta, its server info. Nonotifications/initializedfollows it: SEP-2575 removed theinitialize/initializedpair, and the Streamable HTTP binding of this revision defines no client-to-server notifications at all.Legacy - revisions
2025-11-25,2025-06-18,2025-03-26and2024-11-05, which every third-party server speaks today. The handshake is aninitializerequest carryingprotocolVersion,capabilitiesandclientInfo, answered with the server'sprotocolVersion,capabilities,serverInfoand maybeinstructions, and followed by anotifications/initialized. A server that answers with a revision this client does not speak fails the handshake with a message naming it and the ones this client speaks, and the transport is left as it is for "shutdown". The legacy handshake declares no client capabilities, whatever "client_capabilities" says: a legacy server asks for sampling, elicitation or roots with requests of its own, which this client does not serve.
Which one, is decided by "protocol_version". Pinned to a legacy revision, the legacy handshake asks for it; pinned to anything else, the modern handshake is the only one tried. Left unset, the client finds out the way the specification gives for a client of both eras: it probes with a server/discover of the newest modern revision, and
an answer means the server is modern, and that is the handshake;
one of the errors only a modern server sends (
-32020,-32021,-32022) means the server is modern too, and its error is what the call fails with - except a-32022whose list of supported versions holds only legacy revisions, of which the newest one this client speaks too is then asked for withinitialize;any other error, or no answer within "discover_timeout" - or "timeout", where that is shorter - on a stdio server, means the server is legacy, and the
initializehandshake follows with the newest legacy revision. Should that fail as well, the failure names what happened to both.
Over HTTP an answer is anything the server sent back: a JSON-RPC error in the body, whatever the status, is read as above - a 400 carrying -32022 is a modern server - and a status without one, the 400 or 404 a legacy server answers a request before initialize with, means legacy. What is not an answer does not fall back: a probe the subprocess of a stdio server did not live through fails with the exit, and one over a connection that could not be made - refused, an unknown host, a timeout - fails with that. A probe answer that arrives after the fallback has begun is dropped.
Returns the raw result hashref of the handshake: capabilities plus _meta containing io.modelcontextprotocol/serverInfo for a modern server, the InitializeResult for a legacy one. Also populates "server_info", "server_capabilities", "instructions", "protocol_version" and "is_legacy".
list_tools
my $tools = await $mcp->list_tools;
Returns an ArrayRef of tool definition hashrefs from the MCP server. Each hashref contains name, description, and inputSchema keys.
Also caches, per tool, which of its arguments are annotated with x-mcp-header in the input schema, which "call_tool" needs to build the Mcp-Param-{Name} headers of the HTTP binding.
Paginated tool lists are walked to the end: as long as the server answers with a nextCursor, the next page is requested with that cursor and its tools appended, so both the returned list and the cache cover every page. Nothing short of the whole list is ever returned - a server that keeps handing back a cursor it already gave out, or that offers more than 100 pages, fails the returned Future instead, because a quietly truncated list is the same bug this walk is here to fix.
call_tool
my $result = await $mcp->call_tool($name, \%arguments);
Calls a named tool on the MCP server with the given arguments hashref. Returns a hashref with content (ArrayRef of content blocks) and isError (boolean).
A tool may annotate arguments in its input schema with x-mcp-header, which over the HTTP binding have to be mirrored into Mcp-Param-{Name} headers; a conforming server rejects a tools/call that passes such an argument without its header, and equally one that carries a header for an argument it did not pass. This method resolves them from the tool's input schema, which means it needs the schema: on a transport that mirrors headers it fetches "list_tools" once for a tool it has not seen, and keeps using the cached schemas afterwards. If that fetch fails the call is still sent, without headers, leaving the decision to the server.
Transports that do not mirror headers - InProcess and Stdio - resolve nothing and never fetch a tool list on their own.
list_prompts
my $prompts = await $mcp->list_prompts;
Returns an ArrayRef of prompt definition hashrefs from the MCP server. Paginated results are followed to the end and merged, on the same terms as "list_tools".
get_prompt
my $result = await $mcp->get_prompt($name, \%arguments);
Retrieves a named prompt from the MCP server, optionally passing arguments. Returns the prompt result hashref.
list_resources
my $resources = await $mcp->list_resources;
Returns an ArrayRef of resource definition hashrefs from the MCP server. Paginated results are followed to the end and merged, on the same terms as "list_tools".
read_resource
my $result = await $mcp->read_resource($uri);
Reads a resource by URI from the MCP server. Returns the resource content hashref.
subscriptions_listen
my $subscription = await $mcp->subscriptions_listen({ toolsListChanged => 1 });
my $id = $subscription->{_meta}{'io.modelcontextprotocol/subscriptionId'};
Opens a subscriptions/listen subscription on the MCP server, requesting server-initiated notifications. $notifications is a hashref mapping notification types to a truthy value (e.g. toolsListChanged, promptsListChanged, resourcesListChanged). The request carries the standard _meta like all client methods.
A server does not answer this request with a response. It opens a stream, writes notifications/subscriptions/acknowledged onto it, and then holds the stream open to carry the notifications that were subscribed to. This method returns that acknowledgement's params: the subscription id under _meta, and under notifications the types the server actually honoured, which need not be everything that was asked for. The notifications themselves arrive at "on_notification", like every other server-initiated notification, and the acknowledgement does not - it is this request's answer rather than something that happened while it waited.
Which notification answers the request is told to the transport rather than recognised by it, so that a transport executes what the protocol layer decided instead of reading method names and deciding for itself.
Ending a subscription is "subscriptions_stop". There is no request for it: closing the stream is what unsubscribes, and a server drops the subscription when its stream finishes.
Only Net::Async::MCP::Transport::HTTP has a stream to run this on. The InProcess transport refuses subscriptions/listen outright, and a server without notification support answers JSON-RPC error -32601 (METHOD_NOT_FOUND); either way the returned Future fails. The Stdio transport refuses it as well: MCP::Server >= 0.15 serves the method over stdio too, answering with a notifications/subscriptions/acknowledged notification where HTTP would answer with a response, and a transport that cannot settle a request from a notification says so rather than wait for an answer that will not come.
Subscriptions are the modern revision's. On a legacy connection - see "is_legacy" - this fails without sending anything: a legacy server sends its notifications unasked and has no subscriptions/listen to answer.
subscriptions_stop
my $stopped = await $mcp->subscriptions_stop($subscription_id);
Ends the subscription of that id, and resolves with true if there was one to end. The $subscription_id is the one "subscriptions_listen" handed back in $subscription->{_meta}{'io.modelcontextprotocol/subscriptionId'}.
Unsubscribing is closing the stream the subscription runs on. The current revision has no request that cancels a subscription and no acknowledgement of one, so nothing goes on the wire and the server drops the subscription when its stream finishes.
Resolves with false for an id no subscription is running under - one that was never opened, one already stopped, and one whose stream has ended on its own, which are not told apart. It is also false on a transport that cannot subscribe at all, since neither the InProcess nor the Stdio transport has a stream a subscription could run on.
That makes this the way to ask whether a subscription is still running. The prompt way is "on_subscription_end", which fires the moment a stream ends on its own - the server closing it, the connection failing - because the Future of "subscriptions_listen" was settled by the acknowledgement long before and cannot report it. This method is what a caller that missed the event, or did not set one, falls back on. See "stop_subscription" in Net::Async::MCP::Transport::HTTP.
ping
await $mcp->ping;
Performs a transport-level liveness check and returns 1. The current MCP revision moved liveness to the transport layer and has no client-addressable JSON-RPC ping request, so no request goes on the wire; sending one would fail against MCP::Server >= 0.15.
Instead the transport's is_alive is consulted, and the returned Future fails if the transport can no longer carry requests: for Net::Async::MCP::Transport::Stdio that means the subprocess has exited, while the InProcess and HTTP transports have no connection state and are always alive.
shutdown
await $mcp->shutdown;
Cleanly shuts down the MCP connection. For the Stdio transport this closes the subprocess's stdin, sends SIGTERM if it is still running 2 seconds later and SIGKILL 2 seconds after that, and waits for it to exit - see "close" in Net::Async::MCP::Transport::Stdio. For the HTTP transport it ends every subscription still running, that being the one thing it holds which outlives the request that opened it, and on a legacy connection with a session sends the DELETE that ends the session; a refusal of it other than 405 or 404 fails the returned Future - see "close" in Net::Async::MCP::Transport::HTTP. For the InProcess transport it is a no-op: a direct call and its return value leave nothing behind.
SEE ALSO
Net::Async::MCP::Transport::InProcess - In-process transport for Perl MCP servers
Net::Async::MCP::Transport::Stdio - Subprocess transport via stdin/stdout
Net::Async::MCP::Transport::HTTP - Streamable HTTP transport for remote servers
IO::Async::Notifier - Base class
Future::AsyncAwait - Async/await syntax used with this module
https://modelcontextprotocol.io - MCP specification
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.