=begin pod

=head1 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
(C<2025-11-25> and C<2026-07-28>), over stdio or Streamable HTTP.

=head2 Synopsis

=begin code :lang<raku>
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
=end code

Use with Claude Code:

=begin code :lang<json>
{
  "mcpServers": {
    "my-tools": {
      "command": "raku",
      "args": ["-I", "lib", "examples/my-server.raku"]
    }
  }
}
=end code

=head2 Tools

Tools are functions the LLM can call. Define parameters with types and
descriptions — the framework generates JSON Schema automatically.

=begin code :lang<raku>
$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>}..."
    };
=end code

Tool handlers receive C<:%args> and return:
=item C<Str> — wrapped as C<[{type: "text", text: $result}]>
=item C<List> — passed through as content items
=item On exception — returned as C<isError: true> content

=head2 Tool Groups

Group related tools under a common prefix. Names are joined with C<_>
(underscore), not C</>:

=begin code :lang<raku>
$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
=end code

The separator matters because the MCP spec restricts tool names to
C<[A-Za-z0-9_-]>, at most 128 characters — a C</> 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:

=begin code :lang<raku>
$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>)
=end code

The same rules apply to prompts (duplicate names) and resources
(duplicate URIs).

=head2 Toolkits

A toolkit is a self-contained bundle of tools, prompts, and resources
that plugs into any C<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.

=head3 The C<MCP::Server::Toolkit> role

=begin code :lang<raku>
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
}
=end code

=item C<register($registrar)> — required. Register everything the
toolkit provides by calling C<.tool>, C<.prompt>, and C<.resource> on the
supplied registrar; its method signatures match C<MCP::Server>'s own.
=item C<default-prefix> — optional. The prefix applied when C<.plug> is
called with no explicit C<:prefix>. The default implementation returns an
undefined C<Str>, meaning "no prefix unless the caller asks for one".
=item C<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. A C<prefix> key inside C<%config> is rejected as
reserved — it belongs beside the entry when plugging or in a C<:tools>
list, not inside the toolkit's own config.

Here is a complete worked example — everything a small toolkit
distribution needs:

=begin code :lang<raku>
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") };
}
=end code

=begin code :lang<raku>
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
=end code

=head3 Prefixing rules

C<$server.plug($kit, :prefix<...>)> namespaces everything the toolkit
registers:

=item Tool and prompt names become C<"{prefix}_{name}">.
=item Resource URIs get the prefix injected as the first path segment.
C<scheme://path> becomes C<scheme://prefix/path>; a bare (no C<://>) URI
becomes C<prefix/path>. Leading slashes on the remainder are stripped so
authority-less URIs don't end up with an empty path segment:
C<file:///etc/hosts> with prefix C<docs> becomes
C<file://docs/etc/hosts>, not C<file://docs//etc/hosts>.

Precedence for which prefix applies: an explicit C<:prefix> argument to
C<.plug> beats the toolkit's own C<default-prefix>, which beats no prefix
at all.

=begin code :lang<raku>
$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
=end code

Plugging two toolkits (or the same toolkit twice) that would produce the
same name dies with a message suggesting a C<:prefix>, exactly like the
collision case in "Tool Groups" above:

=begin code :lang<raku>
$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
=end code

C<:sep> overrides the character joining prefix and name (default C<_>).
Since MCP tool names only allow C<[A-Za-z0-9_-]>, the only other sensible
choice is C<->:

=begin code :lang<raku>
$server.plug: $kit, :prefix<kit>, :sep<->;
# kit-greet instead of kit_greet
=end code

=head3 mcp classes are toolkits too

Any class declared with the C<mcp> DSL (C<MCP::Server::DSL>) already does
C<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 C<#|> comment as the description) and every public
attribute becomes a read-only tool that reports its value:

=begin code :lang<raku>
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
=end code

An C<mcp> class's own standalone server — built lazily by C<.server> and
used internally by C<.run>, C<.tools-for-llm>, C<.execute-tool-calls>, and
C<.handle-request> — is entirely separate from any server it gets plugged
into; plugging doesn't consume or replace it, and C<.server> is only ever
built the first time something needs it. C<register>, C<server>,
C<from-config>, and C<default-prefix> are the method names the DSL's
machinery relies on, so an C<mcp> class must not declare its own methods
with those names — doing so shadows the framework instead of adding a
tool. C<from-config> still works on C<mcp> classes exactly as it does on
any other toolkit, deriving its schema from the class's public
attributes; the private C<$!server> attribute is never a valid config
key.

=head2 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).

=begin code :lang<raku>
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;
=end code

C<tools-for-llm> converts registered tools to the OpenAI function-calling
format, sorted by name so the declarations a prompt carries are stable
between runs. C<execute-tool-calls> routes the LLM's tool call requests to
your registered handlers and returns results ready to send back. Tool
results include C<role>, C<tool_call_id>, C<content>, and C<is_error>.

