UnknownKeys

NAME

MCP::Client::UnknownKeys - surface arguments a tool never declared, without touching the call

SYNOPSIS

use MCP::Client::Policy;
use MCP::Client::Reasons;
use MCP::Client::UnknownKeys;

my $policy = MCP::Client::Policy.new(
	provider => MCP::Client::UnknownKeys.new(
		provider => MCP::Client::Reasons.new(provider => $registry),
		on-warn  => -> %warning {
			$log.warning(
				"{%warning<tool>} was called with undeclared argument(s): "
					~ %warning<unknown-keys>.join(', ')
			);
		},
	),
	rules => MCP::Client::Policy.default-rules,
	:&on-ask,
);

# A provider that mangled a call into
#   task { "prompt": "Investigate the", "nvestigate": "…", "the rest": "…" }
# still runs exactly as it would have without this layer -- and says so once.

DESCRIPTION

A model's tool call arrives as a JSON object, and nothing in the loop between the sampler and the server has to agree about what is in it. A truncated generation that a constrained decoder closed into valid JSON, a provider that re-serialised the arguments through a lossy intermediate shape, a model that invented a plausible-sounding parameter: all three produce a call whose keys are not the keys the tool declared, and all three used to arrive at a server that quietly ignored the extras and did four fifths of the job.

MCP::Client::UnknownKeys is the smallest thing that makes that visible. It compares each call's argument keys against the properties the tool's own declaration published, and when a call carries a key the tool never declared it calls &on-warn once. Then it forwards the call — the same object, with the same arguments — to the provider beneath it, and returns that provider's results exactly as they came.

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.

It is a smoke alarm, not a valve

Nothing this layer notices changes what happens. It does not reject the call, does not strip the key, does not rewrite the arguments, does not add a result, does not delay the batch, and does not turn an unknown key into an error a model has to interpret. A tool that ignores an extra argument goes on ignoring it; a tool that refuses one goes on refusing it, in its own words.

That is a deliberate division of labour rather than timidity. Rejection is right where the cost of acting on a mangled call is unbounded and the argument list is known exactly — spawning a subagent on a truncated brief, say, which is why the delegation tool validates strictly on its own account. For the rest of a toolkit, a client-side refusal would be a second opinion about somebody else's schema: MCP servers are free to accept arguments they do not advertise, declarations arrive from servers this client did not write, and a layer that refused a call the server would have honoured breaks working setups to prevent a hypothetical one. So: warn, forward, and let the log say whether stricter handling is ever warranted.

What it audits, and what it says nothing about

A call is audited only when all of these hold, and skipped silently otherwise:

  • Its tool appeared in the most recent tools-for-llm pass-through. A tool this layer has never seen listed has no declaration to check against, so it is never audited — including every call made before the catalogue was listed for the first time.

  • That declaration carried a parameters.properties object with at least one property in it. A tool with no declared properties is either argument-free or schema-free; in both cases every key is equally undeclared, and warning about all of them would be noise standing in for an answer.

  • Its arguments read as a JSON object — either an object already, or the JSON string a model's function call actually travels as. Arguments that will not parse, that parse to something other than an object, that are the empty string a model sends for an argument-free call, or that are absent altogether, are not this layer's to complain about: they are the provider's to report, in the vocabulary the provider already has.

reason is always tolerated, declared or not. It is the parameter MCP::Client::Reasons adds to declarations on the way out and removes from calls on the way down, so a stack with reasons switched on legitimately produces calls carrying a key no server ever declared. The name is a literal here rather than an import, so this layer works in a stack that has no reason layer in it at all — the two are independent.

Where in the stack

Between the policy and the reason layer:

Policy( UnknownKeys( Reasons( ...( Registry ) ) ) )

Both neighbours are load-bearing, because this layer compares two things that are rewritten at different heights.

Under the policy, so the arguments it reads are the model's own. The policy is the last layer that sees a call before anything narrows it, and it is also what the human answers a prompt about; auditing beneath it means the keys compared are the keys the model actually emitted.

Over the reason layer, so the declarations it caches are the ones the model was shown. The reason layer adds reason to every declaration it can read on the way out and strips it from every call on the way down. Sitting over it, the tools-for-llm that fills this layer's cache sees the augmented schemas, so a reason in a call matches a declared property and nothing is reported; sitting under it, the calls would already have been stripped and the cache would carry an undeclared reason. Either seat is safe — the literal tolerance covers both, and this layer is correct with reasons on or off — but this one is the seat where neither half of the comparison has been rewritten behind its back.

