NAME

AnyEvent::MCP::Server::Simple - simple function interface for MCP servers

SYNOPSIS

use AnyEvent::MCP::Server::Simple;

mcp_config
   server_info   => {
      name    => "example",
      version => "0",
   },
   instructions => <<'EOF';
      Use echo to return the supplied text.
EOF

mcp_tool echo =>
   "Return the supplied text.",
   {
      type       => "object",
      properties => { text => { type => "string" } },
      required   => ["text"],
   },
   sub {
      my ($req, $args) = @_;

      $req->reply ({
         content => [{ type => "text", text => $args->{text} }],
      });
   };

mcp_start;

DESCRIPTION

For most MCP servers, registering a few tools and starting a transport is enough. This module does just that: call mcp_tool to register callbacks, optionally configure the server with mcp_config, then start it with mcp_start, mcp_stdio or mcp_http. Registration alone does not start a server or an event loop.

Human-readable descriptions and instructions are stripped of surrounding whitespace and common indentation when registered, so indented here-documents can be used naturally.

mcp_start, mcp_stdio and mcp_http are alternative blocking entry points for a self-contained server. They run AnyEvent until shutdown; you do not start another event loop. If your application already owns the server lifetime or needs several transports, use the object interfaces instead.

Simple uses AnyEvent::MCP::Server and a default Toolset internally; it is not a second server implementation. Applications needing custom dispatch, such as a multibroker, can use the Server API directly.

Start with "TUTORIAL" for a complete server and a first client call. For running the bundled servers without writing Perl, see AnyEvent::MCP::Intro.

TUTORIAL

This tutorial builds a small server, calls it, then adds options, permission checks and asynchronous work. You do not need to implement JSON-RPC, authentication or a transport. Simple supplies those parts; your program declares tools and handles their arguments.

For installation, connecting AI clients and configuring the bundled servers, see AnyEvent::MCP::Intro. The function reference follows this tutorial.

A complete first server

Save the following as echo-mcp:

#!/opt/bin/perl

use common::sense;
use AnyEvent::MCP::Server::Simple;

my %options = mcp_getoptions {
   name        => "echo-demo",
   usage       => "Usage: $0 [OPTION]...\n",
   description => "Return text supplied to the echo tool.",
};

@ARGV == 0
   or die "Unexpected arguments\n";

mcp_config
   server_info => {
      name    => "echo-demo",
      version => "1",
   },
   instructions => "Use echo to return the supplied text.";

mcp_tool echo =>
   "Return the supplied text.",
   {
      type       => "object",
      properties => {
         text => {
            type      => "string",
            maxLength => 4096,
         },
      },
      required => ["text"],
   },
   sub {
      my ($req, $args) = @_;

      my $text = $args->{text};
      defined $text and !ref $text and length $text <= 4096
         or die "text must be a string of at most 4096 characters\n";

      $req->reply ({
         content => [{ type => "text", text => $text }],
      });
   };

mcp_start %options;

Use your installed Perl's explicit path in the shebang, or invoke the file with perl as in the commands below.

mcp_getoptions parses the common server options and returns startup settings. mcp_config declares protocol metadata and instructions. mcp_tool registers a description, input schema and callback. mcp_start constructs the transport and runs AnyEvent until shutdown. Register everything before starting: code after mcp_start runs only when the server stops.

The input schema tells the client what to supply. The server does not validate arguments against JSON Schema, so the callback checks the value it actually uses. A missing text, array or object fails as a tool error. The example allows an empty string.

$req is an AnyEvent::MCP::Server::Request; $args is its argument hash reference. Replying finishes the request. Merely returning from the callback does not. You supply the MCP result, and the server supplies the JSON-RPC envelope.

Start and call the server

Inspect the common options with:

perl echo-mcp --help

For a client launching a local subprocess, configure:

perl /absolute/path/to/echo-mcp --stdio

The client must launch the process itself. Stdin and stdout carry MCP, so starting the program in a terminal and typing text at it is not a test of the echo tool. Print diagnostics to stderr, not stdout.

