NAME

MCP - Model Context Protocol Perl SDK

SYNOPSIS

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

Connect 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. At its core MCP is all about text processing, which makes it a great fit for Perl.

The protocol revision implemented is 2026-07-28, and it is stateless. There is no handshake and no session, every request stands on its own, so an MCP endpoint is just another route in your Mojolicious application and scales the same way.

Read on for a tour, or go straight to MCP::Server for the reference documentation.

TUTORIAL

A walk through building MCP applications with Perl, from a single tool to a deployed web service.

Concepts

A server exposes three kinds of primitives, and 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 are what most servers are made of. 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.

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;

The name and description are how a model decides to call the tool, so write them for the model rather than for a human reader. The input schema is enforced before your code runs, so $args is always valid by the time you see it. 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. 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 worth doing once before reaching for a client.

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"]
    }
  }
}

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

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.

Validating arguments

Input schemas are JSON Schema 2020-12 unless they name another dialect with $schema, and are 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.

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, such as a name that does not exist or an argument that makes no sense together with another. 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.

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.

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 as soon as 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. Both transports serve other requests while it is pending.

Two details are worth copying. 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 is called. And the chain ends in a catch, which turns a failure into an error result; a promise that is rejected instead never produces a response at all.

Progress reports

Long running tools report progress through the context, 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 is upgraded from JSON to SSE the moment there is something to send. That needs no configuration and works behind a pre-forking web server.

Capture the context in a variable before an async boundary, since "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;
}

Progress is only sent when the client asked for it with a progress token, so notify_progress is always safe to call.

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");

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

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

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 are not 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 is 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.

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

Notification streams

Notifications that belong to no request, such as 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 is 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, or state minted by one worker is rejected by the next, and "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.

COPYRIGHT AND LICENSE

Copyright (C) 2025-2026, Sebastian Riedel.

This program is free software, you can redistribute it and/or modify it under the terms of the MIT license.

SEE ALSO

MCP::Server, MCP::Client, Mojolicious, https://mojolicious.org, https://modelcontextprotocol.io.