It cannot usefully sit lower. The registry has no declaration cache to share and never sees the tools a composer above it publishes on its own account (a delegation tool, a lease tool), so a layer seated there would audit part of the catalogue and be blind to the rest. Above the policy it would see calls the policy is about to refuse and, worse, would sit over the layer whose grants and preset rules name tools by the very names it is trying to check.

The warning

&on-warn is handed one hash and its answer is ignored:

Key What it is
tool The tool name, as the catalogue published it
unknown-keys The undeclared keys being reported now, sorted
declared-keys Every property the declaration published, sorted
id The call's id, or the empty string when it has none

It defaults to a one-line note, which is the right default for a script and the wrong one for a host: a host with a log, an event stream or a session record should pass its own and put the warning where a human will find it later.

The callback is shielded. One that throws is swallowed, because a broken bit of host bookkeeping must never change what a tool call does — which is the whole promise this layer is built on.

Said once

Each (tool, unknown key) pair is reported once per instance, and an instance is a session's stack. A model that has decided fs_read takes a recursive flag will decide it again on every call, and a warning per call would bury the first one. A new key on the same tool is a new fact and is reported; so is the same key on a different tool.

unknown-keys therefore carries the keys being reported for the first time, not every undeclared key on the call — the ones already said are not repeated in a warning about something else.

The seen-set is never cleared, not even by a catalogue refresh. A refresh can only stop a key being unknown (by declaring it), and a key that has stopped being unknown does not need announcing again.

Never throws, never blocks

execute-tool-calls is transparent in both directions. Every call is forwarded, in the caller's order, as the very object the caller passed, and whatever the inner provider returns is returned unchanged — including an empty batch, which is passed on rather than short-circuited, so a provider that logs or wakes something on each batch behaves exactly as it would with this layer absent. An inner provider that throws throws through: this layer adds no error shape of its own, because the layer above it already has one and a second opinion would only differ from it.

The audit itself cannot fail the call. It holds a lock only long enough to claim a warning as said, never while calling &on-warn and never across the forward, and any exception raised while reading a call's own arguments is swallowed — an unreadable call is one this layer has nothing to say about, not one it gets to break.

tools-for-llm keeps the deliberate asymmetry the other composers keep: an inner provider that cannot list its tools throws, because publishing a silently shorter catalogue leaves a model wondering where a capability went. The declaration cache is replaced only when a listing succeeded.

EXAMPLES

The incident this layer exists for, in miniature — a provider that closed a truncated generation into valid JSON, so the loop's own length gate never fired:

my @warnings;
my $audited = MCP::Client::UnknownKeys.new(
	provider => $registry,
	on-warn  => -> %warning { @warnings.push: %warning },
);

$audited.tools-for-llm;   # fills the cache; fs_read declares path, offset

$audited.execute-tool-calls([
	%(
		id => 'call_1',
		function => %(
			name => 'fs_read',
			arguments => '{"path":"src/app.raku","ffset":12,"the rest":"…"}',
		),
	),
]);

# The read still happened, at the path the model asked for.
say @warnings[0]<unknown-keys>;    # (ffset the rest)
say @warnings[0]<declared-keys>;   # (offset path)

Wiring it to a host's own log, which is what a host should do — a one-line note is a script's default, not a session's record:

my $audited = MCP::Client::UnknownKeys.new(
	provider => $inner,
	on-warn  => -> %warning {
		$session.log(
			level   => 'warning',
			message => "{%warning<tool>} ({%warning<id>}) carried undeclared "
				~ "argument(s) {%warning<unknown-keys>.join(', ')}; it declares "
				~ %warning<declared-keys>.join(', '),
		);
	},
);

Which tools are being audited right now — the set the last catalogue listing left behind, useful in a test and in a "why did this not warn?" diagnosis:

$audited.tools-for-llm;
say $audited.audited-tools;   # (fs_read fs_write task)

SEE ALSO

MCP::Client::Policy — the permission layer this one stacks under.

MCP::Client::Reasons — the layer directly beneath, whose reason parameter this one tolerates by name.

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.