Each call's C<arguments> may be a hash or the JSON string most APIs
actually send; an empty or blank string means "no arguments", which is
what models send for a tool that takes none. Anything else that is not a
JSON object comes back as a result with C<is_error> set, never as an
exception.

=head2 Resources

Resources expose data the LLM can read.

=begin code :lang<raku>
$server.resource: 'config://app',
    name => 'App Config',
    description => 'Application configuration',
    mime-type => 'application/json',
    handler => -> :%args { '{"debug": true}' };
=end code

=head3 Cacheability

C<:ttl-ms> and C<: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
C<ttlMs: 0> (do not cache) and C<cacheScope: "private">.

=begin code :lang<raku>
# 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 };
=end code

A modern C<resources/read> then carries the hints on the result:

=begin code
config://app      { "ttlMs": 0,        "cacheScope": "private" }
docs://handbook   { "ttlMs": 86400000, "cacheScope": "public"  }
=end code

C<:ttl-ms> must be zero or more and C<:cache-scope> must be C<public> or
C<private>, or registration dies naming the offending resource. Both
options are also accepted by the registrar a toolkit's C<register> is
handed, so a pack can declare cacheability for its own resources and
have it survive prefixing. Legacy clients never see either field.

=head2 Prompts

Prompts are pre-defined templates for the user.

=begin code :lang<raku>
$server.prompt: 'review',
    description => 'Code review prompt',
    arguments => [
        { name => 'code', description => 'Code to review', required => True },
    ],
    handler => -> :%args { "Please review:\n{%args<code>}" };
=end code

=head2 One-shot setup

C<MCP::Server.new(:tools[...])> builds and plugs toolkits declaratively
instead of calling C<.plug> once per toolkit by hand. Each entry in the
list may be:

=item A bare or fully-qualified toolkit name (C<Str>) — instantiated via
C<.from-config({})> (or C<.new> if the toolkit doesn't provide
C<from-config>) and plugged with no config and no explicit prefix.
=item A C<Pair> of name/instance C<=> config hash — the config's reserved
C<prefix> key, if present, is pulled out and passed to C<.plug>; the rest
goes to C<.from-config>. When the key side is already an instance, the
config hash may contain only C<prefix> — anything else dies, since an
instance is already built and has nothing left to configure.
=item A toolkit instance on its own — plugged as-is, unprefixed (unless
it declares a C<default-prefix>).

Name resolution matches C<load-toolkit-class>: a bare name (no C<::>)
resolves under C<MCP::Server::Tool::>, so C<'FileSystem'> loads
C<MCP::Server::Tool::FileSystem>; a name containing C<::> 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 C<zef install> command to fix it.

=begin code :lang<raku>
my $server = MCP::Server.new(
    :name<my-tools>,
    :tools[
        'Echo',
        FileSystem => { root => '/data', prefix => 'docs' },
    ],
);
=end code

Loading the same toolkit twice under different prefixes is exactly what
you'd expect — repeat the entry with different config:

=begin code :lang<raku>
:tools[
    FileSystem => { root => '/docs',  prefix => 'docs'  },
    FileSystem => { root => '/build', prefix => 'build' },
],
=end code

=head2 Config files

C<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 C<:tools> does, in a form that doesn't require a line of
Raku:

=begin code :lang<json>
{
  "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"] }
  }
}
=end code

=begin code :lang<raku>
my $server = MCP::Server.from-config('server-config.json');
$server.run;
=end code

Keys ending in C<#alias> (like C<FileSystem#build> above) load the same
toolkit more than once with different settings: the part before C<#> is
the toolkit name, and the whole key — alias included — just has to be
unique inside the C<tools> object, since JSON object keys can't repeat on
their own. The reserved C<prefix> key inside each toolkit's config object
works exactly as it does for C<:tools> — it's pulled out and passed to
C<.plug>, and C<from-config> on the toolkit itself never sees it.

Top-level keys are limited to C<name>, C<version>, C<instructions>,
C<tools>, and C<http> (the last of which turns on the Streamable HTTP
transport — see "HTTP transport" below). C<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 C<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.

=head2 raku-mcp

Installing this distribution also installs a C<raku-mcp> command — a
thin CLI front end (C<MCP::Server::CLI>) for assembling a server out of
toolkit packs without writing any Raku at all:

=begin code
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
=end code

