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.

MCP::Client v0.5.0

talk to an MCP server, in either protocol era

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

MCP::Server:ver<0.6.0+>:auth<zef:apogee>JSON::Fast:ver<0.19+>:auth<cpan:TIMOTIMO>Cro::HTTP:ver<0.8.11+>:auth<zef:cro>:api<0>MIME::Base64:ver<1.2.5+>:auth<zef:raku-community-modules>

Test Dependencies

Provides

  • MCP::Client
  • MCP::Client::Cache
  • MCP::Client::Correlator
  • MCP::Client::Exceptions
  • MCP::Client::Leases
  • MCP::Client::Leases::Table
  • MCP::Client::Policy
  • MCP::Client::Policy::Commands
  • MCP::Client::Policy::Floor
  • MCP::Client::Policy::Grants
  • MCP::Client::Policy::Rules
  • MCP::Client::Protocol
  • MCP::Client::Reasons
  • MCP::Client::Registry
  • MCP::Client::SSE
  • MCP::Client::Transport
  • MCP::Client::Transport::HTTP
  • MCP::Client::Transport::Stdio
  • MCP::Client::UnknownKeys

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.