NAME

Net::Async::MCP::Server - Async MCP server base class

VERSION

version 0.002

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 text content block inside a complete result.

  • A hashref that already carries a resultType (e.g. input_required for MRTR) is passed through unchanged.

  • A hashref carrying a content arrayref and/or a structuredContent key (but no resultType) is passed through verbatim, wrapped in a complete result. 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 only structuredContent is given, a content array 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/protocolVersion must be a defined, non-empty string listed in supported_versions. Missing or empty returns -32602; present but unsupported returns -32022, with the server's supported list and the requested version in the error's data.

  • io.modelcontextprotocol/clientCapabilities must be present as a key in _meta — its value is not otherwise inspected, so undef/JSON null satisfies the check. Missing the key returns -32602.

Once _meta validates, method is mapped as:

  • server/discover dispatches to discover.

  • tools/list dispatches to list_tools.

  • tools/call looks up params.name in the tool registry (unregistered name: -32602) and dispatches to call_tool with params.arguments; see the call_tool method above for how a tool's return value or exception becomes the result.

  • resources/list dispatches to list_resources.

  • resources/read looks up params.uri in the resource registry (unregistered URI: -32602) and dispatches to read_resource with the URI.

  • prompts/list dispatches to list_prompts.

  • prompts/get looks up params.name in the prompt registry (unregistered name: -32602), checks that every registered argument with a true required is defined in params.arguments (missing: -32602), and dispatches to get_prompt with params.name and params.arguments.

  • Anything else returns -32601, including legacy handshake method names such as initialize, ping or notifications/initialized by default. When legacy_compat is true and the request arrived from the Stdio transport without 2026 request metadata, the 2025-11-25 forms of those methods plus tools/list and tools/call use the legacy response shapes instead. The same names sent as notifications (no id) otherwise never reach this dispatch — they fall into the no-op/undef path 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.