You can make a first call without an AI client. Save this as try-echo in the same directory as echo-mcp:

use common::sense;
use AnyEvent;
use AnyEvent::MCP::Client::JSONL;

my $client = AnyEvent::MCP::Client::JSONL->new (
   command => [$^X, "./echo-mcp", "--stdio"],
);

my $cv = AE::cv;

$client->call (echo => { text => "hello" }, sub {
   $cv->send (@_);
});

my ($success, $result) = $cv->recv;
$success
   or die "$result->{message}\n";
!$result->{isError}
   or die "$result->{content}[0]{text}\n";

print $result->{content}[0]{text}, "\n";

Run perl try-echo. It prints:

hello

The JSONL client starts the server, connects and negotiates the protocol on first use. $^X chooses the same Perl interpreter as the test client. The condition variable keeps the event loop running until the reply arrives. Keep $client alive while its operations are outstanding.

There are two success checks. $success means the JSON-RPC operation completed successfully; a tool can still return isError in that result. This client expects our echo tool's text result. A general client must handle other MCP content representations too.

To use HTTP, start the server in one terminal:

perl echo-mcp --bind :31742

and retrieve its URL in another:

perl echo-mcp --bind :31742 --print-url

Configure that URL in your MCP client and ask it to call echo with text hello. Both commands use instance echo-demo, unless you override it with --name. Repeat the same listener overrides when printing the URL. --print-url prints the address and exits; it does not start a server.

For the small Perl client above, replace the JSONL import and constructor with:

use AnyEvent::MCP::Client::HTTP;

my $client = AnyEvent::MCP::Client::HTTP->new (
   url => $ARGV[0],
);

Pass the printed URL as its first command-line argument. The rest of the call and result handling stays the same.

mcp_start supplies named HTTP settings and a token derived from the private global secret. Stdio needs no HTTP configuration. The advertised server_info describes the program; the startup name selects local settings and credentials. They need not be identical. See "CONFIGURING YOUR SETUP" in AnyEvent::MCP::Intro.

Add an application option

Suppose you want the server user to choose the maximum text length. Replace the original mcp_getoptions call with:

my $max_length = 4096;

my %options = mcp_getoptions (
   {
      name        => "echo-demo",
      usage       => "Usage: $0 [OPTION]...\n",
      description => "Return text supplied to the echo tool.",
      options     => [
         ["--max-length N", "Maximum text length in characters"],
      ],
   },
   "max-length=i" => \$max_length,
);

$max_length > 0
   or die "--max-length must be positive\n";

Use $max_length in both the schema's maxLength and the callback's length check. The server user can then start:

perl echo-mcp --stdio --max-length 1000

The option specification is passed to Getopt::Long. The options entries supply human-readable help; they do not register options. The later tool examples use the original 4096-character limit; use your chosen limit in them too if you keep this option. Common options such as --stdio, --bind and --help remain available.

If your server also takes positional arguments, validate the remaining @ARGV after mcp_getoptions. It handles --help and --print-url before that validation, so the user can retrieve an HTTP URL without supplying an application workdir or starting the application.

Settings affecting URL discovery, such as name and path, should be available to mcp_getoptions through its leading settings hash or an earlier mcp_config call. A setting supplied only after the parser exits for --print-url cannot affect that printed URL.

Choose a result representation

Our echo callback returns one text content item. Use reply when the representation matters: text, other content types, a larger result or a particular MCP result shape.

For an ordinary result object, you can replace the reply with:

$req->result ({ text => $text, length => length $text });

result returns that hash as structuredContent and also serializes it as JSON in one text content item. Clients consuming only text can still see the data, but it is deliberately duplicated. Wrap arrays or scalars in an object, or use reply to choose a different representation.

To report a tool failure explicitly:

$req->error ("The requested operation cannot be completed");

A synchronous exception from the handler also becomes a tool-error result. Malformed protocol requests and an unknown tool name are handled by the server. Your callback does not construct a JSON-RPC response itself.

