NAME
Net::Async::MCP::Server - Async MCP server base class
VERSION
version 0.003
SYNOPSIS
use IO::Async::Loop;
use Net::Async::MCP::Server;
use Net::Async::MCP::Server::Transport::Stdio;
my $loop = IO::Async::Loop->new;
my $server = Net::Async::MCP::Server->new(
name => 'my-server',
);
$loop->add($server);
Net::Async::MCP::Server::Transport::Stdio->new(
server => $server,
)->handle_requests;
DESCRIPTION
Net::Async::MCP::Server is an asynchronous MCP (Model Context Protocol) server built on IO::Async. By default it implements the stateless, per-request metadata variant of MCP 2026-07-28: every request carries io.modelcontextprotocol/* keys in _meta, every successful result carries resultType and an io.modelcontextprotocol/serverInfo entry in _meta, and the initialize handshake is rejected.
Set legacy_compat => 1 only when constructing a server used with Net::Async::MCP::Server::Transport::Stdio for a MCP 2025-11-25 client. In that opt-in path, the Stdio transport accepts the legacy lifecycle and emits legacy result shapes; 2026 requests continue to use the stateless wire format. Net::Async::MCP::Server::Transport::StreamableHTTP remains 2026-only.
supported_versions
Returns ArrayRef of protocol version strings this server accepts.
legacy_compat
Returns true when the optional MCP 2025-11-25 compatibility path is enabled for Net::Async::MCP::Server::Transport::Stdio. It is false by default.
name
Returns the server name exposed via MCP protocol.
server_info
Returns a hashref with name and version keys for the MCP protocol.
server_capabilities
Returns the server capabilities hashref.
discover
Handles the server/discover RPC. Returns a Future that resolves to a hashref of the server's supported protocol versions, capabilities, and caching hints: { resultType, supportedVersions, capabilities, ttlMs, cacheScope }. The _meta.io.modelcontextprotocol/serverInfo entry seen on the wire is not part of this result — it is added afterwards, by _jsonrpc_response, to every response handle sends.
tools
Returns ArrayRef of registered tool definitions (internal storage shape).
find_tool
my $tool = $server->find_tool('echo');
Returns the registered tool definition hashref (internal storage shape, inputSchema normalised) for $name, or an empty list when no such tool is registered. Transports use this to consult a tool's schema; the returned hash is the registry's own, so treat it as read-only.
register_tool(%tool)
Registers a tool. Expects name, description, inputSchema (or the legacy input_schema alias), and an optional code reference. The inputSchema MUST be present — tools without a parameter schema are non-conformant under MCP 2026-07-28.
Croaks when a tool with the same name is already registered; a registered tool is never silently replaced or duplicated.
The optional title, annotations and outputSchema keys are passed through unchanged into the tool's wire shape in tools/list (see "list_tools") when defined, and omitted from it otherwise.
list_tools
Returns a Future that resolves to an ArrayRef of tool definitions in wire shape (name, description, inputSchema, not input_schema, and the optional title, annotations, outputSchema when set).
call_tool($name, $arguments)
Called when a client invokes a tool. Subclasses should override to provide tool implementations. Returns a Future that resolves to the tool's return value, which the dispatch layer then wraps:
A plain value (string, number, hashref, arrayref) is wrapped in a single
textcontent block inside acompleteresult.A hashref that already carries a
resultType(e.g.input_requiredfor MRTR) is passed through unchanged.A hashref carrying a
contentarrayref and/or astructuredContentkey (but noresultType) is passed through verbatim, wrapped in acompleteresult. This lets a tool return its own content blocks — images, multiple text blocks, structured output — instead of the single stringified text block the default wrapping produces. When onlystructuredContentis given, acontentarray with one text block holding its JSON encoding is added.
To signal that a tool needs more input from the client, return a hashref shaped like:
{
resultType => 'input_required',
inputRequests => { ... },
requestState => '...', # optional, opaque
}
resources
Returns ArrayRef of registered resource definitions (internal storage shape).
register_resource(%resource)
Registers a resource. Expects uri and name, an optional mimeType, description and title, and a code reference invoked with the resource URI when the resource is read.
Croaks when a resource with the same uri is already registered.
list_resources
Returns a Future that resolves to an ArrayRef of resource definitions in wire shape (uri, name, and the optional mimeType, description, title).
read_resource($uri)
Called when a client reads a resource. Looks up $uri in the resource registry and invokes its code reference with the URI. Returns a Future that resolves to a hashref of the shape { contents => [ { uri, mimeType?, text } ] }.
A resource whose code returns a hashref already carrying a contents arrayref is passed through verbatim; any other return value is stringified into a single text content block tagged with the resource's mimeType.
prompts
Returns ArrayRef of registered prompt definitions (internal storage shape).
register_prompt(%prompt)
Registers a prompt. Expects name, optional title, description and an arguments ArrayRef, and a code reference invoked with the request arguments when the prompt is fetched. An arguments entry with a true required must be present in prompts/get or the request fails with -32602 before code runs.
Croaks when a prompt with the same name is already registered.
list_prompts
Returns a Future that resolves to an ArrayRef of prompt definitions in wire shape (name, arguments, and the optional title, description).
get_prompt($name, $arguments)
Called when a client fetches a prompt. Looks up $name in the prompt registry and invokes its code reference with $arguments. Returns a Future that resolves to a hashref of the shape { description?, messages => [ { role, content => { type => 'text', text } } ] }.
A prompt whose code returns a hashref already carrying a messages arrayref is passed through verbatim; any other return value is stringified into a single user text message.
handle($request)
The JSON-RPC dispatch entry point. $request is a single decoded JSON-RPC request or notification hashref (jsonrpc, method, and optionally id and params); transports are responsible for decoding wire JSON before calling this and for encoding whatever it returns.
Despite dispatching to the async sub methods discover, list_tools, call_tool, list_resources, read_resource, list_prompts and get_prompt, handle itself is synchronous: it calls ->get on the Future each of those returns and hands back a plain result, never a Future.
If $request has a defined id key (0 or '' counts; only an absent key or a JSON null id does not), handle returns a JSON-RPC response or error hashref, e.g.:
{ jsonrpc => '2.0', id => 1, result => { ... } }
{ jsonrpc => '2.0', id => 1, error => { code => -32602, message => '...' } }
Otherwise $request is treated as a notification: _meta is never validated, nothing is dispatched, and handle returns undef (transports skip writing a response when they get undef back).
Malformed input is rejected before any of that, regardless of id: a $request that is not a hashref returns -32700; a hashref with no defined method returns -32600.
For every request that does carry an id, the per-request _meta contract, read from params._meta, is validated before dispatch:
io.modelcontextprotocol/protocolVersionmust be a defined, non-empty string listed insupported_versions. Missing or empty returns-32602; present but unsupported returns-32022, with the server's supported list and the requested version in the error'sdata.io.modelcontextprotocol/clientCapabilitiesmust be present as a key in_meta— its value is not otherwise inspected, soundef/JSONnullsatisfies the check. Missing the key returns-32602.
Once _meta validates, method is mapped as:
server/discoverdispatches todiscover.tools/listdispatches tolist_tools.tools/calllooks upparams.namein the tool registry (unregistered name:-32602) and dispatches tocall_toolwithparams.arguments; see thecall_toolmethod above for how a tool's return value or exception becomes the result.resources/listdispatches tolist_resources.resources/readlooks upparams.uriin the resource registry (unregistered URI:-32602) and dispatches toread_resourcewith the URI.prompts/listdispatches tolist_prompts.prompts/getlooks upparams.namein the prompt registry (unregistered name:-32602), checks that every registered argument with a truerequiredis defined inparams.arguments(missing:-32602), and dispatches toget_promptwithparams.nameandparams.arguments.Anything else returns
-32601, including legacy handshake method names such asinitialize,pingornotifications/initializedby default. Whenlegacy_compatis true and the request arrived from the Stdio transport without 2026 request metadata, the 2025-11-25 forms of those methods plustools/listandtools/calluse the legacy response shapes instead. The same names sent as notifications (noid) otherwise never reach this dispatch — they fall into the no-op/undefpath above like any other notification.
The full set of JSON-RPC error codes handle itself can produce: -32700, -32600, -32601, -32602, -32022. -32020 (header/body mismatch) is raised by Net::Async::MCP::Server::Transport::StreamableHTTP, not by handle.
SEE ALSO
Net::Async::MCP::Server::Transport::Stdio, Net::Async::MCP::Server::Transport::StreamableHTTP, IO::Async::Notifier, Future::AsyncAwait.
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-net-async-mcp-server/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.