Tools

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;
  • @param descriptions → description;
  • DTOs and enums → $ref to 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 validate structuredContent against the schema would reject the object.
  • A DTO is a $ref to the tool's own $defs.
  • There is no outputSchema when the method has no return type or returns object: 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_response fires on kernel.terminate, after the response is sent, once per executed call, also for several tools/call in 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.