Install

Works with Drupal: ^10.5 || ^11

Using Composer to manage Drupal site dependencies

Alternative installation files

Download tar.gz 653.95 KB
MD5: b7bfe68a868618458204ca6ca137479a
SHA-1: 92ce55aa1473d087fc81697e4240769684232933
SHA-256: 6448bc5d2bb05d885c052525154bee0bac0ba27b2152f810cbdcece250c4602c
Download zip 917.81 KB
MD5: 3e2f084f7c4fdd07ca3fec658986a603
SHA-1: cb3fa3b0aab7d3ee59c4c09cfd8b68cc04f7cd48
SHA-256: c4bfffb8b86f6e7f2a261c645f3f65006ea6114943fa3ac54cf09080583bd978

Release notes

Beta12 is about callers that can correct themselves. Every tool call runs through one sequence. Every failure names the input, item or property it is about. The advertised schema says what a tool really accepts and returns. And a service method can now become a tool with a few attributes, with no plugin class.

Tools from typed methods

  • Put #[ToolMethod] on a public service method and it is a tool: parameters are inputs, the return value is the output, the docblock supplies the descriptions (#3518120). Scalars, entities, backed enums, DateTimeInterface, docblock list and shape types and value objects are read from the signature; #[ToolInput], #[ToolEntityInput], #[ToolOutput] and #[ToolProperty] say what PHP cannot, and config: 'system.site' builds a definition from config schema.
  • Access is never inferred. A method declares a permission, an entity operation with #[ToolEntityInput(op: 'update')], or an access method on the same service; a method with no check fails discovery, as does anything the describer cannot type.
  • A plugin can implement a typed __invoke() instead of doExecute(). The guide is docs/developers/tool-methods.md.

