MCP Tools
Pimcore Copilot ships four MCP (Model Context Protocol) tool handlers for automation actions. An AI agent that can call them can list the configured automation actions, start one, follow the resulting job run, and cancel it, without going through Pimcore Studio.
The handlers are ordinary services carrying #[McpTool] and #[Schema] attributes. They are not
bound to any particular MCP server: this bundle provides the tools, and
integrating them into a server is a separate step.
Requirements
The handlers are registered only when mcp/sdk
^0.7 is installed, so an installation without an MCP server is unaffected. This feature also raises
Copilot's minimum pimcore/studio-backend-bundle to ^2026.3, which is where the shared MCP tool
boundary lives.
The tools
| Tool | Handler | Permission |
|---|---|---|
list_automation_actions | Mcp\Tool\ListAutomationActionsTool::execute() | pcp_general_permission + per-action execute |
run_automation_action | Mcp\Tool\RunAutomationActionTool::execute() | pcp_general_permission + per-action execute |
get_automation_action_run | Mcp\Tool\GetAutomationActionRunTool::execute() | pcp_job_run_permission |
cancel_automation_action_run | Mcp\Tool\CancelAutomationActionRunTool::execute() | pcp_job_run_permission |
All four are in the Pimcore\Bundle\CopilotBundle\Mcp\Tool namespace. Each returns a single JSON text
content block.
list_automation_actions
Lists the automation actions the current user is allowed to execute.
| Parameter | Type | Description |
|---|---|---|
filter | string, optional | Case-insensitive substring match against name, title and group. Omit it or pass null to list every action. |
{
"actions": [
{
"name": "CarVariantGenerator",
"title": "Car Variant Generator",
"description": "This action will create a variant of a car object.",
"group": "catalog",
"stepCount": 1,
"stepTypes": ["generic_variant_generator"],
"manualRunnable": true,
"triggers": {"manual": true, "event": false, "scheduled": false}
}
],
"skipped": 0
}
manualRunnable reports whether the action is triggerable at all; the triggers flags report which
trigger types are enabled on it. skipped counts configurations that could not be read, for example
a step implementation belonging to an uninstalled bundle. A single unreadable configuration is
skipped rather than failing the whole call, and the count tells the agent the list is incomplete.
The response carries run-relevant summaries only, never the raw step configuration.
run_automation_action
Starts an action by name and returns {"jobRunId": 42} immediately. The action itself runs
asynchronously through the Generic Execution Engine, so follow it with get_automation_action_run.
| Parameter | Type | Description |
|---|---|---|
name | string, required | The action configuration name, as returned by list_automation_actions. |
elements | array, optional | Elements the action runs against, for example [{"type": "object", "id": 83}]. type is one of object, asset, document; id is a positive integer. Omit it, or pass null or [], to run with no elements. |
environmentVariables | object, optional | Flat key/value overrides matching the action's configured environment variables. Omit it, or pass null or {}, to keep the configured defaults. |
Malformed elements entries are rejected with an invalid_request naming the offending index, rather
than being coerced. A coerced entry would be dropped further down and the run would start against an
empty selection while still reporting a job run id.
get_automation_action_run
Reports one job run. Owner-scoped: a job run started by another user reports as not found.
| Parameter | Type | Description |
|---|---|---|
jobRunId | integer, required | The id returned by run_automation_action. |
logTail | integer, optional | Number of most recent log lines to include. Defaults to 20, maximum 200. Pass 0 to omit the log. |
{
"id": 42,
"jobName": "ImageClassification",
"state": "running",
"currentStep": 0,
"currentMessage": "Starting Image Classification for /Car Images/jaguar/auto-3095119.jpg",
"canCancel": true,
"startedAt": 1787134430,
"lastUpdated": 1787134431,
"logs": [{"message": "Job Run ImageClassification (42) started.", "createdAt": "2026-08-19T10:13:50+00:00"}]
}
logs holds the job run's log verbatim, oldest first, which is the same text the Pimcore Studio job
overview shows. A failing step writes its error message there, so those lines reach whichever model
provider the agent uses. Pass logTail: 0 for actions whose steps handle data that must not leave the
installation.
Unlike the Pimcore Studio job overview, pcp_job_runs_see_all_permission is deliberately not
honoured here: an agent only ever sees job runs owned by the user whose credentials it acts under.
cancel_automation_action_run
Cancels a job run and returns {"cancelled": true, "jobRunId": 42}. Only the run's owner may cancel
it.
| Parameter | Type | Description |
|---|---|---|
jobRunId | integer, required | The job run to cancel. |
Cancellation is cooperative: it requests cancellation rather than stopping execution instantly. The Generic Execution Engine checks for the request between steps, so a step already running when the request arrives still finishes before the job run stops. Cancelling a run that has already finished reports a conflict.
Permissions
MCP tools bypass the Symfony #[IsGranted] and kernel.exception pipeline that Studio controllers
rely on, so each handler checks permissions itself, against the user the MCP request authenticated
as. The permission keys map to the checkboxes documented under
Permissions.
Beyond the coarse pcp_general_permission, both list_automation_actions and run_automation_action
honour the per-configuration execute permission, which is the user and role grants configured on each
action. Administrators and users holding pcp_configuration_permission may execute any action.
list_automation_actions omits any action the current user may not execute, and
run_automation_action refuses to start one, mirroring the Copilot chat action list.
Error results
Every failure is returned as a tool result with isError: true and a JSON body of
{"error": ..., "code": ...}, never as a transport-level error. The code is what an agent branches
on:
code | Meaning | Examples |
|---|---|---|
permission_denied | The caller may not do this, and no retry will change that. | Missing pcp_general_permission or pcp_job_run_permission; no execute grant on the action; cancelling a job run owned by someone else. |
not_found | The name or id does not resolve. Re-read it and try again. | Unknown action name; unknown or foreign job run id. |
invalid_request | The call was malformed, or is not valid in the current state. | A malformed elements entry; a JSON list where environmentVariables expects an object; cancelling a job run that is no longer running. |
internal_error | Something failed inside Pimcore. | Anything else. |
An internal_error reads {"error": "Internal error while executing <tool> (ref: <id>). The cause was written to the Pimcore application log.", "code": "internal_error"}. The exception class, message and
stack trace go to the Pimcore log under that same ref rather than to the client, so a client-visible
reply can be tied to the logged exception without disclosing anything.
Tool annotations
Each handler declares a display title and MCP tool annotations. list_automation_actions and
get_automation_action_run are marked read-only; run_automation_action and
cancel_automation_action_run are marked as mutating, so an MCP client that auto-approves read-only
tools still asks before starting or cancelling a run.
Integrating the tools into an MCP server
The tools have to be exposed by an MCP server before an agent can call them. There are two ways to do that.
Option 1: expose them from your own MCP server
The four handlers are autowired services, so any bundle building its own Mcp\Server can register
them. Give the SDK a PSR-11 container that resolves the handler classes, then add each handler:
# config/services.yaml of your own bundle. A service locator keys each entry by its service id,
# which is the class name the SDK looks up when it resolves the handler.
services:
my_bundle.mcp.tool_locator: !service_locator
- '@Pimcore\Bundle\CopilotBundle\Mcp\Tool\ListAutomationActionsTool'
- '@Pimcore\Bundle\CopilotBundle\Mcp\Tool\RunAutomationActionTool'
- '@Pimcore\Bundle\CopilotBundle\Mcp\Tool\GetAutomationActionRunTool'
- '@Pimcore\Bundle\CopilotBundle\Mcp\Tool\CancelAutomationActionRunTool'
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Discovery\DocBlockParser;
use Mcp\Capability\Discovery\SchemaGenerator;
use Mcp\Server;
use Pimcore\Bundle\CopilotBundle\Mcp\Tool\ListAutomationActionsTool;
use Pimcore\Bundle\StudioBackendBundle\Mcp\Tool\ToolInputSchemaNormalizer;
$method = new ReflectionMethod(ListAutomationActionsTool::class, 'execute');
$attribute = $method->getAttributes(McpTool::class)[0]->newInstance();
$schemaGenerator = new SchemaGenerator(new DocBlockParser());
$server = Server::builder()
->setServerInfo('My MCP Server', '1.0.0')
->setContainer($toolLocator)
->addTool(
handler: [ListAutomationActionsTool::class, 'execute'],
name: $attribute->name,
title: $attribute->title,
description: $attribute->description,
annotations: $attribute->annotations,
inputSchema: ToolInputSchemaNormalizer::normalize($schemaGenerator->generate($method)),
)
->build();
Two details are easy to miss:
addTool()does not read the#[McpTool]attribute. Without an explicitnamethe tool is registered under the method name, so all four would be calledexecute. Read the attribute by reflection, as above, and the attribute stays the single source of truth.- Normalize the generated input schema.
ToolInputSchemaNormalizerwidens the parameter types the SDK infers so that a JSON object parameter acceptsobject,arrayandnull. Without it,environmentVariablesrejects both{}andnull.
The handlers check permissions against the user the request authenticated as, so the server must run behind a firewall that establishes a Pimcore backend user. They also assume nothing about the transport.
Option 2: use the Pimcore Agent Bundle
If the Pimcore Agent Bundle is installed, no
integration work is needed: this bundle registers the four handlers with that bundle's
pimcore.mcp_tool extension point, which builds the server, applies the schema normalization, and
serves each group under /pimcore-mcp/agent/<group>.
The handlers are grouped the way the Agent Bundle groups its own tools, so an agent can be given inspection without also being given unattended execution:
| Group | Tools |
|---|---|
pimcore-copilot-automation-read | list_automation_actions, get_automation_action_run |
pimcore-copilot-automation-direct-write | run_automation_action, cancel_automation_action_run |
Attach them to an agent through its pimcoreMcpServers list (tool schemas sent with every turn) or
pimcoreMetaGroups list (discovered on demand through the meta-tool). Agents are defined on the
Pimcore side, either in a config/agents/<agent>.yaml preset, in the project's
config/packages/*.yaml, or in Pimcore Studio under System -> Pimcore Agent ->
Agent Configuration:
pimcoreMcpServers:
- pimcore-copilot-automation-read
- pimcore-copilot-automation-direct-write
To confirm the live registration:
bin/console pimcore-agent:mcp:list-tools
See Custom MCP Tools for the extension contract these groups follow.
Recursion warning
If a custom automation action step invokes an AI agent (for example, a step implementation that calls
the Pimcore Agent Bundle to run an LLM turn as part of the job), and that agent can also call
run_automation_action, the agent can start the action again, including the one that triggered it.
This can loop indefinitely and exhaust the Generic Execution Engine's job queue.
Only give run_automation_action to agents that genuinely need to orchestrate automation actions, and
keep it out of the tool set of any agent invoked from inside an automation action step.