Extending tool definitions from the host project

MCPServer builds every tools/list entry from the rpc_methods registry: name, description, inputSchema, outputSchema and the annotations derived from the method's MCP declarations. A host project usually needs to publish more than that, for example a canonical tool id, a risk level, an approval policy, or the audiences a tool belongs to.

Rather than teaching MCPServer about each project's vocabulary, the plugin dispatches PrepareToolDefinitionEvent once per assembled definition. A listener reads the definition and contributes to it. Nothing else changes: with no listener registered, the published definition is exactly what MCPServer built.

The event

Plugins\MCPServer\Domain\Event\PrepareToolDefinitionEvent, type mcp_server_prepare_tool_definition.

Member Purpose
getRpcMethod(): MCPRpcMethodValuesObject the rpc method backing the tool (plugin, method, description, hints)
getToolIdentifier(): string the tool name as it will appear in tools/list, prefix included
getDefinition(): array the assembled definition, as a read-only copy
setMetaValue(string $key, mixed $value): void adds one _meta entry
setAnnotation(string $key, mixed $value): void adds or overrides one annotations entry

The event carries no way to remove or rewrite a definition key. name and inputSchema, which MCP requires, are therefore safe whatever a listener does: a listener can only add _meta entries and annotation entries.

setAnnotation() refuses three keys: readOnlyHint, destructiveHint and openWorldHint. Those are what an MCP client reads to decide whether to ask a human before running a tool, and they are the tool's own declaration (see Writing MCP tools with required annotations), so a listener must not be able to restate them and turn a destructive tool into a read-only looking one. Passing one of them raises a SystemException. Every other key is open, including idempotentHint and title.

The rule is in ToolDefinitionExtension, the value object that the collector merges into the definition: its constructor also refuses the three keys. Thus code that makes an extension without the event cannot put a safety hint over the declaration of the tool.

A listener that throws, for that reason or any other, abandons the whole contribution for that tool, including what listeners before it had already written, so the definition is published exactly as MCPServer built it. The failure is logged through IRpc::LOG_ERRORS: a listing must not fail over a host plugin's bug, but it must not fail silently either.

Where the contribution lands

{
  "name": "FESTI_MCP__archiveTask",
  "description": "Archives a task.",
  "inputSchema": { "type": "object", "properties": { "idTask": { "type": "integer" } } },
  "annotations": { "readOnlyHint": false, "destructiveHint": true, "openWorldHint": false },
  "_meta": { "festi/toolId": "tasks.archive", "festi/riskLevel": "WRITE_LOW_RISK" }
}

_meta appears only when a listener wrote at least one entry, and the annotations a listener sets are merged over the ones MCPServer derived.

Example listener

namespace Plugins\AgentMode;

use Festi\Theme\Plugin\DefaultPlugin;
use Plugins\MCPServer\Domain\Event\PrepareToolDefinitionEvent;

class AgentModePlugin extends DefaultPlugin
{
    /**
     * @event PrepareToolDefinitionEvent::TYPE
     */
    public function onPrepareToolDefinitionHandler(PrepareToolDefinitionEvent $event): void
    {
        $rpcMethod = $event->getRpcMethod();

        $policy = $this->_toolPolicyFacade->getByRpcMethodName($rpcMethod->getMethodName());

        if ($policy === null) {
            return;
        }

        $event->setMetaValue('festi/toolId', $policy->getToolID());
        $event->setMetaValue('festi/riskLevel', $policy->getRiskLevel());
        $event->setMetaValue('festi/approval', $policy->getApprovalMode());
    }
}

Register the listener the way the host project registers its other Festi event listeners, for example in the plugin's init.php:

$core->addEventListener(
    PrepareToolDefinitionEvent::TYPE,
    [$plugin, 'onPrepareToolDefinitionHandler']
);

Cost

The event is dispatched once per tool while tools/list is assembled, after the input and output schemas are built. A listener should answer from data it already holds (one preloaded policy map per request), not with a query per tool: a server with a hundred tools would otherwise make a hundred queries per listing.