=item C<--config=PATH> — load a JSON config file (see "Config files"
above) and run it.
=item C<--tool=SPEC> — load one toolkit and run; repeatable to load
several. C<SPEC> is a bare or qualified toolkit name, optionally followed
by C<=> and an inline JSON config object. A C<"prefix"> key inside that
JSON namespaces the toolkit, same as everywhere else.
=item C<--name=STR>, C<--version=STR>, C<--instructions=STR> — set the
server's identity when using C<--tool> (meaningless with C<--config>,
which takes these from the file instead).
=item C<--http=PORT> — serve the Streamable HTTP transport on C<PORT>
instead of stdio. C<--host=ADDR>, C<--http-path=PATH>, and the
repeatable C<--allow-origin=ORIGIN> configure it; see "HTTP transport"
below for what each one does and for the equivalent config-file section.
=item C<--describe=SPEC> — print a toolkit's config schema and exit,
without starting a server.
=item C<--help> — print usage and exit.

C<--config> and C<--tool> are mutually exclusive with each other and
with C<--describe>. Every flag is C<--name=value>; there is no
space-separated C<--name value> form. C<--host>, C<--http-path>, and
C<--allow-origin> only mean anything to the HTTP transport, so passing
one without C<--http> (and without a C<--config> whose file might turn
HTTP on) is an error rather than a setting that quietly does nothing.

=begin code
$ raku-mcp --tool=FileSystem={"root":"/docs"} --tool=Shell={"allow":["git"]}
$ raku-mcp --config=server-config.json
=end code

C<--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:

=begin code
$ raku-mcp --describe=FileSystem
Toolkit: MCP::Server::Tool::FileSystem
Config:
  root  IO::Path()  required
=end code

Adding C<={...}> — even C<={}> — 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 C<tools/list> etc. against:

=begin code
$ raku-mcp --describe=FileSystem={"root":"/docs"}
Toolkit: MCP::Server::Tool::FileSystem
Config:
  root  IO::Path()  required

Tools:
  read - Read the contents of a file
  ...
=end code

All diagnostics — usage errors, unloadable toolkits, malformed JSON —
go to stderr; stdout carries only C<--help>/C<--describe> output and,
once C<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 C<--config=...>
token — C<parse-args> doesn't accept a separate value argument):

=begin code :lang<json>
{
  "mcpServers": {
    "my-tools": {
      "command": "raku-mcp",
      "args": ["--config=/absolute/path/to/server-config.json"]
    }
  }
}
=end code

=head2 Writing a tool pack

A tool pack is its own zef distribution, named C<MCP::Server::Tool::*>
(e.g. C<MCP::Server::Tool::FileSystem>), providing one class that does
C<MCP::Server::Toolkit>. Conventions that keep a pack interchangeable
with C<:tools>, C<from-config>, and C<raku-mcp --describe>:

=item Namespace as C<MCP::Server::Tool::Name>, one toolkit class per
distribution, so bare-name resolution (C<'Name'> resolving to
C<MCP::Server::Tool::Name>) works without qualification.
=item C<does MCP::Server::Toolkit>, implementing C<register($registrar)>
and, when the pack should namespace by default, C<default-prefix>.
=item Config is public attributes — nothing more. C<from-config> derives
its accepted keys directly from C<self.^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 (C<IO::Path()>) or defaults
behave exactly as they would on any other class.
=item 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 C<is required>. C<from-config> then 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.
=item Depend on C<"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 C<:tools> entry, a C<.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:

=item C<MCP::Server::Tool::FileSystem> — root-confined file access.
=item C<MCP::Server::Tool::Shell> — allowlisted command runner.

=head2 Examples

=item C<examples/echo-server.raku> — Minimal echo and reverse tools
=item C<examples/weather-server.raku> — Weather via wttr.in
=item C<examples/file-server.raku> — File system tools using tool groups
=item C<examples/strawberry-server.raku> — The tool LLMs wish they had
=item C<examples/toolkit-server.raku> — Plugging installed tool packs
  (FileSystem, Shell) into one server alongside an inline tool
=item C<examples/server-config.json> — A JSON config for C<from-config>,
  including an aliased toolkit entry

=head2 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:

=item C<2025-11-25> — the B<legacy> era. Session-shaped: the client opens
with C<initialize>, follows up with C<notifications/initialized>, and the
connection then carries state (whether the handshake finished, what log
level was asked for) until it hangs up.
=item C<2026-07-28> — the B<modern> era. Stateless: there is no
handshake and no session, every request describes itself through
C<params._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
C<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.

=head3 Which era a message belongs to

Four rules, applied per message, in this order:

=item 1. C<initialize> is B<always> legacy. The method does not exist in
2026-07-28 at all, so a stray C<_meta> on it is ignored rather than
treated as a contradiction.
=item 2. C<server/discover> is B<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 C<_meta> whatsoever.
=item 3. Otherwise, a C<params._meta> carrying
C<io.modelcontextprotocol/protocolVersion> makes the message modern.
=item 4. Anything else is legacy.

=begin code :lang<raku>
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
=end code

=head3 The legacy era (2025-11-25)

