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.