Skip to main content
← blog / drupal
Sep 15, 2026  ·  #drupal  ·  8 min read

Define the capability once; call it from anywhere

Drupal's Tool API 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, we already had our own MCP module built directly on the official MCP 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) has since pulled the two apart. 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 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), 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:

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:

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, which is what the session demos.

The problem it solves is specific. Drupal 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:

#[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:

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, 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 and released in 1.0.0-beta1.

The bigger one is still open (#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 — Wednesday 30 September, 15:00, Van Oldenbarnevelt Room.

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.