Governing a tool call from the host project
tools/list decides what a caller can see. tools/call decides what a caller can do, and the
two decisions are the same one: a tool a host plugin hid from a caller's listing is refused at
call time as well, with the same answer a tool that does not exist gets. Hiding a tool is not
the same as blocking it, and MCPServer blocks it.
Around an accepted call the plugin dispatches two events, so a host project can run its own approval gate, its idempotency ledger and its audit trail without patching this plugin.
The grant decision on a call
While tools/call resolves a tool, MCPServer dispatches PrepareToolsListEvent exactly as it
does for tools/list (see MCPServerPlugin::getGrantedToolIdentifiers()):
- No listener overrides the decision: the call proceeds unchanged, and the plugin's own
@sectionpermission check still runs when the method is dispatched. - A listener overrides the decision: a tool outside its granted set is answered with
Error: Unknown tool '<name>'., the same answer an unregistered name gets, so the refusal tells the caller nothing about whether the tool exists. The method is never invoked. A name that resolves to nothing runs the same grant decision before it is refused, and a malformedargumentsor_metafield is normalised rather than raising, so neither the answer nor the work behind it distinguishes a hidden tool from an absent one. What the two paths do not promise is constant time: the host's own gate is free to spend a different amount of work on a name it recognises. - A listener fails while deciding: the call is refused with
Error: Tool access could not be verified., the failure is logged, and the method is never invoked. The answer is deliberately not the unknown-tool one: this is an outage, not a policy answer, so the caller learns that the check did not run and a retry is meaningful, while nothing about the decision is disclosed. - Nobody is signed in: the call is refused like an unknown tool. A tool call is always made on
behalf of someone, and with no caller there is no grant decision to take, so the call must
not fall through to the registry's own permission default, which ignores a method that has
no
festi_section_actionsrow.
The decision is taken over the registry rows alone. No input or output schema is built to evaluate it, so re-checking a call costs one registry read.
Why a failing listener is fatal here but not on the listing
MCPServerPlugin::getGrantedToolIdentifiers(), the method tools/list renders from, keeps its
lenient contract: a listener that fails degrades to MCPServer's own base-role filtering instead
of failing the listing. It has to. A listener that threw never reached setOverride(), so
there is no way to know whether it would have overridden the decision at all, and refusing to
list anything on a listener bug would take the whole server down.
tools/call reads the same decision strictly, through ToolAccessGate::getGrantedToolIdentifiers(),
which lets the failure out. A listing is advisory: at worst a caller sees a tool it cannot use. A call is enforcement:
if the gate that hides the generic tools cannot answer, treating "no answer" as "allowed" hands
the caller exactly the tools the gate exists to block. So the listing stays lenient, the call
fails closed, and the asymmetry is deliberate.
Granting a generic URL driver grants what it can reach
execUrl and getSchemeByUrl, the RPC plugin's generic DataGridStore drivers (see
Creating an MCP Tool from a DGS), take a URL
rather than a bounded set of arguments. A caller granted either of them reaches every DGS page
it can name, including the pages behind tools the gate hides, so hiding a DGS tool while
granting execUrl hides the tool and not the data. Decide about the generic drivers
explicitly, and keep in mind that the @section permission of the page is the last line of
defence there.
Before the call
Plugins\MCPServer\Domain\Event\BeforeToolCallEvent, type mcp_server_before_tool_call.
Dispatched once the call is accepted and before the method runs.
| Member | Purpose |
|---|---|
getContext(): ToolCallContext |
tool identifier, rpc method, arguments, request _meta |
deny(string $reason): void |
refuses the call; the caller receives isError: true with this reason |
setResult(array $result): void |
answers the call with a result the listener already holds |
ToolCallContext::getMeta() is the _meta object of the tools/call request, which MCP
clients use to carry call metadata: festi/idempotencyKey, festi/runId, festi/toolCallId.
getMetaValue($key) reads one entry.
_meta is client-supplied and of arbitrary shape. MCPServer bounds it before any listener sees
it: a _meta that is not an object becomes an empty one, entries without a string key are
dropped, and so is any entry nesting deeper than 4 levels, longer than 4096 characters encoded,
or beyond the 32nd. What it does not do is interpret the values, so a listener must validate
the type of every entry it reads before using it as a key or persisting it.
Refusing wins. A listener that both denies and sets a result is denying, and so is a pair of
listeners where one denies and the other answers: the stored result is discarded and the caller
receives the refusal. A denial reason must not be empty, since it is the only thing the model
is told; deny('') raises a SystemException.
A listener that fails is not caught here: a gate that cannot make its decision must not let the call through, so its failure becomes the call's failure. The caller receives the generic internal error, the tool is never invoked, and the after event still reports the failure once, so an approval gate that breaks leaves an audit record rather than a silence.
After the call
Plugins\MCPServer\Domain\Event\AfterToolCallEvent, type mcp_server_after_tool_call.
Dispatched exactly once for every call that got past the before event, whatever its outcome:
the tool ran, a listener answered it, a listener refused it, or it failed. It is also
dispatched, with the failure and without a before event, for a call refused because the access
gate itself failed, so an audit trail records the calls that could not be decided.
| Member | Purpose |
|---|---|
getContext(): ToolCallContext |
the same context the before event carried |
getResult(): ?array |
the tool result the caller receives, null when the call failed |
getThrowable(): ?Throwable |
the failure, null when the call produced a result |
isFailed(): bool |
whether the call ended in a failure |
getDurationMilliseconds(): float |
wall clock time spent on the call itself |
The after event is a notification: it cannot change the caller's answer, and a listener that fails is logged and ignored, so an audit hook can never break a successful call.
Example: an idempotency ledger
namespace Plugins\AgentMode;
use Festi\Theme\Plugin\DefaultPlugin;
use Plugins\MCPServer\Domain\Event\AfterToolCallEvent;
use Plugins\MCPServer\Domain\Event\BeforeToolCallEvent;
class AgentModePlugin extends DefaultPlugin
{
private const META_IDEMPOTENCY_KEY = 'festi/idempotencyKey';
/**
* @event BeforeToolCallEvent::TYPE
*/
public function onBeforeToolCallHandler(BeforeToolCallEvent $event): void
{
$context = $event->getContext();
$key = $context->getMetaValue(static::META_IDEMPOTENCY_KEY);
if (!is_string($key) || $key === '') {
return;
}
$stored = $this->_ledgerFacade->getCompletedResponse($key);
if ($stored !== null) {
$event->setResult($stored);
return;
}
if ($this->_ledgerFacade->isRunning($key)) {
$event->deny(__('This call is already running. Retry once it finished.'));
}
}
/**
* @event AfterToolCallEvent::TYPE
*/
public function onAfterToolCallHandler(AfterToolCallEvent $event): void
{
$context = $event->getContext();
$key = $context->getMetaValue(static::META_IDEMPOTENCY_KEY);
if (!is_string($key) || $key === '') {
return;
}
$this->_ledgerFacade->finish(
$key,
$event->getResult(),
$event->isFailed(),
$event->getDurationMilliseconds()
);
}
}
What the caller sees when its arguments are wrong
A failure raised inside a tool is answered with a generic message and never exposes internals. A failure the caller's own arguments caused is different: it is answered by naming the argument, because the caller is the only one who can fix it.
| Cause | Answer |
|---|---|
| a required argument was not sent | Error: Missing required argument 'idReward'. An argument that has a default value is not named. |
| an argument value the tool cannot use | Error: Invalid value for argument 'idReward': expected int. |
| the registry row declares an argument that the tool method does not have | Error: Unknown argument 'rewardId'. |
| anything raised inside the tool | Error: An internal error occurred while executing the tool. |
| the host's access gate failed | Error: Tool access could not be verified. |
| the tool name is missing, or is not a string or an integer | Error: Tool name is required. |
Only a failure of the call to the tool method itself is a problem of the arguments: the
dispatcher wraps it in an ArgumentBindingException. A missing argument or a wrong value in a
call that the tool makes in its own code is a failure inside the tool, and gets the generic
answer, also when a parameter of that call has the same name as a parameter of the tool.
An argument that the tool does not declare is not sent to the tool: the call runs without it.
The argument name and its declared type come from the tool's own registry row, not from the engine's failure text, so no class name, file path or stack ever reaches the caller. An argument of an internal type (a value object) is named without naming its type.