=item C<initialize> / C<notifications/initialized> handshake, answering
with the server's capabilities — C<tools>, C<resources>, C<prompts> for
whatever is actually registered, plus C<logging>.
=item C<tools/list>, C<tools/call>, C<resources/list>, C<resources/read>,
C<prompts/list>, C<prompts/get>.
=item C<ping>, answered with an empty result.
=item C<logging/setLevel>, which sets the session's minimum severity.
Levels are the RFC 5424 eight (C<debug>, C<info>, C<notice>, C<warning>,
C<error>, C<critical>, C<alert>, C<emergency>); anything else comes back
as C<-32602> listing the ones that work. The advertised C<logging>
capability is therefore honest — earlier releases advertised it without
implementing the method.
=item Logging via C<notifications/message>, emitted on the transport
once the client has said it is initialized and only at or above the
level it asked for.

=head3 The modern era (2026-07-28)

=item C<server/discover> replaces the handshake. It reports
C<supportedVersions>, C<capabilities>, the server's C<instructions> if it
has any, and the C<ttlMs>/C<cacheScope> for which those answers hold.
=item Every request must carry
C<params._meta.io.modelcontextprotocol/protocolVersion>;
C<...clientInfo> and C<...clientCapabilities> are optional, and
C<...logLevel> opts that one request into log notifications. The one
exception is C<server/discover>, which is how a client finds out what to
put there in the first place.
=item Every result carries C<resultType>. It is C<complete> unless the
handler stopped to ask the client something, in which case the round is
answered C<input_required> — see "Elicitation" below.
=item Every result carries C<_meta.io.modelcontextprotocol/serverInfo>.
=item C<tools/list>, C<prompts/list>, C<resources/list> and
C<resources/read> carry C<ttlMs> and C<cacheScope>. The three listings
use the server-wide values (see "Modern-era server options"); a read uses
whatever the resource declared (see "Cacheability" above).
=item 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.
=item C<initialize>, C<ping> and C<logging/setLevel> are gone, and answer
C<-32601> to a modern caller. Liveness belongs to the transport now, and
the log level travels per request.

A modern C<tools/call> and the answer it gets:

=begin code :lang<json>
{
  "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"
    }
  }
}
=end code

=begin code :lang<json>
{
  "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" }
    }
  }
}
=end code

Because that request opted in at C<info>, anything the handler logs at
C<info> or above is delivered as a C<notifications/message> on the same
channel before the response — interleaved on stdout for stdio, as SSE
events for HTTP. A request that omits C<logLevel> gets no notifications
at all: the spec says a server B<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 C<tools/list> shows the cache metadata:

=begin code :lang<json>
{
  "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" }
    }
  }
}
=end code

=head3 Version negotiation

Alongside the standard JSON-RPC codes (C<-32700> parse error, C<-32600>
invalid request, C<-32601> method not found, C<-32602> invalid params,
C<-32603> internal error), 2026-07-28 defines three of its own:

=item C<-32020> B<header mismatch> — a transport's routing headers
disagree with the body they claim to describe. Only the HTTP transport
can raise this one.
=item C<-32021> B<missing 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").
=item C<-32022> B<unsupported protocol version> — the requested version
is not one this server speaks.

C<-32022> is the one clients are expected to recover from, so it always
carries the way out in C<data>:

=begin code :lang<json>
{
  "jsonrpc": "2.0",
  "id": 9,
  "error": {
    "code": -32022,
    "message": "Unsupported protocol version",
    "data": {
      "requested": "2027-01-01",
      "supported": ["2025-11-25", "2026-07-28"]
    }
  }
}
=end code

C<data.supported> lists B<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 C<_meta> version at all
gets the same error with C<requested: null>.

=head3 Modern-era server options

C<:protocol-versions> restricts which eras an instance answers for; the
default is both. C<:discovery-ttl-ms> and C<:cache-scope> set the cache
metadata on the three catalog listings and on C<server/discover>. An
hour and C<private> are the defaults: registration all happens before
C<run>, so the catalogs really are frozen for the life of the process and
an hour is honest, while C<private> is the safe reading for a server
whose catalog might be user-specific.

=begin code :lang<raku>
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
);
=end code

Restricting the list is enforced in both directions, and each direction
gets the error that helps the client most. A modern-only server answers
C<initialize> with C<-32022> rather than C<-32601>, because the spec asks
it to name what it does speak:

=begin code :lang<json>
{ "code": -32022, "message": "Unsupported protocol version",
  "data": { "requested": "2025-11-25", "supported": ["2026-07-28"] } }
=end code

...and a C<:protocol-versions['2025-11-25',]> server answers any request
carrying a modern C<_meta> version with the mirror image of that. An
unknown version in the list is refused at construction time:

=begin code :lang<raku>
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
=end code

=head3 Not supported

Two 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:

