MCP(3)

πŸ“¦
version 0.150.0 Β· 2 versions
, in opensuse-leap 16.0

πŸ’… MCP(3) β€” Model Context Protocol Perl SDK πŸ¦‹

okay so real talk: this is the MCP page from the User Contributed Perl Documentation section β€” and it's the whole vibe tour of the Model Context Protocol Perl SDK. It's a long one bestie, so it's coming at you in three parts, and this is part one. Buckle in. πŸŽ€


✨ NAME ✨

MCP β€” Model Context Protocol Perl SDK. That's it, that's the name, slay. πŸ’…


🌸 SYNOPSIS 🌸

Here's the little taste test β€” a whole working server in like ten lines:

use Mojolicious::Lite -signatures;

use MCP::Server;

my $server = MCP::Server->new;
$server->tool(
  name        => 'time',
  description => 'Get the current local time',
  code        => sub ($tool, $args) {
    return localtime(time);
  }
);

any '/mcp' => $server->to_action;

app->start;

πŸ¦‹ DESCRIPTION πŸ¦‹

So the whole point: you're connecting Perl with AI using the Model Context Protocol (MCP). An MCP server hands a model three kinds of things: tools it can call, prompts it can start from, and resources it can read. And honestly? At its core MCP is all about text processing, which makes it a chef's kiss fit for Perl. πŸ“

The protocol revision implemented here is 2026-07-28, and it is stateless. No cap, there is no handshake and no session β€” every request stands on its own. So an MCP endpoint is literally just another route in your Mojolicious application and it scales the same way. Periodt.

Read on for the tour, or skip straight to MCP::Server for the reference documentation. iykyk. ✨


🧚 TUTORIAL 🧚

A walk-through of building MCP applications with Perl, going from a single tool all the way to a deployed web service. Let's goooo.


🌈 Concepts

A server exposes three kinds of primitives, and bestie, you can mix them freely:

# Called by the model, whenever it decides to
$server->tool(name => 'deploy', code => sub ($tool, $args) {...});

# Picked by the user, usually from a menu
$server->prompt(name => 'review', code => sub ($prompt, $args) {...});

# Read by the client, to put into context
$server->resource(uri => 'file:///readme', code => sub ($resource) {...});
  • Tools are functions the model calls on its own, and they're what most servers are made of. Main character energy fr.
  • Prompts are templates a user invokes deliberately.
  • Resources are documents a client attaches to a conversation.

Everything else in this tutorial builds on those three. 🫢


✨ Your first server

This is a complete MCP server that gives a model access to your local Perl documentation. Iconic behaviour:

use Mojo::Base -strict, -signatures;

use MCP::Server;

my $server = MCP::Server->new(name => 'PerldocServer');