One sequence for every call

  • The new tool.runner service (and its ToolManager::run() facade) owns the invoker sequence: declared permission before the tool is instantiated, inputs, validation, access(), then execute() (#3583078). An explicit account: runs the call as that user. It returns a ToolInvocation carrying the result, the formatted result and the resolved input values, even on a failed call.
  • Drush tool:run and the AI connector now run through it. The MCP bridge adopts it in mcp_server_tool_bridge!29.
  • ToolMessages holds the shared plain-text formatters, so every invoker prints one denial shape (Access denied for tool <id>. <reason>) and one invalid-input shape (#3583082).
  • A denial reason reaches the caller as the tool wrote it. The message escapes it once, and text that looks like a tag or an entity is no longer stripped or decoded (#3583109).

Failures say what failed

  • A violation's message is now the reason alone. The input's name is its property path, and item and property positions join it (codes.0, product.sku, rows.0.sku) (#3583085, #3583065). Output violations follow the same shape.
  • Failed results carry their violations: getViolations() on the result, and InputException carries them too (getViolations(), getInputNames(), a plain-text getMessage()). A tool's own cross-input check throws new InputException($reason, inputNames: ['end_date']).
  • Every input that cannot be stored is reported in the same response, named on its path: a malformed or unknown entity handle, a locked or undeclared input. The inputs that were stored are still validated, so a bad handle and a bad value arrive together (#3583091):
    Invalid input:
    entity: The handle "handle:nope" is not a valid handle.
    mode: The value you selected is not a valid choice.
    
  • The "This input must be an entity." violation is the reason alone for every invoker; it no longer guesses whether the invoker can pass entities. drush tool:run adds its own advice for an entity input it cannot fill (#3583083).
  • execute() with a required input set to NULL is now an input failure naming the input (#3583094).
  • An entity's label appears in a handle hint or a Drush tool:run entity reference only when the account passes the entity's view label check and, where the label comes from a field, field access on it (#3583108). Drush checks the --uid account the tool ran as. Hints now include a user's name, which core shows to every account.

The schema tells the truth

  • destructive can be left undeclared, and then follows the operation: a write or trigger tool counts as destructive unless it declares destructive: FALSE. This reaches isDestructive(), the Drush listings and the MCP bridge's destructiveHint (#3583086).
  • Output definitions gain a nullable flag, separate from required, defaulting to FALSE: null means the output is absent unless the output says null is a value it carries (#3583043).
    • required yes, nullable no: always present, never null. Schema: T, listed in required.
    • required yes, nullable yes: always present, null when there is no value. Schema: [T, "null"], listed in required.
    • required no, nullable no: a null is left out of the result. Schema: T, key may be missing.
    • required no, nullable yes: sent as null when there is no value. Schema: [T, "null"].
  • Timestamp inputs advertise the integer seconds they already accepted, and a datetime input without a format marker offers both date-time and date, so strict gates such as the MCP SDK no longer turn either spelling away (#3583005). Output schemas are unchanged.
  • A description is no longer prefixed with the label, which already goes out as title; a list's description sits on the array instead of its items; a definition without a description sends none (#3583032).
  • A string can say what it contains: a MediaType constraint publishes contentMediaType (#3583076).
  • Declaring the generic entity type on an entity definition class now fails where it is written, instead of later as a missing entity:entity plugin (#3583063).

Tool Explorer

  • The execute form reports the outcome once: a denial with its reason, a failure as one error, a success as one status. A constraint violation lands on the field that carries it (#3582968).

Drupal 10

  • The Drupal 10 jobs now run on every pipeline, and the suite passes on PHPUnit 9 (#3583074).
  • Known difference: on Drupal 10, validating a map input also validates the fields of an entity inside it and reports a map property's violation twice. Four tests skip on Drupal 10 until #3583093 settles it.

Deprecations

  • ContextDefinitionNormalizer is now InputOutputDefinitionNormalizer, and the service tool.normalizer.context_definition is now tool.normalizer.input_output_definition. The old names keep working with a deprecation and go in rc1 (#3583075).

Upgrading from beta11

Run database updates (drush updb); the post-update rebuilds the container for ToolManager's new arguments. Then watch for:

  • Message text. Violation messages no longer start with Input <name>: and no longer contain (item N) or (property x), and output failures read path: reason. Code that parsed those strings reads getPropertyPath() or uses ToolMessages.
  • InputException. getMessage() is plain text, so stop decoding it; read the input from getInputNames(). A thrower that cast a TranslatableMarkup to string should pass the object.
  • Destructive. An undeclared write or trigger tool is now reported destructive. Declare destructive: FALSE on write tools that only add data.
  • Nullable outputs. An optional output that never declared nullable no longer advertises null, and a NULL it returns is left out of the result. Declare nullable: TRUE to keep sending null. A nullable member or return of a method tool is published as required and nullable.
  • Schema text. Every description loses its label prefix. This is visible to LLM clients, which mostly read description.
  • Entity definitions. new EntityInputDefinition('entity') throws; a generic entity input is new InputDefinition('entity').
  • Plugins. ToolBase::doExecute() is no longer abstract and defaults to __invoke(); a plugin with neither is left out at discovery. typedMethodSignature(), invokeTyped() and entityInputAccess() are now protected methods on ToolBase, so a plugin method with one of those names must be renamed.
  • Exceptions in method tools. A method tool reports its own \InvalidArgumentException as a failed run; throw InputException for messages meant for the caller.
  • Entity input violations. Code that matched "cannot pass entities" in a violation message reads the input from getPropertyPath() and the data type from the definition instead; only drush tool:run still prints that advice.
  • Entity labels. A handle hint or Drush entity reference carries no label for an entity the account may not view label, or whose label field it may not view. EntityHandleTransformer::getAccessibleLabel() takes an optional account.
  • MCP bridge. Update mcp_server_tool_bridge to its first release that requires beta12. Earlier bridge releases send input violations to MCP clients without the input's name.
  • Implementors. ToolResultInterface gained getViolations() and ToolInterface::setFailureResult() a third parameter. Nothing in-tree or in the sibling projects implements them directly.

Contributors

Code from @dries, @jurgenhaas, @mglaman, @michaellander and @sjpagan; review and real-module testing from @f0ns and @svendecabooter.

Created by: michaellander
Created on: 20 Oct 2026 at 21:00 UTC
Last updated: 20 Oct 2026 at 21:00 UTC
Bug fixes
New features

Other releases