Reasons
NAME
MCP::Client::Reasons - every tool call carries why, for the human watching
SYNOPSIS
use MCP::Client::Reasons;
use MCP::Client::Policy;
my $policy = MCP::Client::Policy.new(
provider => MCP::Client::Reasons.new(provider => $registry),
rules => MCP::Client::Policy.default-rules,
:&on-ask,
);
# The model now writes:
# fs_write { "path": "src/app.raku", "content": "...",
# "reason": "Add the missing guard clause you asked for" }
#
# The permission prompt sees the reason (it is in the call's arguments), the
# tool card renders it, and the server is handed the call without it.
DESCRIPTION
A tool call tells you what an agent is about to do. It does not tell you why,
and "why" is most of what a human needs in order to answer a permission prompt
in less than a minute ā fs_write to config/app.yml is either the change
that was asked for or the beginning of a bad afternoon, and the arguments look
the same either way.
MCP::Client::Reasons is the smallest thing that gets the why: it adds one
optional reason parameter to every tool declaration on the way out, and
deletes it from every call on the way in. The model fills it in because it is
declared where the model is looking; the UI reads it off the call it is about
to render; the server never learns the parameter exists.
It is a provider composer, satisfying the same duck type
MCP::Client::Registry and MCP::Client::Policy do ā tools-for-llm plus
execute-tool-calls ā so it stacks in wherever a provider goes.
What it does to the catalogue
Each declaration it can read gains one property:
reason => {
type => 'string',
description => 'One short sentence: why this call. Shown to the human ' ~
'reviewing the session.',
}
It is never added to required. A model that omits it makes a call that is
still valid, and a turn is never spent on a schema error over a field that
exists for a human's benefit. The declaration is copied rather than edited, so
the provider's own catalogue objects are not mutated under it.
A declaration this layer cannot read is passed through exactly as it came
and is not tracked: no function, no string name, no parameters object,
or a properties that is not an object. There is no schema there to extend
without inventing one, and inventing one is how a layer meant to be invisible
starts breaking servers.
The collision rule
A tool that already declares its own reason parameter is left completely
alone ā not augmented, and, more importantly, not stripped. Its reason is a
real argument that its server expects to receive, and a layer that deleted it
on the way past would corrupt every call to it in a way nobody would think to
look for.
That is the general rule this layer is built on: when in doubt, do not strip. A parameter removed from a call that needed it is a silent misbehaviour; a parameter left on a call that did not expect it is the server's own validation problem, reported to the model as an ordinary tool error.
The same reasoning applies to a tool that lists reason in required
without declaring it ā an odd schema, but one whose author plainly means
something by the name, so it is left alone too.
Which calls get stripped
The ones whose tool name was augmented by the most recent tools-for-llm.
That set is instance state, replaced wholesale under a lock each time the
catalogue is listed, so a refresh that adds or removes a tool downstream is
reflected in what gets stripped from the next batch.
Everything else forwards with its arguments untouched, and untouched means the
very object the caller passed: a call to a tool this layer has never seen
listed, a call to a collision tool, a call whose arguments are not a JSON
object, a call with no reason in them. Only a call that actually carries a
reason for an augmented tool is rebuilt, and then only far enough to drop
that one key.
Arguments arrive in the two shapes the bridges accept ā an object, or the JSON
string a model's function call actually travels as (with the empty string
meaning "no arguments", exactly as MCP::Client and MCP::Server read it).
A string is re-serialised compactly after the key is dropped, so a stripped
call is preserved by value, not byte for byte; every other call is passed on
byte for byte because it is passed on unchanged.
The reason is a claim, not evidence
It is written by the model, about the model's own intentions, in the same sampling step as the arguments. It is not a signature, an audit record or a justification anybody checked. Two consequences, both deliberate:
A UI must keep the arguments primary. Render the reason as supporting text next to the command, the path and the diff ā never instead of them, and never in a way that lets a human approve a call having read only the reason. A model that is wrong about what it is doing writes a sincere reason for the wrong call, and a model that has been talked into something by a poisoned document writes a persuasive one.
Machine policy must never consume it. The rule engine, the danger floor and the auto-mode classifier all decide on the tool name, the arguments and the paths ā never on this field. A permission layer that read the reason would be a permission layer the model could talk its way through by writing nicer sentences, which is precisely the property the static rules exist to not have.
Where in the stack
Directly under the policy and over everything else:
Policy( Reasons( Subagents( Escalation( Leases( Registry ) ) ) ) )
Under the policy, because the policy's &on-ask request carries a copy of the
call, and the call still has the reason in its arguments at that point ā
which is the entire reason this layer exists. Put it over the policy and the
prompt shows the human a call with no reason in it, having stripped the field
before the question was asked.
Under, though, not necessarily directly under: a non-mutating layer such as
MCP::Client::UnknownKeys may sit between
the two, because a layer that rewrites neither the declarations on the way out
nor the arguments on the way down leaves the reason exactly where &on-ask
finds it.
Over everything else, because everything else is a provider whose tools should
be augmented too. Over the subagent composer, and the task tool gains a
reason like any other ā delegating is one of the calls a human most wants
the why of. Over the lease layer, the escalation layer and the registry, so no
layer that inspects arguments (locating paths, matching commands) ever sees a
parameter that is not the server's.
Because the strip happens at forward time and nowhere else, the reason stays in
the assistant message the model produced: the recorded tool_calls keep it,
so a session transcript, a replay and a resumed run all still have it with no
schema change anywhere. Read it back with the parameter name this module
publishes as MCP::Client::Reasons::REASON-PARAM.
Never throws
execute-tool-calls keeps the provider contract exactly: an inner provider
that died becomes one is_error result per call, in the caller's own order,
and results that came back are passed through as they came. tools-for-llm
keeps the deliberate asymmetry: an inner provider that cannot list its tools
throws, because publishing a silently shorter catalogue leaves a model
wondering where a capability went.
EXAMPLES
The whole of the wiring, in a stack that also delegates and locks:
my $reasons = MCP::Client::Reasons.new(
provider => MCP::Client::Leases.new(inner => $registry, :$table, :$agent-id),
);
my $policy = MCP::Client::Policy.new(
provider => $reasons,
rules => @preset-rules,
on-ask => -> %request {
# %request<arguments><reason> is the model's sentence, when it wrote one.
$ui.ask(%request);
},
);
A UI reading the reason off a call it is rendering ā from the recorded
tool_calls, which is where it still is:
use MCP::Client::Reasons;
use JSON::Fast;
sub reason-of($call) {
my $raw = $call<function><arguments>;
my %args = $raw ~~ Associative ?? $raw.Hash !! (try from-json($raw)) // {};
%args{MCP::Client::Reasons::REASON-PARAM} // '';
}
Which tools are being augmented right now ā the set the last catalogue listing left behind, useful in a test and in a "why is this not being stripped?" diagnosis:
$reasons.tools-for-llm;
say $reasons.augmented-tools; # (fs_read fs_write ... task)
SEE ALSO
MCP::Client::Policy ā the permission layer
this one stacks under, and the &on-ask request whose arguments carry the
reason to the human.
MCP::Client::Leases ā another composer in the same stack, sitting below this one.