=item B<C<subscriptions/listen>>. Subscriptions only mean something if
the server emits change notifications, and this one emits none and
advertises no C<listChanged> capability. Rather than accept a
subscription it would never honour, it answers C<-32601> (C<404> over
HTTP) like any other method it does not have.
=item B<Extensions>. C<server/discover> reports no C<extensions> key
because there are none; the same goes for the C<x-mcp-header> parameter
annotations, which would need a schema-annotation mechanism the tool
registration API does not have.

=head2 Transport

Default transport is stdio (stdin/stdout), and it serves both eras.
Custom stream transports can be created by implementing the
C<MCP::Server::Transport> role:

=begin code :lang<raku>
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));
=end code

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

=head3 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 C<MCP::Server> exposes four transport-free methods for it.

=begin code :lang<raku>
method handle-modern-request(%msg, :&notify, 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
=end code

C<handle-modern-request> takes a parsed message and returns the response
hash. Its contract:

=item The era is B<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: C<-32601> for
C<initialize>/C<ping>/C<logging/setLevel>, C<-32022> when C<_meta> has no
usable protocol version.
=item A notification — a body with no C<id> — is dispatched for its side
effects and answered with an B<empty Hash>, since JSON-RPC forbids
replying to one. Transports turn that into whatever "accepted, nothing
to say" looks like for them (C<202> over HTTP).
=item C<:&notify> is called B<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.
=item C<:$cancelled> is a Promise the transport keeps for "the caller has
walked away". It reaches handlers through
C<$*MCP-REQUEST-CONTEXT.cancelled>.
=item It touches no server state, so concurrent calls are safe as far as
this class is concerned. B<Your handlers still have to be thread-safe
themselves> — see the warning under "HTTP transport".

C<discovery-document> is static for the life of the process, so a
transport may cache it (it carries its own C<ttlMs>/C<cacheScope>), and
C<modern-protocol-versions> is what a transport validates a version
header against.

=begin code :lang<raku>
# 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 },
);
=end code

=head3 The request context

C<$*MCP-REQUEST-CONTEXT> is bound to an immutable
C<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:

=item C<wants-log($level)> — would a message at C<$level> reach 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 C<_meta> C<logLevel>.
=item C<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.
=item C<elicit($message, ...)> — ask the human on the other end of the
client a question and block until they answer. See "Elicitation" below.
=item C<can-elicit> — is there anybody to ask? Worth checking in a
handler that can work either way, rather than catching C<elicit>'s
exception.

C<era>, C<protocol-version>, C<log-level>, C<client-info> and
C<client-capabilities> are readable too, for a handler that wants to
adapt to what the client said about itself.

=begin code :lang<raku>
$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}";
    };
=end code

Outside a request — during startup, say — C<$*MCP-REQUEST-CONTEXT> is
undefined, and C<$server.log> falls back to the legacy transport-wide
channel. Logging always writes to C<$*ERR> whatever else happens to it,
so nothing is ever lost just because no client was listening.

=head3 Notifications that are not log lines

C<$server.notify(%notification)> is the delivery half of C<log> on its
own: it takes a whole notification — C<< { method => ..., params => ... } >>,
as C<MCP::Server::Protocol>'s C<notification> sub builds it — routes it
exactly as C<log> would, and returns C<True> if some channel took it.

=begin code :lang<raku>
use MCP::Server::Protocol;

$server.notify(notification('notifications/progress', {
    progressToken => $token, progress => $done, total => @work.elems,
}));
=end code

The two things it does not do are the point of it. It never echoes to
C<$*ERR>, so a background job streaming its output to a client does not
also spray the host process's terminal; and it applies no level gating,
because C<notifications/progress> has no level to gate on and a job's
output should not be silenced by a C<logging/setLevel> made for log
lines. B<Gating is therefore the caller's job>: a C<notifications/message>
raised this way should be sent only when
C<$*MCP-REQUEST-CONTEXT.wants-log($level)> agrees, since the 2026-07-28
rule that a server B<must not> log for a request that did not opt in is
not enforced here.

It is safe to call from any thread — the bundled transports serialise
their writes — which is what makes it usable from a C<Supply.interval>
flusher or a worker that outlives the request that started it. A modern
request's notification still goes to that request's channel and nowhere
else; out of a request the legacy rules apply, so nothing is sent before
the client has said it is initialized.

The bridge is the one exception to "outside a request": a tool called
through C<execute-tool-calls> gets a legacy-era context with no
notification sink, so a handler that reads the context works there too
rather than dying on an undefined dynamic variable.

=head2 Elicitation

A handler can stop in the middle of its work and ask the human on the
other end of the client a question:

