Tools
Every RPC method of the endpoint's API group and version is an MCP tool. tools/list returns them, tools/call executes them through the JsonRpcBundle core. There is no separate allowlist or MCP registration: only membership in an API group decides publication.
Name
The tool name is the RPC method name (Catalog.search). MCP allows names matching ^[a-zA-Z0-9_.-]{1,128}$.
A method of the group whose name does not match the pattern (for example, #[RPC\Info('Bad', concat: ':')] gives Bad:ping) makes every request to that group's MCP endpoint fail with HTTP 500; in debug mode the error explains which method and why. Other groups and the rest of the application are not affected, and methods outside MCP groups are never checked. The bundle deliberately does not skip such a method silently.
Title and description
They come from the core translations in the language negotiated by Accept-Language (the core RpcLocaleNegotiator). The response carries Content-Language and Vary: Accept-Language.
translations/rpc.uk.yaml # base translation, all groups
translations/rpc/mcp.uk.yaml # only for the mcp group, e.g. texts for an AI agent
# translations/rpc/mcp.en.yaml
Catalog.search:
description: Search products by name. Returns up to limit products, the most relevant first.
Each field has its own fallback chain: group + client language → group + default language → base + client language → base + default language → #[RPC\Info] / PHPDoc. If there is no title anywhere, the tool name becomes the title. The translation key is the method name, so a translation applies to all versions of the method.
Do not name a group the same as a Symfony Translator domain (messages, validators, …).
inputSchema
Built from the method metadata in the core, the same source as the OpenRPC document:
- parameters →
properties; parameters without a default value →required; default values →default; @paramdescriptions →description;- DTOs and enums →
$refto the tool's own$defs: OpenRPC#/components/schemas/...references are rewritten, so every tool is self-contained, including recursive DTOs; additionalProperties: false.
/**
* Ping.
* @param string $who Who is pinging
*/
public function ping(string $who = 'x'): string
{
"type": "object",
"properties": {
"who": {"type": "string", "description": "Who is pinging", "default": "x"}
},
"additionalProperties": false
}
The SDK validates arguments against the schema before execution: an unknown argument, a missing required one or a wrong type gives a JSON-RPC error -32602 in the MCP response (not a tool result with isError), and the method is not called. This also covers the core special parameter $rpc, which is why asynchronous calls and webhooks are not possible through MCP.
outputSchema
The method's return type as the core describes it for OpenRPC, wrapped the same way the result is returned:
{"type": "object", "properties": {"result": {"type": "string"}}, "required": ["result"]}
- Every
"type": "array"becomes["array", "object"]: a PHP array serializes to a JSON list or a JSON object depending on its keys (['a' => 1]→{"a":1}), and clients that validatestructuredContentagainst the schema would reject the object. - A DTO is a
$refto the tool's own$defs. - There is no
outputSchemawhen the method has no return type or returnsobject: the core describes such a result as "any object", and that schema would reject a scalar.
Annotations: #[McpTool]
Optional MCP ToolAnnotations hints on a method. The attribute does not affect publication.
use Ufo\McpBundle\Attribute\McpTool;
#[McpTool(readOnlyHint: true, idempotentHint: true)]
public function search(string $query, int $limit = 10): array
| Parameter | Value true |
|---|---|
readOnlyHint |
The tool does not modify its environment |
destructiveHint |
It may perform destructive updates (false: only additive ones) |
idempotentHint |
Calling it again with the same arguments has no further effect |
openWorldHint |
It interacts with an "open world" of external entities (for example, the web) |
All parameters are ?bool: null means "not specified" and is left out of the output.
tools/call
A call becomes an RpcRequest (the RpcFromMcp carrier: method = tool name, params = arguments, version from the route) and runs through the core RpcExecutor, the same pipeline as /rpc: parameter and DTO conversion, #[RPC\Assertions], #[RPC\Cache], #[RPC\Lock], events. The response is normalized by the core serializer, exactly as /rpc returns it.
Success:
{
"content": [{"type": "text", "text": "{\"id\":7,\"name\":\"n7\"}"}],
"structuredContent": {"result": {"id": 7, "name": "n7"}},
"isError": false
}
structuredContent is for clients that read structured output (validated against outputSchema); the same JSON in TextContent is for the rest.
An error (an exception in the method, a validation error or an exception outside the method, for example in an rpc.request listener) is a core RPC error with isError: true and no structuredContent:
{
"content": [{"type": "text", "text": "{\"code\":-32603,\"message\":\"...\",\"data\":{\"exception\":\"Ufo\\\\RpcError\\\\RpcInternalException\"}}"}],
"isError": true
}
data follows the core rules for the environment: full details (file, line, trace) in dev / test, elsewhere only the exception class and extra (validation details). Every error goes through the rpc.error event and the core logging.
Events and cache:
rpc.post_responsefires onkernel.terminate, after the response is sent, once per executed call, also for severaltools/callin one JSON-RPC batch. It does not fire for an error response or a cached one (a core rule).#[RPC\Cache]works the same as on/rpc: a cached call does not execute the method.
The id of the MCP request's JSON-RPC envelope belongs to the client; the internal RPC request id is generated for each call and never exposed.