Skip to main content
Version: 2026.1

Custom MCP Tools

Any Symfony bundle can contribute MCP tools to the agent system. Tools are registered via the pimcore.mcp_tool service tag and automatically become available through direct MCP servers and the meta-tool.

Creating a Tool

A tool is a PHP class with one or more methods annotated with #[McpTool]:

<?php

declare(strict_types=1);

namespace Vendor\Bundle\MyBundle\Mcp\Tool;

use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Result\CallToolResult;
use Pimcore\Bundle\StudioBackendBundle\Mcp\Tool\McpToolErrorHandlerInterface;
use Throwable;

final readonly class SearchProductsTool
{
public function __construct(
private ProductServiceInterface $productService,
private McpToolErrorHandlerInterface $errorHandler
) {
}

#[McpTool(
name: 'my_search_products',
description: 'Search for products by category and price range. '
. 'Returns product names, prices, and IDs.'
)]
public function execute(
#[Schema(
type: 'string',
description: 'Product category (e.g. "Electronics", "Clothing").'
)]
string $category,
#[Schema(
type: 'integer',
description: 'Maximum price in cents. Omit for no limit.',
minimum: 0
)]
?int $maxPrice = null,
#[Schema(
type: 'integer',
description: 'Results per page. Default: 20, max: 50.',
minimum: 1,
maximum: 50
)]
int $pageSize = 20
): CallToolResult {
try {
$results = $this->productService->search($category, $maxPrice, $pageSize);

return new CallToolResult(
[new TextContent(json_encode($results, JSON_PRETTY_PRINT))],
isError: false
);
} catch (Throwable $e) {
$message = $this->errorHandler->handle($e, 'my_search_products', [
'category' => $category,
]);

return new CallToolResult(
[new TextContent(json_encode(['error' => $message]))],
isError: true
);
}
}
}

Error Handling

Inject McpToolErrorHandlerInterface and route the terminal catch (Throwable $e) of every tool through it. The handler logs the exception and returns the message that may be handed to the client:

The handler never forwards an exception message. Whatever reaches it is logged at error level with the exception attached, and the client receives only a generic sentence naming the tool plus a correlation id, for example Internal error while executing my_search_products (ref: 7f3a91c2). Grep the Pimcore application log for that ref to reach the full stack trace.

The terminal catch is the "I did not anticipate this" branch, and a message you did not anticipate is not one you can vouch for.

This matters because tool results leave the Pimcore boundary. External MCP clients forward them to whichever model they are wired to, so a raw $e->getMessage() carries database, search backend, and Twig internals off the server. Note that Symfony does not do this for you: a tool catches its own exception and returns the text as application data inside an HTTP 200 JSON-RPC result, so kernel.debug and Symfony's production error page never see it. For the same reason the handler discloses the same detail in every environment, and the correlation id is what replaces a stack trace during development.

The handler returns a message rather than a result envelope, so a tool keeps whatever envelope shape it already uses. Typed catches stay as they are: a tool that maps AccessDeniedException or NotFoundException to its own error code is giving the agent the feedback it needs to recover, and that mapping must survive.

Saying Something Useful About a Failure

If a tool understands a failure, it should type-catch it and write the sentence itself, from values it already holds:

} catch (NotFoundException) {
return $this->errorResult(sprintf(
'Product %d not found. Use search_data_objects to find valid ids.',
$id,
));
} catch (Throwable $e) {
return $this->errorResult($this->errorHandler->handle($e, 'my_search_products', ['id' => $id]));
}

This is safer than forwarding $e->getMessage(), and usually better copy, because you compose it from data you control rather than from whatever the layer below happened to say.

Resist the temptation to build a list of "safe" exception classes. Safety is a property of the construction, not of the type: ValidationFailedException carries a literal written for the caller at most call sites and an inner getMessage() at CloneService and WidgetValidationService, so any class-level judgement about it is wrong half the time. Where the message is genuinely generative and bounded by the caller's own input — a PQL parse error, a field-validation failure — forward it at the catch that understands why, and say why in a comment. ForwardedExceptionMessageTest enforces that those stay few and reviewed; it will fail your build if a tool starts forwarding a message that nobody signed off.

