 [ ← blog / drupal ](/) Sep 15, 2026  ·  \#drupal  ·  8 min read 

# Define the capability once; call it from anywhere



       ![Tool fanout of capabilities used in different interfaces](/sites/default/files/styles/homepage_/public/2026-09/tool-fanout-card.png?itok=VlnzQUQT) 

 

Drupal's [Tool API](https://www.drupal.org/project/tool) lets you declare an operation one time — typed inputs, typed outputs, an access check — and then invoke it from Drush, from MCP, from ECA, or from a controller. I spent a good while arguing against it before I understood that, and my DrupalCon Rotterdam session is now built on top of it. Here is what changed my mind.

## Another abstraction in another module

I first heard about the Tool module through the AI initiative, and that is roughly where I filed it. A plugin type for well-described, self-contained operations — declare what a thing does, what typed inputs it takes, what typed outputs it returns. Drupal has a track record of generalizing a problem before anyone has finished understanding the problem, and agent tooling was very much an unfinished problem. Locking in a contract that early usually costs more than it saves.

It stopped being an abstract objection when I started looking at `mcp_server`. On [Acquia Source](https://www.acquia.com/products/source), we already had our own MCP module built directly on the [official MCP PHP SDK](https://github.com/modelcontextprotocol/php-sdk). It worked. But maintaining a private implementation of a public protocol is a bad long-term position, so the goal was to align with the community module instead of carrying our own.

I found an MCP implementation with the Tool API underneath it. Aligning meant adopting not just a protocol server but a plugin type I had already decided was premature — and we would be trading a working thing for it. That is a real cost when the abstraction is unproven, and the protocol isn't.

Mateu ([e0ipso](https://www.drupal.org/u/e0ipso)) has since pulled the two apart. [`mcp_server`](https://www.drupal.org/project/mcp_server) is the protocol runtime and nothing more — it requires only `php` and `mcp/sdk` at the Composer level — and the Tool API integration spun out into [`mcp_server_tool_bridge`](https://www.drupal.org/project/mcp_server_tool_bridge) as its own project.

That split says the instinct about `mcp_server` was right. A protocol server should be a thin integration between Drupal and the SDK, and that is what it is now.

What I had backward was the other half. I was judging the Tool API entirely as a tax on getting MCP working, which is the wrong frame — MCP is one caller among several. I did not see that until I started thinking about the others.

## The Action API objection

My other complaint was that Drupal already has this. Action plugins are executable, access-checked units of work. Why not extend them?

I took that to Michael Lander ([michaellander](https://www.drupal.org/u/michaellander)), expecting a philosophical answer. I got a backward compatibility answer instead, and it is more persuasive.

Look at what an action actually promises. Here is `ExecutableInterface` on 11.x:

```php
interface ExecutableInterface {

  /**
   * Executes the plugin.
   *
   * @todo Uncomment the new $object method parameter before drupal:12.0.0.
   */
  public function execute(/* ?object $object = NULL */);

}

```

No return type. No typed inputs. No declared outputs. Adding one optional parameter is a Drupal 12 change, and it has been sitting commented out waiting for the major.

`ActionInterface` is more honest about it. The docblock carries a `@todo WARNING` list of additions the API was supposed to receive — context awareness, configuration handling, a data processing API — with issue links going back to 2013. None of it landed.

So the question is not "could actions do this." It is "how many minor releases and how many downstream contrib breaks does it cost to get typed inputs, declared outputs, operation semantics, and per-invocation access onto an interface a decade of code already implements?" The answer is: more than building alongside it. The abstraction I was calling overhead was the faster path.

## What made it click: stop thinking about MCP

The shift was thinking about callers instead of protocols.

A tool declares a capability. It does not know who is invoking it. Define the capability once, and every caller gets it:

```bash
drush tool:list --operation=read --format=markdown
drush tool:info canvas_create_page --format=json
drush tool:run canvas_create_page --input='{"title":"Pricing","path":"/pricing"}'

```

Same plugin over MCP stdio. Same plugin from ECA. Same plugin from a controller. `--format=json` on `tool:info` means a developer running an agent locally gets the input contract read back before anything is called, with no protocol server in the loop at all.

## What I built with it

That reframing produced [Canvas Tools](https://www.drupal.org/project/canvas_tools), which is what the session demos.

The problem it solves is specific. [Drupal Canvas](https://www.drupal.org/project/canvas) 1.x ships no public API — the authoring surface is `@internal` top to bottom — and it had no Tool API integration. So the module wraps Canvas's authoring behavior once, keeps all the coupling in two services, and exposes the result as typed operations. The agent never imports a Canvas class. When 1.x internals shift, one service changes.

The rule that makes it safe to run unattended: mutations write to Canvas's auto-save store, never a direct save. Create makes an unpublished draft. "Published" is intent carried in the auto-save and realized atomically when `canvas_publish_auto_saves` runs. A whole page gets built across a dozen operations and either goes live in one commit or gets opened in the Canvas editor for review first. That is Canvas's own draft path, the same one a human editor rides. Nothing about it is agent-specific.

Here is one operation, trimmed:

```php
#[Tool(
  id: 'canvas_create_page',
  label: new TranslatableMarkup('Create Canvas page'),
  description: new TranslatableMarkup('Create a new Drupal Canvas page. The page entity is always created as an unpublished draft, so it never appears live with an empty layout. With published=TRUE (the default) the page is marked to go live the next time auto-saves are published.'),
  operation: ToolOperation::Write,
  destructive: FALSE,
  input_definitions: [/* typed inputs */],
  output_definitions: [/* page_id, uuid */],
)]
final class CreateCanvasPageTool extends CanvasToolBase {

  protected function doExecute(array $values): ExecutableResult { /* … */ }

  protected function checkAccess(array $values, AccountInterface $account): bool { /* … */ }

}

```

The `description` is doing real work. It is the only thing a non-deterministic caller reads before deciding to invoke, so it documents the draft semantics rather than the happy path. `ToolOperation::Write` says this mutates and is not idempotent. `destructive: FALSE` says do not prompt; `canvas_delete_page` sets `TRUE` and callers confirm first.

`checkAccess()` is abstract on `ToolBase`, so every tool has to answer the access question. Canvas Tools answers it by delegating to `$entity->access()` rather than checking a bare permission, which means `hook_entity_access()` and per-entity access modules still apply to an agent exactly as they apply to a person.

## The seam is what generalizes

The interesting part is not the Canvas plumbing. It is that `drupal/tool_belt` was already doing the same thing for content modeling, with no coordination between us:

```bash
tool_belt                          canvas_tools
content type + fields → nodes   →  content template + binding → page

```

`tool_belt` builds the data model and seeds content. Canvas Tools owns display: content templates, page regions, pages. No overlap, no shared code, and an agent chains straight through the two because both are `#[Tool]` plugins with typed outputs. That composition is what I didn't see when I argued that actions could cover this.

Field binding is where the typing earns itself. `canvas_bind_component_props` binds a component prop to a host entity field, and it validates against the same candidate set the Canvas editor offers in a site builder. Bind a boolean field to a string prop, and it is rejected up front with the list of valid fields, instead of failing silently at render.

## The part I did not expect: I am now filing issues against it

Building roughly twenty operations against the Tool API surfaced the gaps you only find by using something hard.

`tool:run` was masking failure messages by reading outputs from a failed tool ([\#3582942](https://git.drupalcode.org/project/tool/-/work_items/3582942), fixed in `tool` 1.0.0-beta3). Tools with no inputs at all were uncallable over MCP, because the bridge emits empty `properties` as a JSON array and the SDK's validator wants an object — a PHP-to-JSON footgun, fixed in [mcp\_server\_tool\_bridge MR !2](https://git.drupalcode.org/project/mcp_server_tool_bridge/-/merge_requests/2) and released in 1.0.0-beta1.

The bigger one is still open ([\#3582943](https://git.drupalcode.org/project/tool/-/work_items/3582943)): a consumer can read *what* tools exist but not enough about them to drive one safely. `tool:info` flattens an input to its data type — it shows `variant: string`, not that it only accepts `info|success|warning`. Required permissions are not listed. `checkRequirements()` failures are not surfaced, so `tool:list` happily shows an agent a tool it cannot run. Most of that metadata is already declared in the model. It just is not introspectable from the outside.

Which is a much better problem to have than the one I thought I had. "This abstraction is premature" turned into "this abstraction needs to expose more of what it already knows." I would rather argue about the second.

## The whole arc, at DrupalCon Rotterdam

The demo runs it end to end: an agent authors an SDC, models a Recipe content type, lays out a content template with props bound to the node's own fields, and publishes a page — every step a `drush tool:run` call, and nothing live until publish. Then the same operations over MCP, with no extra code.

[**SDCs, Canvas, and the Agent That Builds With Them**](https://events.drupal.org/rotterdam2026/session/sdcs-canvas-and-agent-builds-them) — Wednesday 30 September, 15:00, Van Oldenbarnevelt Room.

 

  ![](/themes/custom/mglaman/images/headshot.webp)Thanks for reading

I write about Drupal, PHP, and the quiet infrastructure behind large sites — caching, config, and testing. New posts land every week or two.

 [Subscribe via RSS →](/rss.xml) [About me →](/about-me) 

 



 

  [← Older

simplytest.me should be good enough to sell Drupal with

 ](/blog/simplytestme-should-be-good-enough-sell-drupal) [Newer →

Simplytest.me can launch Drupal CMS site templates

 ](/blog/simplytestme-can-launch-drupal-cms-site-templates)