Reply once. Code that calls reply, result or error and then calls another reply method is a programming error. Keep a final reply separate from the work leading to it so the completion point is easy to see.

Add a permission check before changing state

Authentication alone does not decide which tool operations a user allows. For an example with a side effect but no external worker, add a tool that stores one note in the server's memory.

Add this before registering the tools:

use AnyEvent::MCP::Server::Permissions;

my $note;

mcp_config permissions => AnyEvent::MCP::Server::Permissions->new (
   name    => "echo-notes",
   actions => {
      remember => "Replace the server's in-memory note",
   },
);

Then register this tool before mcp_start:

mcp_tool remember =>
   "Replace the server's in-memory note with the supplied text.",
   {
      type       => "object",
      properties => {
         text => { type => "string", maxLength => 4096 },
      },
      required => ["text"],
   },
   sub {
      my ($req, $args) = @_;

      my $text = $args->{text};
      defined $text and !ref $text and length $text <= 4096
         or die "text must be a string of at most 4096 characters\n";

      $req->require_permission ({
         tool           => $req->{tool},
         action         => "remember",
         sandbox_grants => [],
         reason         => "replace the server's in-memory note",
      });

      $note = $text;

      $req->result ({ remembered => length $note });
   };

Restart the server and call remember through your client. The default permission helper asks on the server machine. Allow once permits this invocation. Allow always saves a rule for action remember; subsequent matching calls do not ask again. The note itself lasts only for this server process and is shared by its clients.

The permission engine's name echo-notes selects its default policy file:

~/.config/anyevent-mcp/rules/echo-notes.ini

The actual directory follows XDG_CONFIG_HOME when set. This is the shared engine's default convention, different from mcp-fs's rules/fs-NAME.ini. Manage it using anyevent-mcp manage-rules-file with the actual pathname, or supply file to the engine explicitly.

For example, a manually written allow rule is:

[rule]
decision=allow
action=remember
sandbox-grants=

The empty grant list is intentional. This tool updates an in-memory value; it does not launch a sandboxed worker. action is the operation class to authorize, while sandbox_grants describes worker authority.

The permission engine checks policy; it does not restrict filesystem or network access by itself. If you add file or command tools, define the necessary grants and enforce the returned set through your worker or sandbox. Do not copy sandbox_grants => [] into a file-writing handler and assume it confines access.

Here the permission name is deliberately fixed and independent of the HTTP instance name. If you want each --name to have separate policy, pass $options{name} as the permission engine's name instead.

Why checks must precede side effects

When permission is missing, require_permission throws a special exception. Server catches it, asks, and may invoke the handler again. The assignment to $note therefore comes after the check.

With a local prompt, the server retries internally. Modern MCP form asking returns input_required; the client repeats the call with its answer. Earlier one-time grants survive permission retries within this invocation, but do not authorize the next call. Saved rules are rechecked.

Do not catch that exception as an ordinary failure around the permission check. It must reach Server's synchronous dispatch boundary. For several checks, complete all of them before modifying state or starting asynchronous work. An on_request hook also runs on retry, so it must not perform a once-only side effect before the handler's checks.

To choose client-side form asking for this example:

PERL_ANYEVENT_MCP_PERMISSION_ASK=auto perl echo-mcp --stdio

auto uses modern form elicitation when the client advertises support, otherwise the local helper. mcp requires that support; helper keeps the prompt local. The tiny try-echo client advertises no form support, so auto uses the helper there. To handle forms in a Perl client, see "on_request" in AnyEvent::MCP::Client and "elicitation" in AnyEvent::MCP::Permissions::Ask.

For grant definitions, subject scopes and multi-path operations, see AnyEvent::MCP::Server::Permissions and "Permission checks and retries" in AnyEvent::MCP::Server::Toolset.

Let an asynchronous operation finish later

A tool callback runs in the AnyEvent event loop. Blocking it with sleep or a synchronous long-running command also blocks other requests.

