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 @section permission 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 malformed arguments or _meta field 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_actions row.

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.