=begin code :lang<raku>
$server.tool: 'deploy',
    description => 'Deploy the current build',
    handler => -> :%args {
        my $answer = $*MCP-REQUEST-CONTEXT.elicit(
            'Which environment should I deploy to?',
            properties => {
                env => {
                    type        => 'string',
                    description => 'Target environment',
                    enum        => <staging production>.List,
                },
                notes => { type => 'string', description => 'Release notes' },
            },
            required => ['env',],
        );

        # A refusal is an answer, not a malfunction.
        return 'Nothing deployed.' unless $answer.accepted;

        deploy($answer.content<env>, notes => $answer.content<notes> // '');
        "Deployed to {$answer.content<env>}.";
    };
=end code

C<elicit> returns an C<MCP::Server::Elicitation::Outcome>: C<action>
(C<accept>, C<decline> or C<cancel>), the predicates C<accepted>,
C<declined> and C<cancelled>, and C<content> — the form the user filled
in, populated only for an accept. Nothing here throws when the user says
no, because a user saying no is the system working.

Ask more than once and each question is a separate round trip; ask from
several threads at once and they go out together in one.

=head3 What goes on the wire

Elicitation is the 2026-07-28 B<multi round-trip request> pattern (MRTR).
The call the handler is serving is answered with a question rather than a
result:

=begin code :lang<json>
{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "q1": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "Which environment should I deploy to?",
          "requestedSchema": {
            "type": "object",
            "properties": {
              "env": { "type": "string", "enum": ["staging", "production"] },
              "notes": { "type": "string", "description": "Release notes" }
            },
            "required": ["env"]
          }
        }
      }
    },
    "requestState": "mrtr-3-1f0c…",
    "_meta": { "io.modelcontextprotocol/serverInfo": { } }
  }
}
=end code

The client asks its user and sends the B<same request again> — a new
JSON-RPC id, the original C<params>, plus the answers keyed by the
server's own ids and the C<requestState> echoed back byte for byte:

=begin code :lang<json>
{
  "jsonrpc": "2.0",
  "id": 8,
  "method": "tools/call",
  "params": {
    "name": "deploy",
    "arguments": { },
    "inputResponses": {
      "q1": { "action": "accept", "content": { "env": "staging" } }
    },
    "requestState": "mrtr-3-1f0c…",
    "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }
  }
}
=end code

Only C<tools/call>, C<resources/read> and C<prompts/get> may be answered
this way — the spec names those three and no others — and only for a
client that declared C<elicitation: { form: {} }> in its C<_meta>
C<clientCapabilities>. A handler that asks anyway gets an exception
saying which of the two conditions failed, which for a tool becomes an
C<isError> result. Check C<can-elicit> first if the handler can do
something useful without an answer.

Each round is an independent request with its own notification channel,
its own C<logLevel> opt-in and its own cancellation signal, and the
context delegates accordingly: a log line raised after the handler
resumes goes out on the round that resumed it. The original request's
disconnection is B<not> treated as cancellation — it was answered the
moment the call parked, so its stream closing is expected.

=head3 How the handler survives the gap

The handler is B<parked>, not restarted. It runs on a thread of its own
and blocks in C<await>, which inside a C<start> block suspends the
continuation without pinning a pool thread. Everything it had already
done stays done — which a restart-based implementation could not
promise, since it would re-run every side effect up to the question.

Two consequences worth knowing about:

=item A parked call does not survive a restart of the process. The token
names an in-memory rendezvous, not a serialised continuation.
=item Behind a load balancer, the retry must reach the instance that
asked. Route on C<requestState> or pin the session.

Only a modern-era request, on one of the three eligible methods, from a
client that declared the capability is dispatched onto its own thread.
Everything else — stdio, legacy clients, clients that declared nothing —
runs synchronously on the caller's thread exactly as it always has.

=head3 Timeouts, TTL and capacity

=item C<< $ctx.elicit(..., :timeout(30)) >> bounds one question: when it
runs out the question is withdrawn and the answer is a C<cancel>.
=item C<< MCP::Server.new(:elicitation-ttl(300)) >> bounds the wait for
a client that never comes back. When it expires the call is abandoned:
C<elicit> throws inside the handler, which unwinds through its own error
path, and the token stops working.
=item C<< MCP::Server.new(:max-pending-elicitations(64)) >> bounds how
many calls may sit parked at once. Past that, asking is refused with an
error result rather than queued behind clients that may never answer.
=item C<.pending-elicitations> reports how many are parked right now,
for a health endpoint or a test.

=head3 When there is no client to ask

C<:&on-elicit> is the fallback for the paths where the "client" cannot be
asked anything: a legacy-era client, the LLM tool bridge, or a handler
called directly from your own code. It takes the same ElicitRequest the
client would have received and returns an ElicitResult:

=begin code :lang<raku>
my $server = MCP::Server.new(
    :name<deployer>,
    on-elicit => -> %request {
        say %request<params><message>;
        my $answer = prompt('> ');
        $answer.defined && $answer.trim.chars
            ?? { action => 'accept', content => { env => $answer.trim } }
            !! { action => 'decline' };
    },
);
=end code

