=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>.
When a handler returns a protocol result with C<structuredContent>, the bridge
also preserves it as C<structured_content>; the model-visible C<content> is
rendered independently and is unchanged. This is the same key and rendering
used by C<MCP::Client>'s remote bridge.

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.

C<tools-for-llm> deliberately carries B<no annotations>: these
declarations go on an OpenAI-compatible provider's wire verbatim, where
an unrecognised member of a tool declaration is a 400 rather than an
ignored field. Annotations are published where the protocol puts them —
the C<tools/list> catalog — and read back through
C<< $server.tool-annotations($name) >>.

=head3 How a batch is executed

C<execute-tool-calls> runs the calls it is given B<serially, in model
order> — unless B<every> call in the batch names a tool annotated
C<readOnlyHint> and C<idempotentHint>, in which case the distinct calls
run on up to C<$.tool-concurrency> workers (default 4). Three things hold
either way:

=item B<Order is not an outcome.> Result I<n> answers call I<n>, whatever
finished first, each under the C<tool_call_id> of the call it answers.
=item B<One mutating call makes the batch a sequence.> A write, a command
or a question for the user beside a read means the read waits its turn: a
model that asked for both in one turn is describing an order, and the
annotation is the only evidence there is that a call is not part of one.
=item B<Identical calls in a widened batch execute once>, the answer
copied into each slot that asked for it. Two reads of one file is one
read — one file opened, one lot of tokens back.

=begin code :lang<raku>
# Four annotated reads: up to four handlers at once, answers in order.
my @results = $server.execute-tool-calls(@four-reads);

# Never widen anything, whatever is annotated:
my $strict = MCP::Server.new(:name<strict>, tool-concurrency => 1);
=end code

C<MCP::Client>'s bridge does exactly the same with what a remote server
annotated, so a toolkit is interchangeable local or remote in this too.
See L<MCP::Server::Batch> for the scheduler both of them share.

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

=item Annotate the tools that only read — see below. It is the one piece
of self-description a host can act on, and the pack is the only thing
that knows the answer.

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.

=head3 Annotations

Every registration takes an optional C<:%annotations>, the MCP
C<Tool.annotations> object. It is published in the C<tools/list> catalog
exactly as given, and it is what tells a host what calling the tool does:

=begin code :lang<raku>
$registrar.tool: 'read',
    annotations => { readOnlyHint => True, idempotentHint => True },
    description => 'Read a file from inside the server root',
    params  => { path => { type => 'string', required => True } },
    handler => -> :%args { ... };
=end code

The spec's keys are C<readOnlyHint>, C<destructiveHint>,
C<idempotentHint> and C<openWorldHint> (each a C<Bool>) plus a
human-facing C<title> (a C<Str>). Those five are type-checked at
registration, because a C<< readOnlyHint => 'true' >> is a hint that
reads as set and tests as unset everywhere it matters; any other key
travels untouched, since the annotations object is open. A tool that
declares nothing publishes no C<annotations> member at all.

B<What this server does with them.> C<readOnlyHint> and C<idempotentHint>
together — B<both>, explicitly C<True> — are a licence for
C<execute-tool-calls> to run a batch of such calls side by side and to
execute identical calls in that batch once (see "LLM Tool Bridge" below).
Nothing else here reads them; they are otherwise the pack's word to
whichever client is listening.

B<So annotate honestly, and remember what the claim is about.> It is
about the B<handler>, not the intent:

=item B<Read-only> means the call changes nothing a later call could
observe — not on this machine, not on a remote one.
=item B<Idempotent> means calling it again with the same arguments adds
nothing. A tool that hands out a fresh handle, or asks a human a
question, is not idempotent however little it writes.
=item B<Both together mean thread-safe against itself.> Two of them will
be inside your handler at once. Anything they share — a memoised cache, a
lazily-built client, a counter — needs a lock, and a lazy initialiser two
callers can enter is the classic way to find that out the hard way.

Anything you would not want run twice at once carries nothing. That is
the safe direction, and it costs only the width of one batch.