$server->tool(
  name         => 'perldoc',
  description  => 'Look up the documentation of a Perl module',
  input_schema => {
    type       => 'object',
    properties => {module => {type => 'string', description => 'Module name, such as Mojo::UserAgent'}},
    required   => ['module']
  },
  code => sub ($tool, $args) {
    my $module = $args->{module};
    return $tool->text_result("Not a module name: $module", 1) unless $module =~ /^\w+(?:::\w+)*\z/;
    open my $doc, '-|', 'perldoc', '-o', 'text', '-T', $module or return $tool->text_result('Found no perldoc', 1);
    my $text = do { local $/; <$doc> };
    return length($text // '') ? $text : $tool->text_result("Found no documentation for $module", 1);
  }
);

$server->to_stdio;

Lowkey the most important part: the name and description are how a model decides to call the tool, so write them for the model, not for a human reader. The input schema is enforced before your code runs, so $args is always valid by the time you see it. And returning a plain string is shorthand for a text result. πŸ’–


πŸ’¬ Talking to your server

to_stdio in MCP::Server speaks JSON-RPC on standard input and output, so you can drive the server by hand, no client needed. Every request declares the protocol version it was made with and the capabilities of the client making it:

$ perl perldoc_stdio.pl
{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"perldoc","arguments":{"module":"Mojo::JSON"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}

One JSON object per line, in and out. That is the whole protocol, and it is genuinely worth doing once before you reach for a client. Trust me on this one. πŸ™ˆ


πŸ”Œ Connecting a client

Most MCP hosts launch stdio servers themselves and are configured with a JSON file β€” claude_desktop_config.json for Claude Desktop and mcp.json for many others:

{
  "mcpServers": {
    "perldoc": {
      "command": "perl",
      "args": ["/absolute/path/to/perldoc_stdio.pl"]
    }
  }
}
Bestie, heads up: use absolute paths, since the host does not run the command from your working directory. And restart the host to pick up changes. When a server does not show up, its STDERR is where to look β€” see "Logging". πŸ‘€

🌐 Web applications

The same server becomes an HTTP endpoint with to_action in MCP::Server, which returns a plain Mojolicious action:

use Mojolicious::Lite -signatures;

use MCP::Server;

my $server = MCP::Server->new(name => 'PerldocServer');
$server->tool(...);

any '/mcp' => $server->to_action;

app->start;

There is nothing special about that route, fr. It can sit under an under, carry placeholders, or share authentication with the rest of your application. In a full Mojolicious application it goes into startup like any other:

sub startup ($self) {
  my $server = MCP::Server->new(name => 'PerldocServer');
  $server->tool(...);
  $self->routes->any('/mcp' => $server->to_action);
}

Clients reach it with the Streamable HTTP transport, so the same JSON travels in a POST body:

{
  "mcpServers": {
    "perldoc": {
      "url": "http://127.0.0.1:3000/mcp"
    }
  }
}

πŸ§ͺ Testing

MCP::Client is conformant on the wire, so pointing it at a Test::Mojo application tests a server the way a real client sees it. Obsessed with this actually:

use Mojo::Base -strict, -signatures;

use Test::More;
use Test::Mojo;
use MCP::Client;

my $t      = Test::Mojo->new('MyApp');
my $client = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/mcp'));

my $result = $client->call_tool('perldoc', {module => 'Mojo::JSON'});
like $result->{content}[0]{text}, qr/Minimalistic JSON/, 'documentation found';

done_testing;

Sharing the user agent with Test::Mojo keeps requests inside the process, so no port is ever bound. Chef's kiss. πŸŽ€


🎯 Validating arguments

Input schemas are JSON Schema 2020-12 unless they name another dialect with $schema, and they're checked before your code is reached β€” so a call with bad arguments never becomes your problem. πŸ’…

use Mojo::JSON qw(false);

input_schema => {
  type       => 'object',
  properties => {
    module => {type => 'string', description => 'Module name, such as Mojo::UserAgent'},
    lines  => {type => 'integer', description => 'Maximum number of lines', minimum => 1, maximum => 500}
  },
  required             => ['module'],
  additionalProperties => false
}

Descriptions are part of the interface, since the model reads them to decide what to pass β€” and so are the constraints. A schema that says up front what is acceptable produces fewer failed calls than a tool that says so afterwards. Words to live by. ✨


🧁 Tool results

Returning a string is shorthand for text_result in MCP::Tool, and there are constructors for the other content types:

# Text
return $tool->text_result('Hello!');

# Error the model can see and react to
return $tool->text_result("Found no documentation for $module", 1);

# Image or audio bytes, base64 encoded for you
return $tool->image_result($png, {mime_type => 'image/png'});
return $tool->audio_result($wav);

# Pointer to a resource, instead of its content
return $tool->resource_link_result('file:///perl/config', {name => 'perl_config'});

Anything the model could react to belongs in the result with the error flag set β€” like a name that does not exist, or an argument that makes no sense together with another one. A tool that dies is answered with an InternalError instead, and the exception goes to log in MCP::Server rather than to the caller, since it can easily contain file system paths or connection strings. 😭

That is a safe fallback and a poor error message, so prefer returning an error result for anything you can predict. Don't make the model guess. πŸ’€

🌟 Structured results

A tool with an output schema returns data instead of prose, and the client gets a guarantee about its shape:

$server->tool(
  name          => 'perl_version',
  description   => 'Version of the Perl interpreter running this server',
  output_schema => {
    type       => 'object',
    properties => {version => {type => 'string'}, release => {type => 'number'}},
    required   => ['version', 'release']
  },
  code => sub ($tool, $args) {
    return $tool->structured_result({version => "$^V", release => $] + 0});
  }
);

Structured content is validated against the output schema before it is sent, and data that does not match becomes an error result β€” so a tool cannot quietly break its own contract. King behaviour honestly. πŸ‘‘


🌸 Prompts

Prompts are templates a user picks deliberately, usually from a menu in the client:

$server->prompt(
  name        => 'explain',
  description => 'Explain what a Perl module is for',
  arguments   => [{name => 'module', description => 'Module name', required => 1}],
  code        => sub ($prompt, $args) {
    return "Read the documentation of $args->{module} with the perldoc tool, then explain in three sentences "
      . 'what problem it solves and when to reach for it.';
  }
);

A plain string becomes a user message, and text_prompt in MCP::Prompt gives you control over the role:

return $prompt->text_prompt('You are a Perl mentor.', 'assistant');

πŸ“š Resources

Resources are documents a client can read and attach to a conversation, identified by URI:

use Config;

$server->resource(
  uri         => 'file:///perl/config',
  name        => 'perl_config',
  description => 'Configuration of the Perl interpreter running this server',
  mime_type   => 'text/plain',
  code        => sub ($resource) {
    return join "\n", map {"$_=$Config{$_}"} qw(archname osname perlpath version);
  }
);

Resources take no arguments β€” only the URI they were registered with. For binary content use binary_resource in MCP::Resource, which base64 encodes it for you. 🫢


⚑ Non-blocking tools

A tool that returns a Mojo::Promise leaves the server free to handle other requests while it waits β€” which starts to matter the second it talks to the network:

use Mojo::URL;
use Mojo::UserAgent;

my $ua = Mojo::UserAgent->new;
         $server->tool(
           name         => 'metacpan',
           description  => 'Look up the latest release of a distribution on MetaCPAN',
           input_schema => {type => 'object', properties => {dist => {type => 'string'}}, required => ['dist']},
           code         => sub ($tool, $args) {
             my $url = Mojo::URL->new('https://fastapi.metacpan.org/v1/release');
             push @{$url->path->parts}, $args->{dist};

             return $ua->get_p($url)->then(sub ($tx) {
               my $release = $tx->result->json;
               return "$release->{name} was released on $release->{date} by $release->{author}";
             })->catch(sub ($err) {
               return $tool->text_result("MetaCPAN is unavailable: $err", 1);
             });
           }
         );

The promise resolves to whatever a synchronous tool would have returned. And both transports keep serving other requests while it's pending β€” no blocking, no drama. ✨

Two details here are worth copying, bestie, like genuinely:

  • The argument becomes a path part instead of being interpolated into the URL, so a value containing a slash or a question mark cannot change which endpoint gets called. πŸ›‘οΈ
  • The chain ends in a catch, which turns a failure into an error result. A promise that's just rejected instead never produces a response at all. πŸ’€

🫢 Progress reports

Long-running tools report progress through the context β€” that's the per-request object your code shares with the transport.

         code => sub ($tool, $args) {
           my $context = $tool->context;

           my $total = @{$args->{modules}};
           my $done  = 0;
           for my $module (@{$args->{modules}}) {
             $context->notify_progress(++$done, $total, "Indexed $module");
             ...
           }

           return "Indexed $total modules";
         }

Notifications travel on the response stream of the request they belong to, which gets upgraded from JSON to SSE the moment there's something to send. That needs zero configuration and works behind a pre-forking web server. Slay. πŸ’…

Okay but real talk: capture the context in a variable before an async boundary, because context in MCP::Primitive is only valid for the duration of the call itself. πŸ•

         code => sub ($tool, $args) {
           my $context = $tool->context;
           my $promise = Mojo::Promise->new;
           Mojo::IOLoop->timer(1 => sub {
             $context->notify_progress(1, 1, 'Finished');
             $promise->resolve('Done');
           });
           return $promise;
         }

And progress is only sent when the client asked for it with a progress token, so notify_progress is always safe to call. No vibe check needed, it just behaves. πŸŽ€


🌸 Logging

notify_log in MCP::Server::Context sends log messages to the client, gated by the level it asked for.

         $context->notify_log(info    => "Looking up $module");
         $context->notify_log(warning => {module => $module, truncated => 1});

Nothing is sent unless the client requested logging for the current request, and messages below the level it asked for are dropped β€” so this too is always safe to call. 🧚

Your own logs belong on STDERR, which is where Mojo::Log writes and where an MCP host collects them. Never write to STDOUT from a stdio server, because that is the JSON-RPC channel and a stray print corrupts it. 😭

         # Bad, breaks the stdio transport
         print "Looking up $module\n";

         # Good
         my $log = Mojo::Log->new;
         $log->info("Looking up $module");
Fr fr, do not print to STDOUT on a stdio server. That's the whole JSON-RPC channel. Iconic way to break everything. πŸ’€

πŸ¦‹ Cancellation

When a client goes away, the context emits cancelled in MCP::Server::Context, so work can stop instead of running to completion for nobody. πŸ‘»

         code => sub ($tool, $args) {
           my $context = $tool->context;
           my $promise = Mojo::Promise->new;
           my $id      = Mojo::IOLoop->recurring(1 => sub {...});
           $context->on(cancelled => sub ($context) { Mojo::IOLoop->remove($id) });
           return $promise;
         }

Code that can't subscribe to an event polls is_cancelled in MCP::Server::Context instead. Both transports feed the same signal β€” a closed response stream over HTTP, and a notifications/cancelled notification over stdio β€” so a tool only has to handle it once. Periodt. ✨


πŸ’œ Asking the client for input

A tool that needs a decision from the user returns an input_required result instead of a final one, and the client calls it again with the answer.

         $server->tool(
           name         => 'delete_release',
           input_schema => {type => 'object', properties => {name => {type => 'string'}}, required => ['name']},
           code         => sub ($tool, $args) {
             my $context = $tool->context;
             my $state   = $context->request_state;
             my $confirm = ($context->input_responses // {})->{confirm} // {};
             return $tool->text_result("Deleted $state->{name}") if $state && ($confirm->{action} // '') eq 'accept';

             return $tool->input_required({
               confirm => {
                 method => 'elicitation/create',
                 params => {
                   message         => "Really delete $args->{name}?",
                   requestedSchema => {type => 'object', properties => {ok => {type => 'boolean'}}}
                 }
               }
             }, {name => $args->{name}});
           }
         );

The second argument is state carried across the round trip, and the server remembers nothing. It's sealed with HMAC-SHA256, bound to the caller and to this very tool, and given a short lifetime, so a client cannot edit it on the way back. request_state in MCP::Server::Context returns undef for state that fails any of those checks β€” which your code should treat exactly like a first call and simply ask again. πŸŽ€

Note that the retry acts on $state->{name} and not on $args->{name}. Only the state is sealed; the arguments of the second call are as fresh and as untrusted as those of the first. So a client could ask about one release and then present that confirmation with another one named. 😭 Whatever the user actually agreed to has to come back out of the state.

Only ask for capabilities the client declared in client_capabilities in MCP::Server::Context, and set state_secret in MCP::Server if the server runs in more than one process β€” see "Deployment" below. πŸ‘‘


πŸ”₯ Authentication and scopes

An MCP endpoint is authenticated like any other route, and for OAuth 2.0 bearer tokens the HTTP transport has an auth hook that runs before dispatch.

         any '/mcp' => $server->to_action({
           auth => sub ($c) {
             return undef unless ($c->req->headers->authorization // '') =~ /^Bearer\s+(\S+)$/;
             return undef unless my $token = validate_token($1);
             $c->stash(role => $token->{role});
             return {principal => $token->{sub}, scopes => $token->{scopes}};
           },
           metadata_url => 'https://example.com/.well-known/oauth-protected-resource'
         });

Returning a false value rejects the request with a 401 and a WWW-Authenticate challenge pointing at the metadata document. Validating the token is yours to do, since only your application knows the authorization server. πŸ™ˆ

Primitives can then require scopes, which are checked on every call and hide the primitive from callers who aren't entitled to see it.

         $server->tool(name => 'delete_release', scopes => ['mcp:write'], code => sub ($tool, $args) {...});

Scopes are enforced against what the hook granted, so they only protect anything on a server that has one. Without it no request carries scopes, and a scoped primitive is as reachable as an unscoped one. Over stdio, where the caller is whoever started the process, that's the intended behaviour. πŸ’…

Serve the metadata document with oauth_metadata in MCP::Server, which fills in the scopes your server actually uses.

         get '/.well-known/oauth-protected-resource' => sub ($c) {
           $c->render(json => $server->oauth_metadata(
             resource              => 'https://example.com/mcp',
             authorization_servers => ['https://auth.example.com']
           ));
         };

🌈 Per-caller primitives

The lists of tools, prompts and resources are assembled per request, and MCP::Server emits an event for each one, so a single server can show different things to different callers. Main character server energy.

         $server->on(tools => sub ($server, $tools, $context) {
           my $c = $context->controller;
           return if $c && ($c->stash('role') // '') eq 'admin';
           @$tools = grep { $_->name ne 'delete_release' } @$tools;
         });

The array reference is yours to modify in place, and removing a primitive also makes it uncallable, not merely invisible. Everything known about the caller is on the context, including the Mojolicious::Controller for HTTP requests β€” which is only there for the HTTP transport. Decide what to hide from what the context actually says, never from what it fails to say, so an unauthenticated request ends up with the smallest list rather than the largest. πŸ›‘οΈ


πŸ“ Caching

Results that are expensive to produce and rarely change may be cached by clients and gateways, for as long as the primitive says.

         $server->resource(uri => 'file:///perl/config', cache_ttl => 3_600_000, code => sub ($resource) {...});

Times are in milliseconds, and the default of 0 means every result has to be revalidated. Lists derive their hints from the primitives in them, and are marked private as soon as they could differ per caller β€” which is the case for every server using scopes or the events above β€” so caching never leaks one caller's view to another. Obsessed. ✨


⭐ Notification streams

Notifications that belong to no request β€” like a tool list that changed while a client was connected β€” need a stream of their own. Clients open one with subscriptions/listen, which has to be enabled explicitly.

         any '/mcp' => $server->to_action({streaming => 1});

         # Somewhere else in the application
         $server->notify_list_changed('tools');

Every subscription only receives the notification types it asked for. This is the one part of the protocol that keeps per-process state, so it's off by default and not compatible with pre-forking web servers. Progress and log notifications do not depend on it. 🧚


πŸ‘‘ Deployment

An MCP endpoint deploys exactly like the rest of your Mojolicious application β€” see "DEPLOYMENT" in Mojolicious::Guides::Cookbook. The protocol is stateless, so a pre-forking web server and a load balancer need no special handling.

         $ ./myapp.pl prefork

Serve a remote endpoint over HTTPS, which the protocol requires and which is usually the reverse proxy's job. Two settings are worth having as well:

  • state_secret in MCP::Server has to be shared by every process that could serve a retry β€” otherwise state minted by one worker is rejected by the next. 😭
  • origins in MCP::Server::Transport::HTTP protects a server bound to localhost from being driven by a web page. πŸ›‘οΈ
         my $server = MCP::Server->new(state_secret => $ENV{MCP_STATE_SECRET});

         any '/mcp' => $server->to_action({origins => ['https://example.com']});

Everything except "Notification streams" works under prefork. πŸ’…

okay bestie, legal era, but make it cute ✨

  • Copyright (C) 2025-2026, Sebastian Riedel. β€” that's the actual human who made this, put some respect on the name πŸ‘‘

This program is free software, and you can literally redistribute it and/or modify it under the terms of the MIT license 🫢 no cap, that's the whole vibe β€” free to use, free to share, free to remix, just keep the license terms and you're slaying 🌸

fr fr, read the MIT license before you go shipping this in your big project β€” it's short, it's iconic, it's the moment πŸ’…

🌈 SEE ALSO 🌈

the bestie-adjacent pages you should also go read, in no particular order because they're all main characters:

  • MCP::Server β€” the server side of the operation πŸ’œ
  • MCP::Client β€” the client side, the other half of the duo πŸ¦‹
  • Mojolicious β€” the whole ecosystem this lives in, the mothership tbh πŸŽ€
  • <https://mojolicious.org> β€” the website, go touch it 🌸
  • <https://modelcontextprotocol.io> β€” the protocol spec itself, iykyk ⭐

and that's the whole page, bestie β€” you read all three parts, I'm genuinely so proud of you πŸ˜­πŸŽ€

this little beauty came from the perl v5.42.0 docs, dated 2026-07-31, and it lives at MCP(3) 🌟

go build something iconic with it. periodt. πŸ’…βœ¨

πŸ“¦ where this page comes from