Anything it returns that is not an ElicitResult is read as a decline. A
modern client that declared the capability always wins over it: the human
at the other end of the protocol is a better answer than a callback in
the server process.

One caveat on that example: C<prompt> reads stdin and writes stdout, so
it only works in a server that does not own them. Over stdio those two
are the protocol, and writing a question to stdout corrupts it — a stdio
server's fallback has to ask somewhere else (a GUI, a queue, a policy
table), or the server has to run over HTTP, as
C<MCP::Server::Tool::Ask>'s example does.

=head3 A note on the token

C<requestState> is a monotonic counter plus 128 bits from C<rand>. Raku's
core has no CSPRNG, and C<rand> is a Mersenne Twister, so a token is not
unguessable to an attacker who has seen a few of them. What guessing one
buys is the ability to answer somebody else's question once, within the
TTL, on the right method — no data comes back out — and it is defended in
depth: claims are single use, pinned to the method that minted the token,
bounded by the TTL and capped in number. Binding to a real CSPRNG is a
tracked follow-up rather than something this release pretends to have.

=head2 HTTP transport

C<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
C<Transport> role implementation, for the reasons in "Building
request/response transports" above.

=begin code :lang<raku>
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;
=end code

=begin code
$ 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"}}}}
=end code

The three routing headers are what let a proxy route and rate-limit MCP
traffic without parsing bodies: C<MCP-Protocol-Version> and
C<Mcp-Method> are required on every request, and C<Mcp-Name> is required
for C<tools/call>, C<resources/read> and C<prompts/get>. All of them are
checked against the body, and disagreement is a C<400> with C<-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 C<=?base64?...?=> sentinel, which is decoded before
the comparison.

=head3 Starting and stopping

=item C<start> returns 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.
=item C<stop> shuts the listener down and lets in-flight responses
finish. It is safe on an instance that was never started, which makes it
usable as a C<LEAVE> or C<END> handler.
=item C<run> is C<start>, then block until SIGINT (or SIGTERM where the
platform has one — Windows does not), then C<stop>. This is what the
CLI uses.

=head3 Options

=item C<:$server> (required) — the C<MCP::Server> whose catalogs and
handlers are exposed. Register everything before C<start>: the transport
treats it as frozen.
=item C<:$host> — default C<127.0.0.1>. See "Security" below before
changing it.
=item C<:$port> — default C<8080>. Must be 1..65535; C<0> is refused
rather than silently rebound to whatever the OS picks, since nothing
could then tell you which port that was.
=item C<:$path> — default C<mcp>. Surrounding slashes are normalised
away and more than one segment is fine, so C<'/api/mcp/'> and
C<'api/mcp'> both serve C<http://host:port/api/mcp>.
=item C<:@allowed-origins> — extra browser origins to accept, as exact
C<Str>s or C<Regex>es. Anything else dies at construction.
=item C<:$allow-no-origin> — default C<True>; whether a request with no
C<Origin> header at all is accepted.
=item C<:$keepalive> — default C<15> seconds 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.
=item C<:$disconnect-poll> — default C<0.25> seconds between the
client-liveness probes that turn a hangup into
C<$*MCP-REQUEST-CONTEXT.cancelled>. See "Cancellation and thread safety"
below. Must be greater than zero.

=head3 From the command line

=begin code
$ 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
=end code

The same settings live in the config file's optional C<http> section, so
a C<--config> server can serve HTTP without any flags at all:

=begin code :lang<json>
{
  "name": "my-tools",
  "tools": { "FileSystem": { "root": "/docs" } },
  "http": {
    "port": 8080,
    "host": "127.0.0.1",
    "path": "api/mcp",
    "allowedOrigins": ["https://app.example.com"]
  }
}
=end code

Only those four keys are accepted, and each is validated as the file is
read — a port outside 1..65535, an empty C<host>, an C<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, B<the command line
wins>: C<--http=9000> against the file above serves on 9000, keeping the
file's host and path. C<--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 C<http>
section is enough to select the transport; C<--http=PORT> on its own is
enough without one.

=head3 JSON or SSE

Whether a request comes back as one JSON object or as an event stream is
decided lazily, per request:

=item A client whose C<Accept> does not include C<text/event-stream>
always gets a single C<application/json> object. Anything the handler
logs goes no further than the server's own C<$*ERR>; the spec leaves
that choice to the server.
=item A client that does accept C<text/event-stream> gets a stream B<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.
=item 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. C<X-Accel-Buffering: no> and C<Cache-Control: no-store> go out
with it.

Both conditions have to hold for a notification to reach a client: the
C<Accept> header B<and> the request's C<_meta> C<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 C<slow-count> tool that logs C<"step $_"> as it goes, with
both conditions met:

=begin code
$ 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","data":"step 0"}}