The one exception the handler forwards is InvalidMcpToolArgumentException. Throwing it is an explicit statement that you composed the message for the caller, out of the caller's own input; never throw it wrapping another exception's message. ObjectParameterNormalizer is the shipped example of a correct throw.

The handler, both normalizers and that exception live in studio-backend-bundle (Pimcore\Bundle\StudioBackendBundle\Mcp\...), so every bundle exposing MCP tools shares one policy.

Multi-Tool Classes

A single class can expose multiple tools by annotating multiple methods with #[McpTool]:

final readonly class ProductCrudTool
{
public function __construct(
private ProductServiceInterface $productService,
private McpToolErrorHandlerInterface $errorHandler
) {
}

#[McpTool(
name: 'get_product',
description: 'Get a product by ID.'
)]
public function get(
#[Schema(type: 'integer', description: 'Product ID.')]
int $id
): CallToolResult {
// ...
}

#[McpTool(
name: 'update_product',
description: 'Update a product by ID.'
)]
public function update(
#[Schema(type: 'integer', description: 'Product ID.')]
int $id,
#[Schema(type: 'string', description: 'JSON object of fields to update.')]
string $fields
): CallToolResult {
// ...
}
}

Each #[McpTool]-annotated method is registered as a separate tool. Tool names must be unique within a group.

Key Conventions

  • Tool naming: Keep names short and descriptive. The MCP server provides the namespace, so prefixes are unnecessary. Avoid prefixes - the Copilot SDK adds its own server-key prefix, and the combined name must stay under 64 characters
  • Descriptions: Write for LLMs, not API docs. Include examples and reference other tools
  • Response format: Return JSON via TextContent. Keep responses compact - LLMs process text, not UI payloads
  • Error handling: Catch exceptions, log them, and return isError: true with a message

Registering the Tool

Tag the service with pimcore.mcp_tool and a group attribute:

# In your bundle's services.yaml
services:
Vendor\Bundle\MyBundle\Mcp\Tool\SearchProductsTool:
tags:
- { name: 'pimcore.mcp_tool', group: 'my-products' }

This automatically:

  1. Creates a direct MCP server endpoint at /pimcore-mcp/agent/my-products
  2. Makes the tool discoverable via the meta-tool server
  3. Includes it in the McpToolRegistry for programmatic access

How Registration Works

The McpToolRegistryPass compiler pass runs at container compilation time. It collects all services tagged with pimcore.mcp_tool, reads their #[McpTool] attributes via reflection, groups them by the tag's group attribute, and creates per-group ServiceLocator instances. The McpServerFactory then creates MCP server endpoints for each group on demand.

Tool names must be unique within a group - the compiler pass throws an error on duplicates.

Adding to an Existing Group

To extend a built-in group (e.g. adding a custom data object tool):

services:
Vendor\Bundle\MyBundle\Mcp\Tool\MyCustomObjectTool:
tags:
- { name: 'pimcore.mcp_tool', group: 'pimcore-data-objects-read' }

The tool is added to the existing pimcore-data-objects-read server alongside the built-in tools.

Group Descriptions

The first tool tag in a group with a description attribute sets the group description (shown in meta-tool discovery):

services:
Vendor\Bundle\MyBundle\Mcp\Tool\ListProductsTool:
tags:
- name: 'pimcore.mcp_tool'
group: 'my-products'
description: 'Search and manage products'

Using the Tool in Agents

Add the group to an agent's configuration:

# agent-server/config/agents/product-agent.yaml
name: product-agent
displayName: Product Agent
description: Search and manage products

pimcoreMcpServers:
- my-products # Direct access

# Or as meta-tool (on-demand discovery):
pimcoreMetaGroups:
- my-products