Add use AnyEvent; and register the following before mcp_start:

mcp_tool delayed_echo =>
   "Return the supplied text after a delay of up to 60 seconds.",
   {
      type       => "object",
      properties => {
         text => { type => "string", maxLength => 4096 },
         seconds => {
            type    => "integer",
            minimum => 0,
            maximum => 60,
         },
      },
      required => ["text", "seconds"],
   },
   sub {
      my ($req, $args) = @_;

      my $text = $args->{text};
      defined $text and !ref $text and length $text <= 4096
         or die "text must be a string of at most 4096 characters\n";

      my $seconds = $args->{seconds};
      defined $seconds and !ref $seconds
         and $seconds =~ /\A[0-9]+\z/ and $seconds <= 60
         or die "seconds must be an integer from 0 to 60\n";

      $req->progress (0, 1, "Waiting");

      my $timer = AnyEvent->timer (after => $seconds, cb => sub {
         $req->{finished}
            and return;

         $req->progress (1, 1, "Done");
         $req->reply ({
            content => [{ type => "text", text => $text }],
         });
      });

      $req->{on_cancel} = sub {
         undef $timer;
      };
   };

The tool is advertised as delayed-echo, because registration converts underscores to hyphens. Ask the client to call it with text hello and seconds 2. The callback returns immediately; the timer supplies the reply later. Other requests can run while it waits.

Keep watcher objects alive while they are needed. Here the cancellation callback retains $timer, and the timer's callback retains $req and $text. Cancellation releases the timer. Replying finishes the request and removes its cancellation callback, releasing the watcher as well. No separate job registry is needed for this example.

Check finished in late completion callbacks before replying. Cancellation sets both finished and cancelled and sends no reply. For subprocesses or network work, the cancellation callback should stop that operation and release its resources instead of merely abandoning the eventual reply.

Server catches exceptions during synchronous dispatch, not exceptions from a later timer or I/O callback. If an asynchronous operation can fail, handle that failure in its completion callback and report it with error or reply, provided the request is still active.

progress only sends when the caller supplied a progress token. Delivery is best effort; the HTTP server in this distribution does not deliver progress notifications. A client without progress display can still receive the final result.

When to use the object interfaces

Simple owns one default Toolset and the blocking server lifetime. That is useful for a small standalone program. If you already run an event loop, want multiple transports or tool registries, or need custom protocol dispatch, construct the objects directly.

AnyEvent::MCP::Server::Toolset registers reusable tools. AnyEvent::MCP::Server handles the protocol. AnyEvent::MCP::Server::HTTP and AnyEvent::MCP::Server::JSONL manage connections. AnyEvent::MCP::Server::Request describes request fields, replies, progress and cancellation. The same tool callback model applies at both levels.

FUNCTIONS

mcp_config %config

Adds server configuration. Repeated calls extend or replace previously declared values. instructions is treated as human-readable text and normalized.

Server and transport options are passed to the underlying objects. Common options include:

server_info => { name => $name, version => $version }

Advertised MCP server identity.

instructions => $text

Human-readable guidance for the client, normalized when configured.

short_instructions => $text

Optional condensed guidance for finding this server's tools. Used as the advertised MCP instructions when auto_instructions is enabled.

auto_instructions => $boolean

Enabled by default. Exposes full instructions through the generated meta-instructions tool while advertising the short instructions if set. When short or custom instructions are provided, append a brief hint pointing to the full tool description; otherwise advertise full instructions as before. Disable to advertise full instructions and omit the generated tool.

custom_instructions => $text

Instance-specific purpose prepended to advertised instructions. Can also be set through --custom-instructions or custom_instructions in the program or instance section of the global INI configuration.

on_request => $callback->($server, $req)

Synchronous hook before each dispatch attempt. For example, refresh database state here. This does not replace dispatch or receive a completion callback, and permission retries can invoke it again.

on_start => $callback->($server)