data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","logger":"my-tools","data":"step 1"}}

data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","logger":"my-tools","data":"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"}}}}
=end code

=head3 Modern era only

HTTP speaks 2026-07-28 and nothing else. C<initialize>, C<ping> and
C<logging/setLevel> answer C<-32601> here, and a request whose
C<MCP-Protocol-Version> header is not a modern version this server is
configured for is refused with C<400> and C<-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
C<_meta> protocol version in the body, since C<MCP-Protocol-Version> is
compared against it and a header with nothing to match is a C<-32020>.
C<server/discover> is exempt — the C<MCP-Protocol-Version> header is
still required, but the body needs no C<_meta> at all, since it is the
bootstrap probe a client sends before it can know what version to
claim.

=head3 Status codes

=item C<200> — a response, including one carrying a JSON-RPC error
object. The error is the payload, not the transport's problem.
=item C<202> — a well-formed notification (no C<id>). Dispatched, no
body, as JSON-RPC requires.
=item C<400> — C<-32020> header mismatch or missing routing header,
C<-32022> unsupported version, C<-32700> unparseable body, C<-32600> a
JSON-RPC batch (the modern era sends one message per request).
=item C<403> — C<Origin> refused. Checked first, before anything with a
side effect.
=item C<404> — C<-32601>, which covers unknown methods and
C<subscriptions/listen>. A method that does not exist is a resource that
does not exist.
=item C<405> — anything but POST, with C<Allow: POST>.
=item C<415> — a body that is not C<application/json>.
=item C<500> — C<-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 C<Mcp-Session-Id> and C<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.

=head3 Cancellation and thread safety

A client hanging up is the only cancellation signal this protocol has —
whether it closed the response stream, cancelled the request, or the
connection died under it. When it happens,
C<$*MCP-REQUEST-CONTEXT.cancelled> flips to C<True> and the eventual
result is discarded. B<The server never interrupts a running handler>: a
synchronous one that never looks runs to completion, holding its thread.
Anything long-running should poll.

Nothing pushes a disconnect at the endpoint — a Cro route is handed a
request, not a connection lifetime — so it is checked for, on the
C<disconnect-poll> interval (a quarter of a second by default), with a
zero-byte write that puts nothing on the wire. Lower it for handlers that
should give up the moment their caller does; raise it to trade latency
for syscalls. Detection is exact rather than heuristic: a socket that has
read EOF from its peer is closed, so a client that has hung up could not
have been sent the answer anyway. Behind TLS the probe cannot tell, and
cancellation then falls back to the slower signal — the write that fails
when the handler finally answers — so a handler on a TLS endpoint may
never see the flag at all.

=begin code :lang<raku>
$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";
    };
=end code

B<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 C<start>, the
modern path mutates no server state, each request's notifications are
routed to that request's channel, and C<$*ERR> writes are locked so log
lines cannot shear), but shared state inside I<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 C<Lock> —
before it is exposed over HTTP, not after.

=head3 Security

The endpoint speaks plain HTTP and binds C<127.0.0.1> by default. Both
defaults are deliberate.

B<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 C<Origin> header is the defence, and the policy is:

=item No C<Origin> at all — allowed by default (C<: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 C<:!allow-no-origin> if every client of yours is a browser.
=item C<http(s)://localhost>, C<http(s)://127.0.0.1>, C<http(s)://[::1]>,
any port — always allowed. This is the page-under-development case.
=item Anything else — refused with C<403> unless it is in
C<:@allowed-origins>.

=begin code :lang<raku>
MCP::Server::HTTP.new(
    :$server,
    allowed-origins => [
        'https://app.example.com',            # exact
        /^ 'https://' \w+ '.example.com' $/,  # or a pattern
    ],
).run;
=end code

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

B<Binding.> C<127.0.0.1> means no firewall prompts and no accidental
exposure. Changing C<:host> to C<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.

B<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 C<X-Accel-Buffering: no>, which nginx honours, but say it plainly
anyway:

=begin code
# 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
}
=end code

=begin code
# Caddy
mcp.example.com {
    reverse_proxy 127.0.0.1:8080 {
        flush_interval -1   # flush immediately: same reason as proxy_buffering off
    }
}
=end code

Forward the browser's C<Origin> as-is, and set C<:allowed-origins> to
the origins your front end really uses — the proxy's own hostname is not
one of them.

B<Authentication.> First-class auth is not built in, and C<.routes> is
the supported way to add your own. It hands back the endpoint as a
C<Cro::Transform> that can be mounted inside a larger Cro application,
where any middleware you like gets a look at the request first:

=begin code :lang<raku>
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;
=end code

Note that C<.routes> is used instead of C<.start>/C<.run> here — the
surrounding C<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 (C</healthz> above) still works.

=head2 Author

Matt Doughty

=head2 License

Artistic-2.0

=end pod
