Policy
NAME
MCP::Client::Policy - per-directory permissions in front of any tool provider
SYNOPSIS
use MCP::Client;
use MCP::Client::Policy;
my $mcp = MCP::Client.connect-stdio(command => 'mcp-filesystem', args => ['/srv']);
my $policy = MCP::Client::Policy.new(
provider => $mcp,
rules => [
|MCP::Client::Policy.default-rules, # reads, and asking
{ tool => 'fs_write', decision => 'allow', under => 'scratch' },
{ tool => 'fs_delete', decision => 'deny' },
],
roots => { fs => '/srv' },
on-ask => &ask-the-user,
);
# A provider itself: it goes wherever the client or the registry went.
$policy.tools-for-llm;
$policy.execute-tool-calls([
{ id => 'call_1', function => { name => 'fs_write',
arguments => '{"path":"scratch/notes.md"}' } },
]);
DESCRIPTION
An agent that can write files is an agent that can write the wrong file. The usual answer ā the one Claude Code made familiar ā is to ask: reads go through, anything that changes the world stops at a prompt, and the human's answer can be remembered for the rest of the session. This is that, as a provider you wrap around another provider.
MCP::Client::Policy satisfies the same duck type MCP::Client::Registry
does ā tools-for-llm plus execute-tool-calls ā so it stacks anywhere a
provider goes: over a single client, over a registry, under a registry, or
several deep, with a different rule set at each layer.
The hard boundary stays where it belongs: on the server, in
MCP::Server::Tool::FileSystem's sandbox root, which no client-side rule can
widen. This is the second, softer question ā not "could this call escape?" but
"did the human agree to this one?" ā and it is asked here because the server
cannot ask it. A server has nobody to ask.
The three answers
Every call is evaluated against the rules by
MCP::Client::Policy::Rules, which
answers allow, deny or ask ā see that module for the rule schema, the
lexical containment test and its tri-state, and the fail-closed semantics.
What this class adds is everything stateful:
allow ā the call is forwarded, untouched, in the same batch as every other allowed call in the round;
deny ā the call comes back as an
is_errorresult saying who refused it and why. The provider never sees it;ask ā the session grants are consulted, and if they have nothing to say the
&on-askcallback is. Its answer decides the call, and may leave a grant behind for the rest of the session.
Grants are consulted only when the static rules said ask. That is what
makes an explicit < { tool => '*', decision = 'ask' } >> rule mean "ask me
every time" rather than "ask me until I say always" ā but it also means such a
rule quietly defeats the remembering, which is why it is not the default (the
engine already asks about anything no rule matched).
One seam, two kinds of question
&on-ask is called with a hash whose kind says what is being asked. A
permission question:
{
kind => 'permission',
tool => 'fs_write',
arguments => { path => 'scratch/notes.md' }, # the parsed copy
call => { id => 'call_1', function => {...} }, # a copy of the original
rule => { tool => 'fs_*', decision => 'ask' }, # or an undefined Hash
reason => 'rule', # or 'no-match' or 'unevaluable-path'
paths => ('/srv/scratch/notes.md',),
suggestion => { tool => 'fs_write', under => '/srv/scratch' },
}
answered with one of:
{ action => 'allow-once' }
{ action => 'deny-once' }
{ action => 'always-allow' } # remembers the suggestion
{ action => 'always-deny', rule => { tool => 'fs_*', under => '/srv/live' } }
{ action => 'always-allow', rules => [ # several, in one answer
{ tool => 'fs_write', under => '/srv' },
{ tool => 'fs_edit', under => '/srv' },
] }
rule defaults to suggestion, and the decision of a remembered rule is
taken from the action rather than from the rule, so a UI need only say what the
human clicked. Anything else ā an unknown action, a rule that will not
validate, a callback that throws, no callback at all ā is a refusal of this
call only, phrased in a way the model can read and act on.
rules is the plural of rule, for the coarse offers a UI makes when the
suggestion is too fine-grained to be useful ā "allow edits and new directories
anywhere under the workspace" is four rules and one click. Every rule in it is
validated exactly as a single one is, and the answer is all or nothing: one
unusable rule refuses this call and remembers none of them, because half a
grant is not what the human agreed to. An empty rules, or an answer carrying
both rule and rules, is the same kind of refusal. &.on-grant fires once
per answer however many rules it carried.
The other kind is a server's own elicitation, forwarded from
MCP::Client's on-elicit hook by elicit-hook:
{ kind => 'server-elicit', request => { method => 'elicitation/create', params => {...} } }
answered with a bare ElicitResult ā < { action => 'accept', content =
{...} } >>, < { action => 'decline' } > or < { action => 'cancel' } >.
Both kinds go through the same lock, because there is one human and they can
only answer one question at a time.
Two consequences of that lock are worth designing around. First, an ask has
no timeout: MCP::Client's per-call timeout budget stops at the wire and
does not cover a human deliberating, so a batch (and any concurrent batch, and
any server elicitation) waits as long as the question stays open ā a UI that
wants a deadline must impose its own inside &on-ask. Second, the lock is a
plain non-reentrant Lock: an &on-ask callback that re-enters the same
policy ā dispatching a policy-mediated tool call from inside the prompt
handler, say ā deadlocks on its own question, with no diagnostic. Treat the
callback as a leaf: collect the answer, return, and only then let the
application do more work.
What the queue does not do is put the same question twice. The effective
grants are read again the moment the lock is acquired, so the calls that were
waiting behind an always-allow or always-deny answer are decided by the
grant it left rather than re-asked: &on-ask is not called again, &on-grant
does not fire again, and nothing further is remembered ā deciding by a grant
never was an event. Ask six agents about the same directory at once and the
human answers once. A host that asks the human itself, in its own bracket of
locks rather than through &on-ask, wants the same check by hand before it
opens a dialog: that is grant-decision.
A suggestion is the deepest directory that really contains every path in
the call, so it can be as wide as '/' when nothing narrower is true (an
absolute path with no configured root, for instance). A UI should render the
suggested directory rather than assume it is narrow.
A call that named no directory but did name a website ā a web_fetch and
its url ā is suggested by host instead: < { tool => 'web_fetch', host
= 'docs.raku.org' } >>, so that "always allow" means this site and not the
web. Never both, and neither when there is nothing honest to scope to (a URL
that will not parse leaves the bare tool). A UI that renders the suggestion
should read whichever narrower is there rather than expect under.
Per-agent elicitation
MCP::Client takes one on-elicit hook, which is a problem the moment a
fleet of agents shares a client: the server's question goes to whichever
policy's elicit-hook was wired at construction, which is rarely the agent
whose call provoked it. Build the children with < :claim-elicits > and each
one answers its own:
my $child = MCP::Client::Policy.new(
:$provider, on-ask => &ask-in-this-pane, :claim-elicits,
);
While a claiming policy is forwarding a batch, any elicitation those calls
provoke is put to that policy's &on-ask, as a server-elicit, under
that policy's ask-lock ā whichever policy the host wired. The request hash
is unchanged: the callback answering it is already the claimant's own, which is
the only badge a UI needs. Nothing else moves; permission prompts were always
asked by the policy deciding the call.
This works because elicitation is fulfilled on the thread that made the call ā
MCP::Client answers an input-required round inline, before call-tool
returns ā so the claim travels as a dynamic variable down the forwarding stack.
Three things therefore fall back to the wired policy, which is exactly the
behaviour of every host written before this existed:
a call from a policy that did not claim;
a claimant with no
&on-askā a headless child does not get to answer by declining on behalf of a human somebody else has;a provider that fulfils elicitations on some other thread than the one that called it. No provider in this distribution does.
Stacked policies do not fight over a question: the outermost claim wins, because the call entered through the agent whose batch it is.
Never throws
execute-tool-calls keeps MCP::Client's promise exactly: a malformed call,
a denied call, a callback that blew up, a provider that died ā each is one
is_error result in the caller's own order, and the calls around it are
unaffected. tools-for-llm keeps the deliberate asymmetry too: a provider
that cannot list its tools throws, because silently publishing a shorter
catalogue leaves a model wondering where a capability went.
Where in the stack, and what a rule name means
Rules name tools as this policy sees them, which is not always as the model
sees them. A registry strips its prefix before passing a call on, so a policy
under a registry sees read where a policy over the same registry sees
fs_read. Same for roots keys. Getting this wrong is silent: no rule ever
matches, the engine falls through to ask, and every call goes to the human.
If everything is suddenly asking, this is why.
The other composers in this distribution are written to sit below a policy, in this order:
Policy( Reasons( Leases( Registry ) ) )
MCP::Client::Reasons gives every tool an
optional reason parameter and deletes it again before the call is forwarded.
It goes directly under the policy so that the &on-ask request still carries
the model's sentence ā a UI renders it beside the arguments, never instead of
them, and no rule, floor or classifier ever reads it.
MCP::Client::Leases goes further down still, so that permission is resolved before a lease is consumed and a re-asked call cannot walk around one.
EXAMPLES
Headless, with no human anywhere ā a batch job, a CI run. Wire no callback and anything not explicitly allowed is refused with a message saying so:
my $policy = MCP::Client::Policy.new(
provider => $mcp,
rules => MCP::Client::Policy.default-rules, # the read-only tools, and user_ask
);
say $policy.interactive; # False
$policy.execute-tool-calls([
{ id => '1', function => { name => 'fs_write', arguments => '{"path":"x"}' } },
]);
# ({ role => 'tool', tool_call_id => '1', is_error => True,
# content => "Permission required: 'fs_write' needs approval, but ..." },)
The interactive case, with the server's own questions going to the same human through the same lock. Note the forward declaration: the client needs the hook at construction and the policy needs the client, so one of the two has to be named before it exists:
my $policy;
my $mcp = MCP::Client.connect-stdio(
command => 'mcp-filesystem',
args => ['/srv'],
on-elicit => -> %request { $policy.elicit-hook.(%request) },
);
$policy = MCP::Client::Policy.new(:provider($mcp), :&on-ask, roots => { fs => '/srv' });
Wire on-elicit only when there is somebody to ask: setting it is what
declares the elicitation capability to the server, and a server told it may ask
will ask. .interactive is the guard.
Session grants are plain data, so a UI that wants "always allow" to survive a restart persists them and hands them back:
spurt 'grants.json', to-json($policy.grants);
# ... next run ...
my $policy = MCP::Client::Policy.new(
:$provider, :&on-ask, grants => from-json(slurp 'grants.json'),
);
&on-grant says when to do that, rather than leaving a host to poll
.grants or to guess that an always- answer it saw go past has landed. It
is handed the whole list, already copied, the moment the grant is made ā before
the call that provoked it has even been decided, so an engine that mirrors
grants into a session sees them in time for the next call in the same batch:
my $policy = MCP::Client::Policy.new(
:$provider, :&on-ask,
on-grant => -> @grants { $session.remember-grants(@grants) },
);
Like &on-ask, it is a leaf: it runs on the thread deciding the call, so
collect what you need and return.
A fleet ā a parent agent and the children it spawned, each with its own policy ā wants one grant set between them, or the human answers the same question once per child. Hand them all the same MCP::Client::Policy::Grants and they have it:
my $grants = MCP::Client::Policy::Grants.new(grants => $session.grants);
my $parent = MCP::Client::Policy.new(:$provider, :&on-ask, grants-store => $grants);
my $child = MCP::Client::Policy.new(:$provider, :&on-ask, grants-store => $grants);
With a store wired, an always- answer is written to the store instead of to
the policy that asked, and every policy reads the store on every decision ā
so a grant made through the parent binds a child that started before it and a
child started after it alike. .grants then renders the effective set (this
policy's own grants, then the book), which is what &on-grant is handed and
what a session should persist. Seed a resumed session into the store rather than
into grants, so a rule is not counted twice.
None of this widens anything: grants are still consulted only once the static
rules have said ask, so a shared grant cannot overrule a deny rule or the
danger floor, and a deny grant still beats an allow grant.
Stacked, with a rule set at each layer ā the outer policy speaks the model's names, the inner one speaks the server's:
my $registry = MCP::Client::Registry.new;
$registry.add(
MCP::Client::Policy.new(provider => $fs-server, rules => [{ tool => 'delete', decision => 'deny' },]),
prefix => 'fs',
);
my $outer = MCP::Client::Policy.new(
provider => $registry,
rules => [{ tool => 'fs_write', decision => 'ask' },],
:&on-ask,
);