Called after the transport is ready, before entering the event loop. Use it for startup information or installing watchers. Stdout carries protocol messages in stdio mode; print diagnostics to stderr. The callback must not block.

idle_exit => $seconds

Optional positive idle time for spawned stdio servers. With a multibroker advertising anyevent-mcp/multibroker version 1, Simple requests shutdown once no MCP requests have been active for that long. The broker can ignore the request. HTTP servers, ordinary clients and servers without this option are unaffected.

See AnyEvent::MCP::Server for further protocol options and events.

mcp_tool $name, $description, $input_schema, $callback->($req, $args)

Registers a tool named $name with $description and the JSON Schema hash reference $input_schema. $description is normalized like instructions.

$callback receives an AnyEvent::MCP::Server::Request object $req and its $req->{args} hash reference as $args, for convenience.

Tools complete the request with $req->reply ($result), or use $req->result ($data) and $req->error ($message) for convenience. Request context is available directly in the hash, for example $req->{tool} and $req->{server}.

%server_options = mcp_getoptions ({ %settings }, @getopt_options)

Parse standard server options --name, --bind, repeatable --token, --stdio, --custom-instructions, repeatable --auto-allow and --auto-deny, --quiet, --print-url, --tool-help and --help alongside any application-specific Getopt::Long option specifications:

my $verbose;
my %options = mcp_getoptions (
   {
      usage => "Usage: $0 [OPTION]... WORKDIR\n",
      description => "Provide example tools through MCP.",
      option_heading => "Example options:",
      options => [
         ["--verbose", "Show additional diagnostics"],
      ],
      examples => "  $0 --stdio ./project",
      path => "/",
   },
   "verbose!" => \$verbose,
);
@ARGV == 1 or die "workdir required\n";
mcp_start %options;

The optional leading hash accepts these help settings:

usage => $text

Usage synopsis, including its trailing newline. A default synopsis is generated when omitted.

description => $text

Introductory help paragraph.

options => [[$label, $description]...]

Application option labels and descriptions. A plain string among the pairs starts a new section; a string at the beginning replaces the default section heading.

option_heading => $text

Initial section heading, defaulting to Application options:.

examples => $text

Preformatted examples.

This section is the canonical reference for common server options. Executable manpages document only their additional or differing behavior.

The common MCP options are documented automatically and their descriptions are shared by all callers. Application option descriptions are explicit; a Getopt::Long specification does not contain enough information to write one.

Other settings in that hash, such as path, auth or url_scheme, are passed through to mcp_start. Without usage, a default synopsis is generated. Standard --help prints the expanded help and exits; a parsing error dies with the shorter usage synopsis. Standalone servers using this parser emit tool calls and tool-error replies to the configured AnyEvent::Log output by default, including asynchronous failures. --quiet suppresses this operational log, not warnings and other ordinary diagnostics. The process-wide default formatter uses a short day/time timestamp, program, instance and PID; custom formatting callbacks and output destinations configured through PERL_ANYEVENT_LOG are retained. Verbose protocol diagnostics retain their normal AnyEvent log levels. --tool-help prints the registered tools with their argument types, required fields, defaults and descriptions, then exits before listening. Call it only after registering tools; it reflects the actual registry, including conditional registration. See mcp_tool_help for custom servers. --print-url resolves the same name, bind, tokens and path as startup, prints the first accepted token in the URL and exits without starting a listener. It requires a persistently stored master secret for derived credentials and cannot predict a port requested as zero.

All other option specifications and remaining @ARGV follow the caller's normal Getopt::Long configuration, including require_order if selected. --stdio is understood by mcp_start and skips HTTP configuration. --auto-allow TOOL[,ACTION[,CAPABILITY]] and --auto-deny with the same argument each add one process-local rule, in command-line order. Omitted fields and * match any value. Named capabilities limit allowed authority or trigger a denial; see "auto_rules" in AnyEvent::MCP::Server::Permissions. These rules are checked before saved rules. The startup functions apply them to the configured AnyEvent::MCP::Server::Permissions object, creating an in-memory one if necessary. Only tools that request permission are affected; tools without permission checks are not blocked. In a multibroker they apply to broker-visible tool names, not to independent permission checks performed by downstream servers. --connect URL connects to a multibroker at http://host:port/token/alias instead of serving stdio or starting an HTTP listener by default. It overrides implicit stdio from the environment; explicit --stdio and --connect cannot be combined. The helper deliberately does not enforce application-specific positional arguments; --print-url should not have to supply a workdir just to print an address. It is a parser, not a second command language.

