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_error result 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-ask callback 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,
);

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.