Readme
MCP::Server
A framework for building MCP (Model Context Protocol) servers in Raku.
Register tools, resources, and prompts ā the framework handles the
JSON-RPC 2.0 protocol, message dispatch, and both protocol eras
(2025-11-25 and 2026-07-28), over stdio or Streamable HTTP.
Synopsis
use MCP::Server;
my $server = MCP::Server.new(:name<my-tools>, :version<1.0>);
$server.tool: 'greet',
description => 'Greet someone by name',
params => {
name => { type => 'string', description => 'Name to greet', required => True },
},
handler => -> :%args { "Hello, {%args<name>}!" };
$server.run; # Listens on stdin/stdout
Use with Claude Code:
{
"mcpServers": {
"my-tools": {
"command": "raku",
"args": ["-I", "lib", "examples/my-server.raku"]
}
}
}
Tools
Tools are functions the LLM can call. Define parameters with types and descriptions ā the framework generates JSON Schema automatically.
$server.tool: 'search',
description => 'Search the web',
params => {
query => { type => 'string', description => 'Search query', required => True },
limit => { type => 'integer', description => 'Max results' },
},
handler => -> :%args {
# Return a string (wrapped as text content)
"Results for {%args<query>}..."
};
Tool handlers receive :%args and return:
Strā wrapped as[{type: "text", text: $result}]Listā passed through as content itemsOn exception ā returned as
isError: truecontent
Tool Groups
Group related tools under a common prefix. Names are joined with _
(underscore), not /:
$server.tool-group: 'file', -> $g {
$g.tool: 'read',
description => 'Read a file',
params => { path => { type => 'string', required => True } },
handler => -> :%args { %args<path>.IO.slurp };
$g.tool: 'list',
description => 'List a directory',
params => { path => { type => 'string', required => True } },
handler => -> :%args { %args<path>.IO.dir.join("\n") };
};
# Registers as: file_read, file_list
The separator matters because the MCP spec restricts tool names to
[A-Za-z0-9_-], at most 128 characters ā a / would produce a name no
client could call. Registering a tool with an invalid name, or one that
collides with a name already registered on the server, dies immediately
(with a message naming the offending tool) rather than silently
overwriting the earlier registration:
$server.tool: 'file/read', handler => -> :%args { };
# dies: Invalid tool name 'file/read': MCP tool names must be 1 to 128
# characters of [A-Za-z0-9_-]
$server.tool: 'read', handler => -> :%args { 1 };
$server.tool: 'read', handler => -> :%args { 2 };
# dies: Duplicate tool 'read' on MCP server '...'; register one of the
# providers under a prefix, e.g. $server.plug($kit, :prefix<other>)
The same rules apply to prompts (duplicate names) and resources (duplicate URIs).
Toolkits
A toolkit is a self-contained bundle of tools, prompts, and resources
that plugs into any MCP::Server. Toolkits are how you distribute
reusable tool packs as their own zef distributions ā see "Writing a tool
pack" below ā but a toolkit can just as easily live in the same file as
the server that uses it.
The MCP::Server::Toolkit role
use MCP::Server::Toolkit;
role MCP::Server::Toolkit {
method register($registrar) { ... } # required
method default-prefix(--> Str) { Str } # optional, defaults to none
method from-config(::?CLASS:U: %config) { ... } # provided
}
register($registrar)ā required. Register everything the toolkit provides by calling.tool,.prompt, and.resourceon the supplied registrar; its method signatures matchMCP::Server's own.default-prefixā optional. The prefix applied when.plugis called with no explicit:prefix. The default implementation returns an undefinedStr, meaning "no prefix unless the caller asks for one".from-config(%config)ā provided by the role, not something you normally override. Builds an instance from a plain (JSON-shaped) hash, validating every key against the class's public attributes: unknown keys and missing required attributes both die, listing the valid or missing keys in the message. Aprefixkey inside%configis rejected as reserved ā it belongs beside the entry when plugging or in a:toolslist, not inside the toolkit's own config.
Here is a complete worked example ā everything a small toolkit distribution needs:
use MCP::Server::Toolkit;
unit class MCP::Server::Tool::Weather does MCP::Server::Toolkit;
has Str:D $.units = 'metric'; # optional -- has a default
has Str:D $.api-key is required; # required -- from-config demands it
method default-prefix(--> Str) { 'weather' }
method register($registrar) {
$registrar.tool: 'forecast',
description => 'Get a forecast for a location',
params => {
location => { type => 'string', description => 'City name', required => True },
},
handler => -> :%args {
fetch-forecast(%args<location>, :$!units, :$!api-key);
};
$registrar.resource: 'weather://stations',
name => 'Known stations',
description => 'Stations this toolkit can report on',
mime-type => 'text/plain',
handler => -> :%args { known-stations().join("\n") };
}
use MCP::Server;
use MCP::Server::Tool::Weather;
my $server = MCP::Server.new(:name<my-tools>);
$server.plug: MCP::Server::Tool::Weather.new(:api-key<secret>);
# default-prefix 'weather' applies automatically:
# registers weather_forecast and weather://weather/stations
Prefixing rules
$server.plug($kit, :prefix<...>) namespaces everything the toolkit
registers:
Tool and prompt names become
"{prefix}_{name}".Resource URIs get the prefix injected as the first path segment.
scheme://pathbecomesscheme://prefix/path; a bare (no://) URI becomesprefix/path. Leading slashes on the remainder are stripped so authority-less URIs don't end up with an empty path segment:file:///etc/hostswith prefixdocsbecomesfile://docs/etc/hosts, notfile://docs//etc/hosts.
Precedence for which prefix applies: an explicit :prefix argument to
.plug beats the toolkit's own default-prefix, which beats no prefix
at all.
$server.plug: MCP::Server::Tool::Weather.new(:api-key<x>);
# Uses default-prefix 'weather' -> weather_forecast
$server.plug: MCP::Server::Tool::Weather.new(:api-key<x>), :prefix<wx>;
# Explicit :prefix wins -> wx_forecast
$server.plug: MCP::Server::Tool::TestKit.new;
# TestKit declares no default-prefix -> registers bare: greet
Plugging two toolkits (or the same toolkit twice) that would produce the
same name dies with a message suggesting a :prefix, exactly like the
collision case in "Tool Groups" above:
$server.plug: MCP::Server::Tool::TestKit.new;
$server.plug: MCP::Server::Tool::TestKit.new;
# dies: Duplicate tool 'greet' on MCP server '...'; register one of the
# providers under a prefix, e.g. $server.plug($kit, :prefix<other>)
$server.plug: MCP::Server::Tool::TestKit.new, :prefix<second>; # fine
:sep overrides the character joining prefix and name (default _).
Since MCP tool names only allow [A-Za-z0-9_-], the only other sensible
choice is -:
$server.plug: $kit, :prefix<kit>, :sep<->;
# kit-greet instead of kit_greet
mcp classes are toolkits too
Any class declared with the mcp DSL (MCP::Server::DSL) already does
MCP::Server::Toolkit, so it can be plugged into a bigger server the
same way as a hand-written pack ā every public method becomes a tool
(with its Pod6 #| comment as the description) and every public
attribute becomes a read-only tool that reports its value:
use MCP::Server::DSL;
use MCP::Server;
mcp Calculator {
#| Add two numbers
method add(Int :$a!, Int :$b!) { $a + $b }
}
my $host = MCP::Server.new(:name<host>);
$host.plug: Calculator.new, :prefix<calc>;
# Registers calc_add
An mcp class's own standalone server ā built lazily by .server and
used internally by .run, .tools-for-llm, .execute-tool-calls, and
.handle-request ā is entirely separate from any server it gets plugged
into; plugging doesn't consume or replace it, and .server is only ever
built the first time something needs it. register, server,
from-config, and default-prefix are the method names the DSL's
machinery relies on, so an mcp class must not declare its own methods
with those names ā doing so shadows the framework instead of adding a
tool. from-config still works on mcp classes exactly as it does on
any other toolkit, deriving its schema from the class's public
attributes; the private $!server attribute is never a valid config
key.
LLM Tool Bridge
Tools registered with MCP::Server can be used with any OpenAI-compatible LLM API. Define tools once, use them both as MCP tools (for Claude Code) and as function-calling tools (for LLM API calls).
use MCP::Server;
use LLM::Chat::Backend::OpenAICommon;
use LLM::Chat::Backend::Settings;
use LLM::Chat::Conversation::Message;
use LLM::Chat::ToolLoop;
# Define tools
my $server = MCP::Server.new(:name<my-tools>);
$server.tool: 'get_weather',
description => 'Get weather for a location',
params => { location => { type => 'string', required => True } },
handler => -> :%args {
my $proc = run 'curl', '-s', "https://wttr.in/{%args<location>}?format=3", :out;
$proc.out.slurp(:close).trim;
};
# Convert to OpenAI format and send to LLM
my @tools = $server.tools-for-llm;
my $backend = LLM::Chat::Backend::OpenAICommon.new(...);
my @messages = (Message.new(:role<user>, :content<What is the weather in London?>),);
my $loop = LLM::Chat::ToolLoop.new(
backend => $backend,
tools => @tools,
execute-tools => -> @calls { $server.execute-tool-calls(@calls) },
);
my $resp = $loop.chat-completion-stream(@messages);
until $resp.is-done { sleep 0.01 }
say $resp.msg if $resp.is-success;
tools-for-llm converts registered tools to the OpenAI function-calling
format. execute-tool-calls routes the LLM's tool call requests to
your registered handlers and returns results ready to send back. Tool
results include role, tool_call_id, content, and is_error.
Resources
Resources expose data the LLM can read.
$server.resource: 'config://app',
name => 'App Config',
description => 'Application configuration',
mime-type => 'application/json',
handler => -> :%args { '{"debug": true}' };
Cacheability
:ttl-ms and :cache-scope tell a 2026-07-28 client how long it may
hold on to what a read returned, and whether the answer is the same for
everybody. They are optional, and their absence is deliberately
pessimistic: a read runs arbitrary handler code whose freshness only its
author knows, so a resource that says nothing is reported as
ttlMs: 0 (do not cache) and cacheScope: "private".
# Reads the world underneath it -- never cache this.
$server.resource: 'config://app',
name => 'App Config',
handler => -> :%args { '/etc/app.json'.IO.slurp };
# Ships with the distribution and is the same for every caller.
$server.resource: 'docs://handbook',
name => 'Handbook',
ttl-ms => 86_400_000, # a day
cache-scope => 'public',
handler => -> :%args { $handbook-text };
A modern resources/read then carries the hints on the result:
config://app { "ttlMs": 0, "cacheScope": "private" }
docs://handbook { "ttlMs": 86400000, "cacheScope": "public" }
:ttl-ms must be zero or more and :cache-scope must be public or
private, or registration dies naming the offending resource. Both
options are also accepted by the registrar a toolkit's register is
handed, so a pack can declare cacheability for its own resources and
have it survive prefixing. Legacy clients never see either field.
Prompts
Prompts are pre-defined templates for the user.
$server.prompt: 'review',
description => 'Code review prompt',
arguments => [
{ name => 'code', description => 'Code to review', required => True },
],
handler => -> :%args { "Please review:\n{%args<code>}" };
One-shot setup
MCP::Server.new(:tools[...]) builds and plugs toolkits declaratively
instead of calling .plug once per toolkit by hand. Each entry in the
list may be:
A bare or fully-qualified toolkit name (
Str) ā instantiated via.from-config({})(or.newif the toolkit doesn't providefrom-config) and plugged with no config and no explicit prefix.A
Pairof name/instance=config hash ā the config's reservedprefixkey, if present, is pulled out and passed to.plug; the rest goes to.from-config. When the key side is already an instance, the config hash may contain onlyprefixā anything else dies, since an instance is already built and has nothing left to configure.A toolkit instance on its own ā plugged as-is, unprefixed (unless it declares a
default-prefix).
Name resolution matches load-toolkit-class: a bare name (no ::)
resolves under MCP::Server::Tool::, so 'FileSystem' loads
MCP::Server::Tool::FileSystem; a name containing :: is used
verbatim. If the module can't be loaded ā typically because the
distribution isn't installed ā the error names the module and suggests
the zef install command to fix it.
my $server = MCP::Server.new(
:name<my-tools>,
:tools[
'Echo',
FileSystem => { root => '/data', prefix => 'docs' },
],
);
Loading the same toolkit twice under different prefixes is exactly what you'd expect ā repeat the entry with different config:
:tools[
FileSystem => { root => '/docs', prefix => 'docs' },
FileSystem => { root => '/build', prefix => 'build' },
],
Config files
MCP::Server.from-config($path) builds a whole server ā name, version,
instructions, and every toolkit ā from a single JSON file. It carries the
same information :tools does, in a form that doesn't require a line of
Raku:
{
"name": "my-tools",
"version": "1.0",
"instructions": "Reads and writes files under two roots.",
"tools": {
"FileSystem": { "root": "/docs" },
"FileSystem#build": { "root": "/build", "prefix": "build" },
"Shell": { "allow": ["git", "ls"] }
}
}
my $server = MCP::Server.from-config('server-config.json');
$server.run;
Keys ending in #alias (like FileSystem#build above) load the same
toolkit more than once with different settings: the part before # is
the toolkit name, and the whole key ā alias included ā just has to be
unique inside the tools object, since JSON object keys can't repeat on
their own. The reserved prefix key inside each toolkit's config object
works exactly as it does for :tools ā it's pulled out and passed to
.plug, and from-config on the toolkit itself never sees it.
Top-level keys are limited to name, version, instructions,
tools, and http (the last of which turns on the Streamable HTTP
transport ā see "HTTP transport" below). name is required and must be
a non-empty string. Anything
else that's wrong ā an unknown top-level key, invalid JSON, a non-object
top level, a non-object tools section, a non-object per-toolkit config,
or a toolkit's own config validation failure (unknown/missing keys) ā
dies with the config file's path folded into the message, so errors from
deeply nested toolkit config are still traceable back to the file that
caused them.
raku-mcp
Installing this distribution also installs a raku-mcp command ā a
thin CLI front end (MCP::Server::CLI) for assembling a server out of
toolkit packs without writing any Raku at all:
raku-mcp --config=PATH
raku-mcp --tool=SPEC [--tool=SPEC ...] [--name=STR] [--version=STR] [--instructions=STR]
raku-mcp --tool=SPEC --http=PORT [--host=ADDR] [--http-path=PATH] [--allow-origin=ORIGIN ...]
raku-mcp --describe=SPEC
raku-mcp --help
--config=PATHā load a JSON config file (see "Config files" above) and run it.--tool=SPECā load one toolkit and run; repeatable to load several.SPECis a bare or qualified toolkit name, optionally followed by=and an inline JSON config object. A"prefix"key inside that JSON namespaces the toolkit, same as everywhere else.--name=STR,--version=STR,--instructions=STRā set the server's identity when using--tool(meaningless with--config, which takes these from the file instead).--http=PORTā serve the Streamable HTTP transport onPORTinstead of stdio.--host=ADDR,--http-path=PATH, and the repeatable--allow-origin=ORIGINconfigure it; see "HTTP transport" below for what each one does and for the equivalent config-file section.--describe=SPECā print a toolkit's config schema and exit, without starting a server.--helpā print usage and exit.
--config and --tool are mutually exclusive with each other and
with --describe. Every flag is --name=value; there is no
space-separated --name value form. --host, --http-path, and
--allow-origin only mean anything to the HTTP transport, so passing
one without --http (and without a --config whose file might turn
HTTP on) is an error rather than a setting that quietly does nothing.
$ raku-mcp --tool=FileSystem={"root":"/docs"} --tool=Shell={"allow":["git"]}
$ raku-mcp --config=server-config.json
--describe=Name on its own prints only the config schema ā the
attribute list a toolkit accepts, with each one's type and whether it's
required:
$ raku-mcp --describe=FileSystem
Toolkit: MCP::Server::Tool::FileSystem
Config:
root IO::Path() required
Adding ={...} ā even ={} ā builds the toolkit with that config and
additionally lists every tool, prompt, and resource it registers, since
listing them needs an actual instance to call tools/list etc. against:
$ raku-mcp --describe=FileSystem={"root":"/docs"}
Toolkit: MCP::Server::Tool::FileSystem
Config:
root IO::Path() required
Tools:
read - Read the contents of a file
...
All diagnostics ā usage errors, unloadable toolkits, malformed JSON ā
go to stderr; stdout carries only --help/--describe output and,
once serve starts a transport, the MCP protocol stream itself. This
matters because MCP clients (Claude Code included) read stdout as the
protocol channel ā anything unexpected printed there breaks the
connection.
Point Claude Code at a config file (note the single --config=...
token ā parse-args doesn't accept a separate value argument):
{
"mcpServers": {
"my-tools": {
"command": "raku-mcp",
"args": ["--config=/absolute/path/to/server-config.json"]
}
}
}
Writing a tool pack
A tool pack is its own zef distribution, named MCP::Server::Tool::*
(e.g. MCP::Server::Tool::FileSystem), providing one class that does
MCP::Server::Toolkit. Conventions that keep a pack interchangeable
with :tools, from-config, and raku-mcp --describe:
Namespace as
MCP::Server::Tool::Name, one toolkit class per distribution, so bare-name resolution ('Name'resolving toMCP::Server::Tool::Name) works without qualification.does MCP::Server::Toolkit, implementingregister($registrar)and, when the pack should namespace by default,default-prefix.Config is public attributes ā nothing more.
from-configderives its accepted keys directly fromself.^attributes(:all).grep(*.has_accessor), so every public attribute IS the config schema; there's no separate schema to keep in sync, and coercive types (IO::Path()) or defaults behave exactly as they would on any other class.Any setting that gates what the toolkit is allowed to touch ā a filesystem root, an allowlist of shell commands, a credential with write access ā should be
is required.from-configthen refuses to build an instance without it, so a pack can't end up running wide open just because a config file forgot a key.Depend on
"MCP::Server:auth<zef:apogee>", not on any specific server that happens to embed it ā a well-behaved pack works equally as a one-shot:toolsentry, a.plug-ed instance, or a config-file entry in any host server.
Two reference packs ship as separate distributions and are worth reading as examples of the conventions above:
MCP::Server::Tool::FileSystemā root-confined file access.MCP::Server::Tool::Shellā allowlisted command runner.
Examples
examples/echo-server.rakuā Minimal echo and reverse toolsexamples/weather-server.rakuā Weather via wttr.inexamples/file-server.rakuā File system tools using tool groupsexamples/strawberry-server.rakuā The tool LLMs wish they hadexamples/toolkit-server.rakuā Plugging installed tool packs (FileSystem, Shell) into one server alongside an inline toolexamples/server-config.jsonā A JSON config forfrom-config, including an aliased toolkit entry
Protocol
Two MCP protocol eras are spoken at once, and which one a message belongs to is worked out from the message itself rather than from a mode flag:
2025-11-25ā the legacy era. Session-shaped: the client opens withinitialize, follows up withnotifications/initialized, and the connection then carries state (whether the handshake finished, what log level was asked for) until it hangs up.2026-07-28ā the modern era. Stateless: there is no handshake and no session, every request describes itself throughparams._meta, and the server keeps nothing at all between messages.
The 2026-07-28 specification explicitly permits a dual-era server to
serve both eras concurrently on the same endpoint, and that is what one
MCP::Server instance does. Nothing has to be configured for it: a
2025-11-25 client and a 2026-07-28 client can be talking to the same
process at the same time and neither will notice the other.
Which era a message belongs to
Four rules, applied per message, in this order:
1.
initializeis always legacy. The method does not exist in 2026-07-28 at all, so a stray_metaon it is ignored rather than treated as a contradiction.2.
server/discoveris always modern. It exists only in 2026-07-28, and it is the bootstrap probe a client uses to find out what the server speaks, so it is answerable with no_metawhatsoever.3. Otherwise, a
params._metacarryingio.modelcontextprotocol/protocolVersionmakes the message modern.4. Anything else is legacy.
my $server = MCP::Server.new(:name<my-tools>);
$server.tool: 'greet', handler => -> :%args { 'Hi' };
# 1. initialize is legacy, so the answer names the legacy version.
$server.handle-request({ jsonrpc => '2.0', id => 1, method => 'initialize', params => {} });
# result.protocolVersion => '2025-11-25'
# 2. server/discover is modern even with no _meta at all.
$server.handle-request({ jsonrpc => '2.0', id => 2, method => 'server/discover' });
# result.supportedVersions => ['2026-07-28'], result.resultType => 'complete'
# 3. _meta's protocol version makes anything else modern.
$server.handle-request({
jsonrpc => '2.0', id => 3, method => 'tools/list',
params => { _meta => { 'io.modelcontextprotocol/protocolVersion' => '2026-07-28' } },
});
# result.resultType => 'complete', result.ttlMs => 3600000,
# result.cacheScope => 'private'
# 4. The same call without _meta is legacy: a bare list, no envelope.
$server.handle-request({ jsonrpc => '2.0', id => 4, method => 'tools/list' });
# result.tools only -- no resultType, no ttlMs
The legacy era (2025-11-25)
initialize/notifications/initializedhandshake, answering with the server's capabilities ātools,resources,promptsfor whatever is actually registered, pluslogging.tools/list,tools/call,resources/list,resources/read,prompts/list,prompts/get.ping, answered with an empty result.logging/setLevel, which sets the session's minimum severity. Levels are the RFC 5424 eight (debug,info,notice,warning,error,critical,alert,emergency); anything else comes back as-32602listing the ones that work. The advertisedloggingcapability is therefore honest ā earlier releases advertised it without implementing the method.Logging via
notifications/message, emitted on the transport once the client has said it is initialized and only at or above the level it asked for.
The modern era (2026-07-28)
server/discoverreplaces the handshake. It reportssupportedVersions,capabilities, the server'sinstructionsif it has any, and thettlMs/cacheScopefor which those answers hold.Every request must carry
params._meta.io.modelcontextprotocol/protocolVersion;...clientInfoand...clientCapabilitiesare optional, and...logLevelopts that one request into log notifications. The one exception isserver/discover, which is how a client finds out what to put there in the first place.Every result carries
resultType, and this server's is alwayscompleteā see "Not supported" below.Every result carries
_meta.io.modelcontextprotocol/serverInfo.tools/list,prompts/list,resources/listandresources/readcarryttlMsandcacheScope. The three listings use the server-wide values (see "Modern-era server options"); a read uses whatever the resource declared (see "Cacheability" above).Listings are ordered deterministically ā tools and prompts by name, resources by URI ā in both eras, because Raku hash order is not stable and a cache keyed on the response body deserves better.
initialize,pingandlogging/setLevelare gone, and answer-32601to a modern caller. Liveness belongs to the transport now, and the log level travels per request.
A modern tools/call and the answer it gets:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "greet",
"arguments": { "name": "Ada" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "demo-client", "version": "0.1" },
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/logLevel": "info"
}
}
}
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [ { "type": "text", "text": "Hello, Ada!" } ],
"resultType": "complete",
"_meta": {
"io.modelcontextprotocol/serverInfo": { "name": "my-tools", "version": "1.2.0" }
}
}
}
Because that request opted in at info, anything the handler logs at
info or above is delivered as a notifications/message on the same
channel before the response ā interleaved on stdout for stdio, as SSE
events for HTTP. A request that omits logLevel gets no notifications
at all: the spec says a server must not send them unasked, and the
gating is per request, so one client's log lines can never surface on
another's channel.
The same call against tools/list shows the cache metadata:
{
"jsonrpc": "2.0",
"id": 8,
"result": {
"tools": [ { "name": "greet", "description": "Greet someone by name", "inputSchema": { } } ],
"ttlMs": 3600000,
"cacheScope": "private",
"resultType": "complete",
"_meta": {
"io.modelcontextprotocol/serverInfo": { "name": "my-tools", "version": "1.2.0" }
}
}
}
Version negotiation
Alongside the standard JSON-RPC codes (-32700 parse error, -32600
invalid request, -32601 method not found, -32602 invalid params,
-32603 internal error), 2026-07-28 defines three of its own:
-32020header mismatch ā a transport's routing headers disagree with the body they claim to describe. Only the HTTP transport can raise this one.-32021missing required client capability ā exported as a constant for handlers and transports that need it. This server never raises it: it asks nothing of its clients (see "Not supported").-32022unsupported protocol version ā the requested version is not one this server speaks.
-32022 is the one clients are expected to recover from, so it always
carries the way out in data:
{
"jsonrpc": "2.0",
"id": 9,
"error": {
"code": -32022,
"message": "Unsupported protocol version",
"data": {
"requested": "2027-01-01",
"supported": ["2025-11-25", "2026-07-28"]
}
}
}
data.supported lists every version the server is configured for,
legacy ones included, so a client that guessed too high can drop back
rather than start over. A modern request with no _meta version at all
gets the same error with requested: null.
Modern-era server options
:protocol-versions restricts which eras an instance answers for; the
default is both. :discovery-ttl-ms and :cache-scope set the cache
metadata on the three catalog listings and on server/discover. An
hour and private are the defaults: registration all happens before
run, so the catalogs really are frozen for the life of the process and
an hour is honest, while private is the safe reading for a server
whose catalog might be user-specific.
my $server = MCP::Server.new(
:name<my-tools>,
:protocol-versions['2026-07-28',], # modern clients only
:discovery-ttl-ms(60_000), # catalogs are good for a minute
:cache-scope<public>, # ...and the same for everyone
);
Restricting the list is enforced in both directions, and each direction
gets the error that helps the client most. A modern-only server answers
initialize with -32022 rather than -32601, because the spec asks
it to name what it does speak:
{ "code": -32022, "message": "Unsupported protocol version",
"data": { "requested": "2025-11-25", "supported": ["2026-07-28"] } }
...and a :protocol-versions['2025-11-25',] server answers any request
carrying a modern _meta version with the mirror image of that. An
unknown version in the list is refused at construction time:
MCP::Server.new(:name<x>, :protocol-versions['2024-01-01',]);
# dies: Unknown protocol version(s) for MCP server 'x': 2024-01-01.
# Supported: 2025-11-25, 2026-07-28
Not supported
Three parts of 2026-07-28 are deliberately absent. Each is a consequence of what this framework is, not a gap to be filled in later:
Multi-round-trip requests (MRTR,
input_required). A server uses these to turn around and ask the client something mid-request ā sampling, roots, elicitation.MCP::Servernever initiates a request, so it has nothing to ask, which is exactly why every result it produces isresultType: "complete".subscriptions/listen. Subscriptions only mean something if the server emits change notifications, and this one emits none and advertises nolistChangedcapability. Rather than accept a subscription it would never honour, it answers-32601(404over HTTP) like any other method it does not have.Extensions.
server/discoverreports noextensionskey because there are none; the same goes for thex-mcp-headerparameter annotations, which would need a schema-annotation mechanism the tool registration API does not have.
Transport
Default transport is stdio (stdin/stdout), and it serves both eras.
Custom stream transports can be created by implementing the
MCP::Server::Transport role:
use MCP::Server::Transport;
class MyTransport does MCP::Server::Transport {
method read-message(--> Str) { ... }
method write-message(Str:D $msg) { ... }
}
$server.run(:transport(MyTransport.new));
run reads messages until the stream ends, dispatches each one, and
writes the responses back. Notifications raised while a request is in
flight are written on the same stream, ahead of the response they belong
to.
Building request/response transports
The role above is stream-shaped: two methods, no headers, no status
codes, no pairing of a request with its answer. A transport that speaks
in discrete request/response pairs ā the built-in Streamable HTTP
transport, a message queue, an in-process bridge ā wants a different
seam, and MCP::Server exposes four transport-free methods for it.
method handle-modern-request(%msg, :¬ify, Promise :$cancelled --> Hash)
method server-info(--> Hash) # { name, version }
method discovery-document(--> Hash) # server/discover's body, unframed
method modern-protocol-versions(--> List) # configured versions in the modern era
handle-modern-request takes a parsed message and returns the response
hash. Its contract:
The era is forced modern rather than detected, because a request/response transport has no session to hang a legacy one on. A legacy-shaped message therefore gets a modern answer:
-32601forinitialize/ping/logging/setLevel,-32022when_metahas no usable protocol version.A notification ā a body with no
idā is dispatched for its side effects and answered with an empty Hash, since JSON-RPC forbids replying to one. Transports turn that into whatever "accepted, nothing to say" looks like for them (202over HTTP).:¬ifyis called synchronously, on the calling thread, zero or more times, before the method returns. That is what lets a live stream carry a handler's log lines as they happen rather than after the fact. Leave it out and the request's notifications are dropped.:$cancelledis a Promise the transport keeps for "the caller has walked away". It reaches handlers through$*MCP-REQUEST-CONTEXT.cancelled.It touches no server state, so concurrent calls are safe as far as this class is concerned. Your handlers still have to be thread-safe themselves ā see the warning under "HTTP transport".
discovery-document is static for the life of the process, so a
transport may cache it (it carries its own ttlMs/cacheScope), and
modern-protocol-versions is what a transport validates a version
header against.
# A minimal in-process bridge.
my %response = $server.handle-modern-request(
{
jsonrpc => '2.0', id => 5, method => 'tools/call',
params => {
name => 'crunch', arguments => { },
_meta => {
'io.modelcontextprotocol/protocolVersion' => '2026-07-28',
'io.modelcontextprotocol/logLevel' => 'debug',
},
},
},
notify => -> %notification { @log.push: %notification },
);
The request context
$*MCP-REQUEST-CONTEXT is bound to an immutable
MCP::Server::Context around every dispatch, so a handler can reach it
without any change to its signature, and every thread sees its own
request's context. The two methods handlers actually want:
wants-log($level)ā would a message at$levelreach this client? Worth asking before building an expensive diagnostic string, since in the modern era the answer is no unless the request opted in through_metalogLevel.cancelledā has the caller given up? The server never interrupts a running handler, so a long loop that wants to be interruptible has to look.
era, protocol-version, log-level, client-info and
client-capabilities are readable too, for a handler that wants to
adapt to what the client said about itself.
$server.tool: 'crunch',
description => 'Chew through a big pile of work',
handler => -> :%args {
my $ctx = $*MCP-REQUEST-CONTEXT;
my $done = 0;
for @work -> $item {
last if $ctx.cancelled;
$server.log('debug', "item {$item.id}") if $ctx.wants-log('debug');
crunch($item);
$done++;
}
"crunched $done of {@work.elems}";
};
Outside a request ā during startup, say ā $*MCP-REQUEST-CONTEXT is
undefined, and $server.log falls back to the legacy transport-wide
channel. Logging always writes to $*ERR whatever else happens to it,
so nothing is ever lost just because no client was listening.
HTTP transport
MCP::Server::HTTP is the 2026-07-28 Streamable HTTP transport: one
POST-only endpoint, no sessions, no server-initiated stream, every
request self-describing. It is a sibling of the server rather than a
Transport role implementation, for the reasons in "Building
request/response transports" above.
use MCP::Server;
use MCP::Server::HTTP;
my $server = MCP::Server.new(:name<my-tools>, version => '1.0');
$server.tool: 'greet',
description => 'Greet someone by name',
params => { name => { type => 'string', required => True } },
handler => -> :%args { "Hello, {%args<name>}!" };
MCP::Server::HTTP.new(:$server, :port(8080)).run;
$ curl -s http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: greet' \
-d '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{
"name":"greet","arguments":{"name":"Ada"},
"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'
{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"Hello, Ada!"}],
"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{
"name":"my-tools","version":"1.0"}}}}
The three routing headers are what let a proxy route and rate-limit MCP
traffic without parsing bodies: MCP-Protocol-Version and
Mcp-Method are required on every request, and Mcp-Name is required
for tools/call, resources/read and prompts/get. All of them are
checked against the body, and disagreement is a 400 with -32020
rather than a silently preferred half of the request. A value that
cannot travel in a header as-is ā a resource URI with an accent in it,
say ā may use the =?base64?...?= sentinel, which is decoded before
the comparison.
Starting and stopping
startreturns as soon as the listener is up, so a test or a larger application can carry on. Calling it twice on one instance dies: one instance means one listener.stopshuts the listener down and lets in-flight responses finish. It is safe on an instance that was never started, which makes it usable as aLEAVEorENDhandler.runisstart, then block until SIGINT (or SIGTERM where the platform has one ā Windows does not), thenstop. This is what the CLI uses.
Options
:$server(required) ā theMCP::Serverwhose catalogs and handlers are exposed. Register everything beforestart: the transport treats it as frozen.:$hostā default127.0.0.1. See "Security" below before changing it.:$portā default8080. Must be 1..65535;0is refused rather than silently rebound to whatever the OS picks, since nothing could then tell you which port that was.:$pathā defaultmcp. Surrounding slashes are normalised away and more than one segment is fine, so'/api/mcp/'and'api/mcp'both servehttp://host:port/api/mcp.:@allowed-originsā extra browser origins to accept, as exactStrs orRegexes. Anything else dies at construction.:$allow-no-originā defaultTrue; whether a request with noOriginheader at all is accepted.:$keepaliveā default15seconds between SSE keepalive comments, which stop proxies and impatient clients from tearing down a stream whose handler is still thinking. Must be greater than zero.
From the command line
$ raku-mcp --tool=FileSystem={"root":"/docs"} --http=8080
$ raku-mcp --tool=FileSystem={"root":"/docs"} --http=8080 \
--host=0.0.0.0 --http-path=api/mcp \
--allow-origin=https://app.example.com --allow-origin=https://admin.example.com
The same settings live in the config file's optional http section, so
a --config server can serve HTTP without any flags at all:
{
"name": "my-tools",
"tools": { "FileSystem": { "root": "/docs" } },
"http": {
"port": 8080,
"host": "127.0.0.1",
"path": "api/mcp",
"allowedOrigins": ["https://app.example.com"]
}
}
Only those four keys are accepted, and each is validated as the file is
read ā a port outside 1..65535, an empty host, an allowedOrigins
that is not an array of strings ā so a typo is reported with the config
file's path attached rather than at some later point in startup.
Where both a flag and the file have something to say, the command line
wins: --http=9000 against the file above serves on 9000, keeping the
file's host and path. --allow-origin is the exception that adds
rather than replaces ā flags and file entries are concatenated, since
"also allow this origin" is what you meant. Merely having an http
section is enough to select the transport; --http=PORT on its own is
enough without one.
JSON or SSE
Whether a request comes back as one JSON object or as an event stream is decided lazily, per request:
A client whose
Acceptdoes not includetext/event-streamalways gets a singleapplication/jsonobject. Anything the handler logs goes no further than the server's own$*ERR; the spec leaves that choice to the server.A client that does accept
text/event-streamgets a stream only if the handler actually says something. The handler runs while the transport waits for whichever comes first: it finishing, or its first notification. Finishing first means a plain JSON response, so a quiet request never pays for a stream.Once committed, the stream carries the notifications as SSE events in the order they were raised, then the response as a final event, then closes.
X-Accel-Buffering: noandCache-Control: no-storego out with it.
Both conditions have to hold for a notification to reach a client: the
Accept header and the request's _meta logLevel opt-in. A
handler that logs on behalf of a request that never asked for logs
produces no events, and so no stream.
Calling a slow-count tool that logs "step $_" as it goes, with
both conditions met:
$ curl -sN http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' -H 'Mcp-Name: slow-count' \
-d '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{
"name":"slow-count","arguments":{"to":3},
"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/logLevel":"info"}}}'
data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","logger":"my-tools","message":"step 0"}}
data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","logger":"my-tools","message":"step 1"}}
data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","logger":"my-tools","message":"step 2"}}
data: {"jsonrpc":"2.0","id":8,"result":{"content":[{"type":"text","text":"counted to 3"}],"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"my-tools","version":"1.0"}}}}
Modern era only
HTTP speaks 2026-07-28 and nothing else. initialize, ping and
logging/setLevel answer -32601 here, and a request whose
MCP-Protocol-Version header is not a modern version this server is
configured for is refused with 400 and -32022 before its body is
even parsed. Stdio remains dual-era, so a legacy client is not locked
out of the framework ā only out of this transport.
One consequence worth knowing: every other method needs a matching
_meta protocol version in the body, since MCP-Protocol-Version is
compared against it and a header with nothing to match is a -32020.
server/discover is exempt ā the MCP-Protocol-Version header is
still required, but the body needs no _meta at all, since it is the
bootstrap probe a client sends before it can know what version to
claim.
Status codes
200ā a response, including one carrying a JSON-RPC error object. The error is the payload, not the transport's problem.202ā a well-formed notification (noid). Dispatched, no body, as JSON-RPC requires.400ā-32020header mismatch or missing routing header,-32022unsupported version,-32700unparseable body,-32600a JSON-RPC batch (the modern era sends one message per request).403āOriginrefused. Checked first, before anything with a side effect.404ā-32601, which covers unknown methods andsubscriptions/listen. A method that does not exist is a resource that does not exist.405ā anything but POST, withAllow: POST.415ā a body that is notapplication/json.500ā-32603, as a JSON-RPC object. The endpoint never answers with an HTML error page; an MCP client is owed JSON whatever went wrong.
Stray Mcp-Session-Id and Last-Event-ID headers ā artifacts of an
older Streamable HTTP shape that this transport never emits ā are
ignored outright rather than held against a client carrying them over.
Cancellation and thread safety
A client closing the response stream is the only cancellation signal
this protocol has. When it happens, $*MCP-REQUEST-CONTEXT.cancelled
flips to True and the eventual result is discarded. The server never
interrupts a running handler: a synchronous one that never looks runs
to completion, holding its thread. Anything long-running should poll.
$server.tool: 'reindex',
description => 'Rebuild the search index',
handler => -> :%args {
my $ctx = $*MCP-REQUEST-CONTEXT;
my $n = 0;
for @documents -> $doc {
last if $ctx.cancelled; # cheap, and the only way out
reindex-document($doc);
$n++;
}
"indexed $n of {@documents.elems} documents";
};
Handlers run concurrently. Cro dispatches each request on its thread
pool, so two calls to the same tool can be in flight at once ā which
never happened over stdio, where messages are handled one at a time. The
framework holds up its end (catalogs are frozen before start, the
modern path mutates no server state, each request's notifications are
routed to that request's channel, and $*ERR writes are locked so log
lines cannot shear), but shared state inside your handlers is yours
to protect. A tool that appends to a file, mutates a lexical hash, or
talks to a client library that is not thread-safe needs a Lock ā
before it is exposed over HTTP, not after.
Security
The endpoint speaks plain HTTP and binds 127.0.0.1 by default. Both
defaults are deliberate.
Origin policy. A loopback server is reachable from any web page the
user happens to have open, and DNS rebinding turns "reachable" into
"scriptable". The Origin header is the defence, and the policy is:
No
Originat all ā allowed by default (:allow-no-origin). Non-browser MCP clients do not send one, and a browser always attaches one to the cross-origin POST an attack would have to use, so this admits the clients that cannot be attacked this way without admitting the ones that can. Set:!allow-no-originif every client of yours is a browser.http(s)://localhost,http(s)://127.0.0.1,http(s)://[::1], any port ā always allowed. This is the page-under-development case.Anything else ā refused with
403unless it is in:@allowed-origins.
MCP::Server::HTTP.new(
:$server,
allowed-origins => [
'https://app.example.com', # exact
/^ 'https://' \w+ '.example.com' $/, # or a pattern
],
).run;
Origin is not authentication ā a non-browser client can send whatever
it likes. It stops a web page from using the browser's own access to a
loopback port; that is all it is for.
Binding. 127.0.0.1 means no firewall prompts and no accidental
exposure. Changing :host to 0.0.0.0 publishes an unauthenticated
endpoint that can run every tool you registered ā do it only behind a
reverse proxy that terminates TLS and authenticates, and read the auth
hook below first.
TLS. There is no TLS here; terminate it in front. The one thing a
proxy must get right is not buffering the event stream ā the transport
sends X-Accel-Buffering: no, which nginx honours, but say it plainly
anyway:
# nginx
location /mcp {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Origin $http_origin; # the allow-list needs to see it
proxy_buffering off; # SSE must not be buffered
proxy_read_timeout 1h; # a long-running tool is not a hang
}
# Caddy
mcp.example.com {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1 # flush immediately: same reason as proxy_buffering off
}
}
Forward the browser's Origin as-is, and set :allowed-origins to
the origins your front end really uses ā the proxy's own hostname is not
one of them.
Authentication. First-class auth is not built in, and .routes is
the supported way to add your own. It hands back the endpoint as a
Cro::Transform that can be mounted inside a larger Cro application,
where any middleware you like gets a look at the request first:
use Cro::HTTP::Router;
use Cro::HTTP::Server;
use MCP::Server::HTTP;
my $mcp = MCP::Server::HTTP.new(:$server, :port(8080));
my $app = route {
before {
unless (request.header('Authorization') // '') eq "Bearer $token" {
response.status = 401;
content 'application/json', { error => 'unauthorized' };
}
}
include $mcp.routes; # the MCP endpoint, still at /mcp
get -> 'healthz' { content 'text/plain', 'ok' }
}
Cro::HTTP::Server.new(:host<127.0.0.1>, :port(8080), application => $app).start;
Note that .routes is used instead of .start/.run here ā the
surrounding Cro::HTTP::Server owns the listener, so the transport
must not open one of its own. The routes only match the endpoint's own
path, so everything else in the block (/healthz above) still works.
Author
Matt Doughty
License
Artistic-2.0