AI access and automation
For AI access you have to do nothing
That is the whole message of this section, and it is meant literally.
Every API operation of your plugin becomes a tool for AI access on its own. The tool catalogue is derived from the same description as the API. There is no second list for you to maintain, and no registration step.
Permissions, brand scope and the audit trail bind in the API, not in the tool. An AI call can never do more than the human whose identity it carries. The audit trail records the actor type, so a call made by an AI is distinguishable.
The module switch applies automatically too
The catalogue carries the responsible module switch for each operation. If your module is off, your operations disappear from the tool list without you doing anything.
After an API change
In development the catalogue has to be regenerated:
bin/console cache:clear
make mcp-operations
The first step is not optional. Without it, new operations are missing from the catalogue, and they are missing silently: no error, no warning, they are simply not there.
When a package is installed, the export runs as part of the sequence.
What deliberately does not become a tool
A plain controller, for instance the receiver of a callback from a foreign system, does not appear in the catalogue. That is intentional: such endpoints are machine-to-machine routes with their own verification. They do not belong in the hands of an AI.
If you want something to appear as a tool, build it as an API resource.
Automation
Here you do have to act. Four contracts, all collected through their tag — implementing is enough, there is no registration:
| What you want to contribute | Contract | Tag |
|---|---|---|
| An action that an automation performs | AutomationActionInterface | app.automation_action |
| A trigger an automation reacts to | TriggerProviderInterface | app.automation.trigger_provider |
| Making that trigger selectable | TriggerCatalogProviderInterface | app.automation.trigger_catalog |
| A comparison operator for conditions | ConditionOperatorInterface | app.automation.condition_operator |
All four live under App\Platform\Automation\.
A trigger, complete
Both trigger contracts belong in one class. That is how the shipped Pages plugin does it in PagesTriggerProvider:
<?php
declare(strict_types=1);
namespace Acme\Shipping\Catalog;
use Acme\Shipping\Event\ShipmentDelivered;
use Acme\Shipping\Event\ShipmentDispatched;
use App\Platform\Automation\Engine\TriggerCatalogProviderInterface;
use App\Platform\Automation\Engine\TriggerProviderInterface;
final class AcmeShippingTriggerProvider implements TriggerProviderInterface, TriggerCatalogProviderInterface
{
/** @var array<class-string, string> */
private const KEYS = [
ShipmentDispatched::class => 'acme_shipping.shipment.dispatched',
ShipmentDelivered::class => 'acme_shipping.shipment.delivered',
];
/** Maps a concrete event to trigger keys. Feeds the runtime. */
public function triggerKeysFor(string $eventClass): array
{
$key = self::KEYS[$eventClass] ?? null;
return null === $key ? [] : [$key];
}
/** Enumerates the same keys for the editor. Feeds `GET /api/v1/automation-catalog`. */
public function authoringTriggerKeys(): array
{
return array_values(self::KEYS);
}
}
That is the whole file.
Implement only TriggerProviderInterface and you build a trigger nobody can select. It works, it fires, it is tested — but it is not in the catalogue that the editor and the AI access query. That is the most common stumbling block here, and exactly why both contracts sit in the same class.
An action
AutomationActionInterface requires two methods:
public function getType(): string;
public function execute(ActionContext $context): ActionResult;
Complete, with the rules that apply:
<?php
declare(strict_types=1);
namespace Acme\Shipping\Automation;
use Acme\Shipping\Entity\Shipment;
use App\Platform\Automation\Action\ActionContext;
use App\Platform\Automation\Action\ActionResult;
use App\Platform\Automation\Action\AutomationActionInterface;
use Doctrine\ORM\EntityManagerInterface;
final class MarkShipmentDeliveredAction implements AutomationActionInterface
{
/**
* The machine key as the class's OWN constant.
*
* A guard in the interface reads the set of all action types from the source and knows
* exactly two forms: a literal or `self::…`. Reference the constant of ANOTHER class and
* it fails there as a parse error — the type would otherwise hang silently outside the
* set and nobody would have noticed its missing label.
*/
public const TYPE = 'acme_shipping_mark_delivered';
public function __construct(private readonly EntityManagerInterface $em)
{
}
public function getType(): string
{
return self::TYPE;
}
public function execute(ActionContext $context): ActionResult
{
$ulid = $context->rawString('shipmentUlid');
if ('' === $ulid) {
return ActionResult::failure('acmeShipping.noShipment');
}
$shipment = $this->em->getRepository(Shipment::class)->findOneBy(['ulid' => $ulid]);
if (!$shipment instanceof Shipment) {
return ActionResult::failure('acmeShipping.shipmentNotFound:'.$ulid);
}
// Dry run: check, but change nothing.
if ($context->isDryRun) {
return ActionResult::success(\sprintf('Dry run: %s left unchanged.', $ulid));
}
$shipment->setStatus('delivered');
$this->em->flush();
return ActionResult::success(\sprintf('Shipment %s set to delivered.', $ulid));
}
}
Three things about this are not obvious:
- You have to honour
$context->isDryRunyourself. The dry run is a request, not a lock. Ignore
it and you change data in a preview.
- The sentences in
ActionResultare a diagnostic log, not user-facing text. They end up in the
run's log and stay in the system language. The interface does not render them.
- No audit entry of your own. The change is recorded on save anyway, with the actor type
automation. An extra entry would be a duplicate per field change.
Validate your action on save
The core does not know the required fields of your action. Without your own validation an incomplete configuration only surfaces at run time, and then it hits the recipient rather than the author.
AutomationActionConfigValidatorInterface closes that gap — implemented by the same action class:
public function validateConfig(array $config, ?int $automationBrandId, bool $automationActive): void
The third parameter is the reason the method takes three: a disabled draft may be incomplete, enabling it may not.
public function validateConfig(array $config, ?int $automationBrandId, bool $automationActive): void
{
if (!$automationActive) {
return; // draft: anything goes
}
if ('' === (string) ($config['shipmentUlid'] ?? '')) {
throw new \InvalidArgumentException('shipmentUlid is mandatory when enabling.');
}
}
A comparison operator
ConditionOperatorInterface is the smallest of the four contracts:
public function operator(): string;
public function matches(mixed $actual, mixed $expected): bool;
Labels
Triggers and actions are labelled through keys, not through text. The convention is binding, because the catalogue computes it:
automation.trigger.<lowerCamel(trigger key)>
automation.action.<lowerCamel(action type)>
So acme_shipping.shipment.delivered becomes automation.trigger.acmeShippingShipmentDelivered.
They live in the locale file of your interface, not in the backend. Details in translations.
Fields for the action form
If your action needs its own input fields in the editor, they come from the interface manifest, not from the backend. See interface.
How this relates to events
A trigger sits on an event. The events after a change feed the fan-out for automations and callbacks; the events before a change deliberately do not, they exist for objection.
So if you want something to fire an automation, you need an event after the change. The full catalogue is in the event reference.
An event can exempt itself where it would otherwise create a feedback loop: a change that fires an automation which performs the same change again.
What goes wrong, and why
| Symptom | Cause |
|---|---|
| New operation missing from the tool catalogue | Cache not cleared before the export |
| Trigger runs but cannot be selected | TriggerCatalogProviderInterface missing |
| Action only fails at run time, at the recipient | AutomationActionConfigValidatorInterface missing |
| Label shows the raw key | Locale file missing, or the key does not follow automation.trigger.<lowerCamel> |
| A dry run changes data anyway | $context->isDryRun not honoured in execute() |
| An automation calls itself | Event after the change without an exemption from the fan-out |
Next
- Connecting the MCP server — the client side
- MCP tool catalogue — what a client actually sees
- Event reference — what a trigger can sit on
- Backend — where the contracts live