NAME

Net::Async::MCP - Async MCP (Model Context Protocol) client for IO::Async

VERSION

version 0.004

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:

All methods return Future objects and work with Future::AsyncAwait. Call "initialize" first before using any other MCP methods. It performs the handshake with a single server/discover request of the revision this client speaks (see "protocol_version"; the legacy initialize request no longer exists in it), carrying the client's protocol version, capabilities, and info in _meta.

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'. Sent on every request inside _meta. Defaults to the newest revision this client builds the requests of.

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 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.

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 HTTP request. Unset by default, because an MCP tools/call may legitimately run for minutes and a default would break such a setup; "stall_timeout" is the one that guards against a hung server. Note that 0 is a limit of zero seconds rather than "no limit". HTTP transport only.

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.

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:

  • inputRequests are handed one by one to "on_input_request", and its answers travel back as inputResponses under the very keys the server used.

  • requestState is 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 requestState and no inputRequests is 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 inputRequests entry 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 inputRequests with no "on_input_request" to answer it.

  • More than eight input_required results 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_required result 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 MCP server/discover handshake response. 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 MCP server/discover handshake response. 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. The current MCP revision has replaced the old initialize request with server/discover, which this method sends, carrying the client's protocol version and "client_capabilities" in _meta. The server responds with its capabilities and, in result._meta, its server info.

That single request is the whole handshake: no notifications/initialized follows it. SEP-2575 removed the initialize/initialized pair along with the initialize request itself, and the Streamable HTTP binding of this revision defines no client-to-server notifications at all, so the follow-up would have been an extra POST that no conforming server acts on.

Returns the raw result hashref (capabilities key, plus _meta containing io.modelcontextprotocol/serverInfo). Also populates the "server_info" and "server_capabilities" accessors.

initialize remains a compatibility alias for the handshake; there is no separate discover entry point.

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_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 sends SIGTERM to the subprocess and waits for it to exit. 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 sends nothing. For the InProcess transport it is a no-op: a direct call and its return value leave nothing behind.

SEE ALSO

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.