=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 — unless
the server carries an C<:&on-notify> sink, which picks up what the
request had nowhere to put (see "The host as the client" below).
=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<progress($done, :$total, :$message)> — tell the client how far
along this request is. Returns C<False> and sends nothing when the
client attached no progress token, so it is safe to call unconditionally.
See "Progress" below.
=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<progress-token>,
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 to a
client before it has said it is initialized.

=head3 The host as the client

None of those channels exists in a server nobody dials into — one built
to be driven through C<execute-tool-calls> or C<handle-request> from the
process that created it. Its toolkits still raise notifications, and a
background job announcing that it has finished is worth hearing.
C<:&on-notify> is where they go:

=begin code :lang<raku>
my $events = Channel.new;

my $server = MCP::Server.new(
    :name<in-process>,
    on-notify => -> %notification { $events.send(%notification) },
);
$server.plug: MCP::Server::Tool::Shell.new(allow => <make>);

# ... later, on the host's own thread:
react whenever $events -> %notification {
    next unless %notification<method> eq 'notifications/job';
    say "job {%notification<params><job>} is {%notification<params><state>}";
}
=end code

The sink is called with the same C<< { method => ..., params => ... } >>
hash a transport would have written, and B<the contract is narrow on
purpose>:

=item B<Leaf.> It must not call back into the server, and least of all
into C<log> or C<notify> — a sink that logs is a sink that notifies
itself.
=item B<Non-blocking.> Hand the notification to a queue and return, as
the C<Channel> above does.
=item B<Thread-agnostic.> It is called on whatever thread raised the
notification: a handler thread, a C<Promise> watcher, a job flusher.
Those flushers are a small shared pool, so a sink that blocks for a
second stalls every job's output in the process for a second.
=item B<Shielded, not forgiven.> A throw is caught — it cannot take down
the job flusher that called it — and the notification counts as
delivered. The failure is reported once on C<$*ERR> and that
notification is gone. Do your own error handling inside the sink.

Where it sits in the routing: last, and outside the handshake gate. A
request with a channel of its own still wins outright, so nothing is
delivered twice; a modern request with no sink, a legacy session that
has not finished its handshake, and a server with no transport at all
all reach the sink instead of dropping. Out of a request it is delivered
to B<as well as> a connected transport, because the host and the client
are two audiences rather than two attempts at one.

The bridge is the one exception to "outside a request": a tool called
through C<execute-tool-calls> gets a legacy-era context, so a handler
that reads the context works there too rather than dying on an undefined
dynamic variable. Its notification sink is the C<:&on-notify> callback
when there is one and nothing at all when there is not, which is what
C<$ctx.emit-notification> reports back either way.

=head3 Progress

Progress is opt-in from the client's side: a request carries a
C<progressToken> in its C<params._meta> when the caller wants to be kept
posted, and a server B<must not> send C<notifications/progress> for one
that did not. C<$*MCP-REQUEST-CONTEXT.progress> is that rule made
automatic — it quotes back the token of the request (or, mid-elicitation,
of the round) in flight, and does nothing at all when there is none:

=begin code :lang<raku>
$server.tool: 'reindex',
    description => 'Reindex every document',
    handler => -> :%args {
        my $ctx = $*MCP-REQUEST-CONTEXT;
        my @docs = documents();
        for @docs.kv -> $i, $doc {
            reindex($doc);
            $ctx.progress($i + 1, total => @docs.elems, message => $doc.name);
        }
        "reindexed {@docs.elems} documents";
    };
=end code

The C<Bool> it returns says whether the notification really went out:
C<False> for a client that asked for no progress, and C<False> as well
when the request has no notification channel (a bare C<handle-request>,
or the LLM bridge). A handler that only wants to skip the work of
computing a progress line can ask C<$ctx.progress-token.defined> first.

C<:$total> and C<:$message> are both optional and are left out of the
notification when they are not given: C<ProgressNotificationParams> marks
them optional, and an absent total is not the same claim as a total of
zero. C<$progress> should increase on every call, whether or not a total
is known.

Routing is the request's own channel, so progress for one request can
never surface in another's stream, and nothing is level-gated — progress
has no level — or echoed to C<$*ERR>. Both eras spell the token the same
way, so a 2025-11-25 client gets progress from the same handler code.

=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