mcp_tool_help ($enabled, @toolsets)

If $enabled is true, print the definitions registered in the supplied AnyEvent::MCP::Server::Toolset objects and exit. Otherwise do nothing. Call after registering tools but before starting listeners for executables which manage their own toolsets. Only registered tools appear.

mcp_log $message

Emit operator-facing activity to the configured AnyEvent::Log destination, bypassing its diagnostic verbosity filter. --quiet suppresses this log.

mcp_log_request ($server, $req)

on_request callback to report tool invocations once across permission retries, and log tool-error replies, including asynchronous ones. It logs selected arguments rather than entire request or reply payloads. This includes a JSON-quoted preview of Vim ex commands (up to 200 characters), which can contain sensitive text. The Simple starters install it by default; custom servers can use it too.

mcp_start %config

Starts and runs the usual server. When stdio is true or PERL_ANYEVENT_MCP_MULTIBROKER_PROTOCOL is stdio, uses mcp_stdio. Otherwise starts HTTP without printing its URL. HTTP-capable executables provide --print-url to retrieve the URL explicitly.

The HTTP defaults are IPv4 loopback, a stable name-derived port in 16384..32767, and / as the endpoint path. Without explicit tokens, one server-specific token is derived from the private global secret. The selected token authenticates either its URL path or an Authorization: Bearer TOKEN header at the clean URL returned by $server->url (0). The corresponding executable's --print-url option prints the first accepted token in its URL.

name selects the [instance NAME] configuration section and credential. It defaults to default. Named settings bind, path, url_host, url_scheme and tokens are loaded from the global configuration. Explicit tokens (an ordered, nonempty array reference of URL-safe strings) replaces the configured list and the derived default. Every token works in the URL and bearer header. An explicit bind overrides the configured bind address. Other arguments override these settings.

The global secret is generated automatically when needed; see "GLOBAL CONFIGURATION" in AnyEvent::MCP. No configuration is loaded for stdio. HTTP startup neither prints to stdout nor changes its buffering. mcp_start does not parse @ARGV; applications parse their own options. The bundled executables use mcp_getoptions, which handles shared options and URL discovery before starting the event loop.

mcp_connect %config

Run a reverse-connecting JSONL server using connect => "http://host:port/token/alias". It sends the authenticated extension hello then serves ordinary MCP over the socket. Retry every second for up to sixty attempts after a disconnect; calls are cancelled rather than replayed. This connection has no spawned-child lifecycle authority.

mcp_stdio %config

Creates and runs a server over stdin/stdout. Arguments override the collected configuration. Stdout buffering is disabled. On stdin EOF, pending replies finish and stdout drains before this function returns. Stream errors close the connection and cancel pending requests.

mcp_http %config

Creates and runs an HTTP server. For example:

mcp_http
   host => "127.0.0.1",
   port => 31740,
   path => "/mcp/",
   tokens => ["your-long-random-secret"];

Arguments override the collected configuration. Unlike mcp_start, this function does not derive or supply authentication. Always pass tokens, or explicitly pass auth => "none" when a trusted embedding layer enforces access. Omitting both is an error, not an unauthenticated default. It does not print a URL; use on_start for application-specific startup output. Neither function implements OAuth or Basic authentication. See "AUTHENTICATION" in AnyEvent::MCP::Server::HTTP for details.

All three starters return when the server is closed or on SIGINT or SIGTERM. Signal shutdown closes connections and cancels outstanding requests. The signal watchers belong to the blocking call.