Skip to content

Exposing service methods as tools

A tool does not have to be a plugin class. Put #[ToolMethod] on a public method of any service and Tool API builds the tool from the method itself: the parameters become inputs, the return value becomes the output, and the docblock supplies the descriptions.

Use this when the behavior already exists. A site that has an AlbumManager service does not need an AlbumPublishTool class that only forwards to it.

One attribute is enough

/**
 * Finds images whose title, caption, or alt text contains a word.
 *
 * @param string $word
 *   A single word or phrase, matched case-insensitively.
 *
 * @return \Drupal\album\Image[]
 *   The images that matched, newest first.
 */
#[ToolMethod(id: 'album.search', operation: ToolOperation::Read, permission: 'access content')]
public function search(string $word): array {

That is a complete tool. The input word comes from the parameter and its @param text, the output is named result and typed from @return, and Image is described from the members it marks with #[ToolProperty] (see Value objects). Nothing else in the module knows Tool API exists, and the Tool Explorer module is not needed for any of it. The sections below are for when the defaults are not what you want.

Tool IDs

id is the one thing #[ToolMethod] never infers. Agent, ECA, and MCP configuration refer to a tool by ID, so the ID must survive a refactor. A default built from the service ID and method name would change silently when either is renamed. A method without an id is left out at discovery, with a logged error naming it.

A fuller example

<?php

declare(strict_types=1);

namespace Drupal\my_module;

use Drupal\node\NodeInterface;
use Drupal\tool\Attribute\ToolEntityInput;
use Drupal\tool\Attribute\ToolMethod;
use Drupal\tool\Attribute\ToolOutput;
use Drupal\tool\Tool\ToolOperation;

final class AlbumManager {

  /**
   * Publishes an album and returns the number of tracks made visible.
   *
   * @param \Drupal\node\NodeInterface $album
   *   The album to publish.
   * @param bool $notify
   *   Whether to email subscribers.
   *
   * @return int
   *   The number of published tracks.
   */
  #[ToolMethod(id: 'album.publish', operation: ToolOperation::Write)]
  #[ToolOutput(name: 'track_count')]
  public function publish(
    #[ToolEntityInput(op: 'update')] NodeInterface $album,
    bool $notify = TRUE,
  ): int {
    // ...
  }

}

This method is the tool album.publish with:

Input Type Required Default Description
album entity:node Yes The album to publish.
notify boolean No TRUE Whether to email subscribers.

and one output, track_count, of type integer described as "The number of published tracks." It runs from Drush, Tool Explorer, and every connector like any other tool.

Invokable services

A service whose whole job is one operation can carry the attributes on the class. They apply to __invoke(), the way #[McpTool] works on an invokable class in the MCP PHP SDK.

#[ToolMethod(id: 'album.list', operation: ToolOperation::Read, permission: 'access content')]
#[ToolOutput(name: 'albums')]
final class AlbumLister {

  /**
   * Lists albums from a year.
   *
   * @return array{title: string, year: int}[]
   *   The albums.
   */
  public function __invoke(?int $year = NULL): array {}

}

What is inferred

From Becomes
Method name Label, for example "Publish"
Docblock summary Description
Parameter name Input name
int, float, string, bool integer, float, string, boolean
A class implementing EntityInterface entity:TYPE when one entity type uses that class, entity when several do
A backed enum string or integer with a Choice constraint on the cases; the method receives the case
\DateTimeInterface, \DateTimeImmutable, \DateTime datetime_iso8601; the method receives the object in UTC
string[], list<int>, array<T>, array<int, T> in @param A multiple input of that item type
array{verbose?: bool, limit: int} in @param A map input with typed properties; ? marks an optional key. An enum, DateTime, or entity inside the shape fails discovery, since the method would get a plain value
array{...}[] in @param A multiple map input
array<string, int>, or any array<K, V> whose key is not an integer A map with no declared properties, so any keys
Default value or nullable type Optional input, with the default value. A caller's NULL means the default, so ?int $limit = 10 is advertised as an optional integer that cannot be null
@param type on an untyped or array parameter The input type, so interface-style docblocks work. Docblock class names are not resolved against use statements, so write them fully qualified
@param description Input description
Return type, or @return type One output, named result unless #[ToolOutput] names it
@return string[] A multiple output
@return array{title: string, year: int} One typed output per key, returned as a keyed array; a #[ToolOutput] refines the key it names. The shape must not be nullable
@return array{...}[] with one #[ToolOutput] A single multiple map output with typed properties
@return description Output description
void, or no return at all No output
static, self, or the declaring class No output, since a fluent return tells the caller nothing

A method whose docblock is {@inheritdoc} borrows the docblock of the same method on its interface or parent class, so contracts documented the Drupal way need no duplication.

Inference is strict. A parameter Tool API cannot type, such as a bare array or a non-entity object, fails discovery for that tool with a message naming the method. So does a return type of object, or a union that includes a class, unless #[ToolOutput] declares a data_type. Give that parameter a type it understands or an explicit definition. See When a definition is invalid for what "fails discovery" means for the rest of the site.

A tool needs a description, so a method with neither a docblock summary nor description on #[ToolMethod] fails discovery too. The snippets on this page leave docblocks out where they are not the point; a real method still needs them for its description and for the item type of an array parameter.

Every parameter becomes an input the caller can set. A method with an internal parameter, such as an output directory or a flag only other services should pass, cannot be exposed as is. Put the attribute on a small wrapper method that takes only what the caller may choose:

/**
 * Exports the translations of the given nodes.
 *
 * @param \Drupal\node\NodeInterface[] $nodes
 *   The nodes to export.
 * @param string $langcode
 *   The language to export.
 */
#[ToolMethod(id: 'translation.export', operation: ToolOperation::Write, permission: 'export translations')]
#[ToolOutput(name: 'run', members: ['langcode', 'documents' => ['uri', 'problems']])]
public function exportForTool(#[ToolEntityInput(op: 'view')] array $nodes, string $langcode): ExportRun {
  return $this->export($nodes, $langcode, $this->defaultDirectory);
}

Overriding what is inferred

There are three levels, from lightest to heaviest. Each wins over the ones below it. Prefer describing an input on its parameter; the method level is for a definition that reads better next to the others than inline.

Individual properties on #[ToolInput]: description, label, data_type, constraints, multiple, and example. The description is the first positional argument, so #[ToolInput('One word or phrase.', example: 'Oma')] string $word is the short form. The example is one value an agent could pass, or one item for a list input, and reaches the schema as the JSON Schema examples keyword. Anything left NULL keeps its inferred value. A parameter that takes an entity uses #[ToolEntityInput] instead, which has the same properties, except config, plus the required op.

#[ToolInput(data_type: 'email')] string $recipient,
#[ToolInput(constraints: ['Choice' => ['choices' => ['draft', 'live']]])] string $state,
#[ToolInput(data_type: 'datetime_iso8601', constraints: ['DateOnly' => []])] ?string $on = NULL,
// Documented as "@param string[] $tags" in the docblock.
#[ToolInput(constraints: ['NotBlank' => []])] array $tags,

A required list or map input accepts [] and {}, and a required list or map output may return them. Declare NotBlank when an empty value is a caller mistake, as for $tags above. See Required, empty, and NotBlank.

On a list parameter, NotBlank and Count bind the list, so NotBlank rejects [] and Count bounds the number of items. Every other constraint, such as Choice, binds each item, along with what the item type implies, such as an enum's choices.

A complete definition on the parameter: #[ToolInput(definition: ...)] takes any InputDefinitionInterface, exactly what #[Tool] accepts in input_definitions. #[ToolOutput(definition: ...)] likewise takes an OutputDefinitionInterface. Use it for lists with item definitions and maps with property definitions, which cannot be read from a PHP signature.

#[ToolInput(definition: new MapInputDefinition(
  label: 'Settings',
  description: 'Rendering options.',
  required: FALSE,
  property_definitions: [
    'verbose' => new InputDefinition('boolean', 'Verbose', 'Log every step.', required: FALSE),
  ],
))]
array $settings = [],

#[ToolMethod] itself carries id, which is required, label, description, operation, destructive, and the access options. Outputs have their own attribute. A method that leaves destructive out counts as destructive when its operation is Write, the default, or Trigger; one that only adds data says destructive: FALSE.

Outputs

Every tool result is an array of values keyed by output name. A method returns one value, so by default it is published as the single output result, typed from the return type or @return. #[ToolOutput] on the method changes that: name, description, label, data_type, multiple, example, media_type, config, members for a value object, and a full definition.

One #[ToolOutput] refines the single output. The return value is still published as is, whatever its PHP type, so a method returning a list stays one multiple output.

#[ToolMethod(id: 'album.add_track', operation: ToolOperation::Write, permission: 'edit albums')]
#[ToolOutput(name: 'track')]
public function addTrack(...) {}   // type still inferred from @return

Two or more #[ToolOutput] attributes on an undocumented return declare several outputs. The method then returns an array keyed by those names, and each output needs a data_type or a definition, because nothing can be inferred per key. Returning anything but an array is a developer error and throws.

#[ToolMethod(id: 'album.summarize', operation: ToolOperation::Read, permission: 'access content')]
#[ToolOutput(name: 'count', data_type: 'integer', description: 'How many tracks.')]
#[ToolOutput(name: 'titles', data_type: 'string', multiple: TRUE, description: 'Track titles.')]
public function summarize(#[ToolEntityInput(op: 'view')] NodeInterface $album): array {
  return ['count' => 12, 'titles' => [...]];
}

This mirrors how the MCP PHP SDK works: its #[Schema] attribute takes a full definition that replaces inference or individual keywords that refine it, and its #[McpTool] takes an explicit outputSchema.

A documented return shape does the same without attributes. Each key is an output, typed from the shape, and a key?: entry is optional:

/**
 * Summarizes an album.
 *
 * @return array{title: string, track_count: int, cover?: array{url: string}}
 *   The summary.
 */
#[ToolMethod(id: 'album.summarize', operation: ToolOperation::Read, permission: 'access content')]
public function summarize(#[ToolEntityInput(op: 'view')] NodeInterface $album): array {}

With a shape, #[ToolOutput] names one of its keys and layers onto it: a description, a media_type, a members list when the key holds a value object or a list of them, or a data_type that narrows the type. The shape still supplies everything the attribute leaves out, so a documented return and described outputs are not a choice between two things. A name that is not a key fails discovery, naming the keys that exist.

/**
 * Finds images whose title contains a word.
 *
 * @return array{total: int, results: \Drupal\album\Image[]}
 *   How many matched and the current page of them.
 */
#[ToolMethod(id: 'album.search', operation: ToolOperation::Read, permission: 'access content')]
#[ToolOutput(name: 'total', description: 'Matches across every page.')]
#[ToolOutput(name: 'results', members: ['title', 'url', 'caption'])]
public function search(string $word, int $page = 0): array {}

To publish a list of structured rows as one output, document the shape as a list and name the output:

/**
 * @return array{title: string, year: int}[]
 *   The albums, newest first.
 */
#[ToolMethod(id: 'album.list', operation: ToolOperation::Read, permission: 'access content')]
#[ToolOutput(name: 'albums')]
public function list(?int $year = NULL): array {}

Shapes nest, and a @param or @return type may wrap across docblock lines as long as its brackets balance.

Returning an ExecutableResult

By default the result message is "LABEL completed." and the only way to fail is to throw. Throw Drupal\tool\Exception\InputException for a caller mistake: the caller gets an input error with your message. Any other exception, \InvalidArgumentException included, is logged and the caller only learns that the run failed, because a service's own exception messages can name services or query internals. A #[Tool] plugin's \InvalidArgumentException is still reported as an input error, so when converting a plugin, throw InputException wherever its message is meant for the caller, such as "that name is taken".

A method that needs its own message, a FailureCategory, or a failure that still carries outputs returns an ExecutableResult, which is used as is:

/**
 * Exports the translations of the given nodes.
 *
 * @param \Drupal\node\NodeInterface[] $nodes
 *   The nodes to export.
 * @param string $langcode
 *   The language to export.
 */
#[ToolMethod(id: 'translation.export', operation: ToolOperation::Write, permission: 'export translations')]
#[ToolOutput(name: 'files', data_type: 'uri', multiple: TRUE, description: 'The written files.')]
#[ToolOutput(name: 'string_count', data_type: 'integer', description: 'How many strings were exported.')]
public function export(#[ToolEntityInput(op: 'view')] array $nodes, string $langcode): ExecutableResult {
  $run = $this->exporter->export($nodes, $langcode);
  $outputs = ['files' => $run->uris(), 'string_count' => $run->stringCount()];
  if ($run->problems() !== []) {
    return ExecutableResult::failure(new TranslatableMarkup('The export wrote files that failed the post-write check.'), $outputs, FailureCategory::Runtime);
  }
  return ExecutableResult::success(new TranslatableMarkup('Exported @count strings.', ['@count' => $outputs['string_count']]), $outputs);
}

The return type says nothing about the outputs, so each one is declared with #[ToolOutput] and a data_type, definition, or config, as with several outputs. Values are published as returned, so normalize value objects yourself; a members list on such an output fails discovery.

Output values on a failure are reachable through $tool->getResult()->getContextValues(), not getOutputValue(), which only reads the outputs of a successful run.

Value objects

A plain PHP class in a return type, or inside a shape or list, is described member by member as a map output, and published as an array at run time by the same rule. A member is a public property or a public method marked #[ToolProperty]. Nothing else is published, so the class exposes what its author chose, and a getter added later stays out until it is marked.

final class Image {

  #[ToolProperty]
  public function getTitle(): string {}

  #[ToolProperty(data_type: 'uri')]
  public function getUrl(): string {}

  #[ToolProperty(media_type: 'text/html')]
  public function getCaption(): ?string {}

  #[ToolProperty(data_type: 'timestamp')]
  public function getDate(): ?int {}

  public function getStoragePath(): string {}

}

getStoragePath() is not published. A promoted constructor property takes its mark in the constructor, and its @param line supplies the description and, for Image[], the item type:

final class Album {

  /**
   * @param string $title
   *   The album title.
   * @param \Drupal\album\Image[] $images
   *   The images, in display order.
   */
  public function __construct(
    #[ToolProperty]
    public readonly string $title,
    #[ToolProperty]
    public readonly array $images = [],
  ) {}

}

Output names are snake_case, like input names. A property $targetLangcode publishes as target_langcode. A marked method takes no required argument. getReleaseYear() becomes release_year, isExplicit() becomes explicit, hasCover() keeps its prefix as has_cover, and a method without one of those prefixes keeps its whole name. A mark on a private property, a static member, or a method that needs an argument fails discovery rather than being skipped.

A mark on an interface method, or on a method a subclass overrides, applies to the implementation too, so a method can return an interface such as AlbumInterface and publish whatever class implements it.

A returned class that marks no member fails discovery too, naming the class. Without that rule a forgotten class would publish whatever it encodes to, public properties included, under an untyped schema. Entities, config, dates, enums, and JsonSerializable classes publish through their own rules and are not value objects.

The attribute is inert without Tool API: PHP resolves an attribute only when something reflects it, so the class carries no runtime dependency on the module. It still imports Drupal\tool\Attribute\ToolProperty, though. To keep a domain class free of that, select its members at the call site instead, as described below. Two methods that return Image describe it identically unless a call site selects members.

Describing a class only reflects it. Marked methods run when a value is published, once per member, so they must be cheap and free of side effects. A method that queries or reads the filesystem should not be marked.

Refining a member on the class

What a member holds is a fact about the class, so it is stated on the class, once, on the mark. The arguments refine what inference gets wrong: description, name, label, data_type, constraints, multiple, media_type, example, or omit_when_null: TRUE to leave the key out of the published value when the member is NULL.

An absent key and a null are different facts to a JSON client. A ?string $summary member is published as "summary": null, which is what the PHP type says. When the API it replaces left the key out instead, the way a summary?: string entry in a documented shape does, mark the member:

#[ToolProperty(omit_when_null: TRUE)]
public function getSummary(): ?string {}

The member must be nullable, or discovery fails. The schema is unchanged: the member was already optional.

Choosing members at the call site

One tool may want fewer members than the class marks: a search result needs a title and a URL, not the full record. Name the ones to publish on the output. The list selects and orders within the marked members, and anything not listed is neither described nor called:

#[ToolOutput(name: 'images', members: ['title', 'url', 'caption', 'date'])]
public function search(string $word): array {}

The list applies to the value object the output returns, or to each item when it returns a list of them. A member name used as a key selects the members of the object that member holds, to any depth:

#[ToolOutput(name: 'run', members: ['langcode', 'documents' => ['uri', 'problems']])]
public function export(#[ToolEntityInput(op: 'view')] array $nodes, string $langcode): ExportRun {}

ExportRun and the document class it lists need no #[ToolProperty]: the selection marks the members, and their @param or @var lines still supply the types and descriptions. A nested object without a selection publishes every member it marks.

A name that is not a member fails discovery, naming the method and the tool. So does a list on an output that has no members to select, such as a string or a data_type that replaced inference, and a nested selection on a member that does not hold a value object. omit_when_null on a call-site ToolProperty applies to the output's own members only; one inside a nested selection fails discovery, so mark that member on its class instead.

Refining a class you cannot annotate

When the value object comes from a library or another module, a named ToolProperty in the list refines the member at the call site:

#[ToolOutput(name: 'images', members: [
  'title',
  'url',
  new ToolProperty(name: 'caption', media_type: 'text/html'),
  new ToolProperty(name: 'date', data_type: 'timestamp'),
])]

This is the escape hatch, not the default. A fact stated at one call site is absent from the next, so two tools that return the same class can disagree about it, and nothing warns. When you own the class, put the fact on the class.

Saying what a string contains

title, alt, and caption are all string to PHP, and a front end that prints markup as text, or text as markup, has a bug. media_type on #[ToolInput], #[ToolOutput], or #[ToolProperty] records what the string holds and is published as the JSON Schema contentMediaType keyword.

A member typed \DateTimeInterface is published as an RFC 3339 string and described as date-time. A date held as an integer needs data_type: 'timestamp'. On current core, 11.4, that is advertised and published as an RFC 3339 date-time string too, not as the integer. How a timestamp is advertised on older core versions is being settled in #3583005.

A member with no docblock and no description gets no description in the schema, rather than its title repeated.

Entities inside a value object stay entities, so a wire invoker still receives handles for them. A member that refers back to a class being described is typed any rather than recursing.

Value objects are outputs only. A parameter typed with one fails discovery, because nothing knows how to build the object from an array.

Config objects

Simple configuration already has a schema, so config: 'system.site' on #[ToolInput] or #[ToolOutput] describes the value from it: the config's keys, their types, and their labels, nested for mappings and sequences. An input carrying a config name must be an array parameter and receives the values. An output may return the config object or its values.

#[ToolMethod(id: 'site.info', operation: ToolOperation::Read, permission: 'access content')]
#[ToolOutput(name: 'site', config: 'system.site')]
public function siteInfo(): ImmutableConfig {
  return $this->configFactory->get('system.site');
}

#[ToolMethod(id: 'site.update_info', operation: ToolOperation::Write, permission: 'administer site configuration')]
public function updateSiteInfo(#[ToolInput(config: 'system.site')] array $values): void {}

A config name without a schema fails discovery.

Access

Access is never inferred. Every attributed method must declare at least one of these, and every declared check must pass:

  • permission on #[ToolMethod]: the same declaration as #[Tool(permission: ...)], in the syntax of a route's _permission requirement. Names joined with , are all required; of names joined with +, any one is enough. It is checked first, and catalogs can filter on it through ToolManager::checkPermission() without instantiating the tool.
  • #[ToolEntityInput(op: ...)] on an entity parameter: the account must have that operation on the entity passed for the parameter, checked with the entity's own access control handler. For a list of entities every item is checked. An optional entity input left empty checks nothing. An entity inside a map or array shape, such as array{account: UserInterface}, fails discovery, because no operation can be checked there.
  • access on #[ToolMethod]: the name of a public method on the same service that receives the resolved input values and the account and returns an AccessResultInterface. Use this when the rule is not an entity operation and not a single permission.

#[ToolEntityInput] is not optional for entity parameters. Discovery fails when a parameter whose input is entity-typed, however that type was arrived at, carries a plain #[ToolInput] or no attribute, and when #[ToolEntityInput] sits on a parameter that is not an entity. An entity can therefore never reach a method without an access operation to check.

#[ToolMethod(id: 'album.publish', operation: ToolOperation::Write, access: 'publishAccess')]
public function publish(#[ToolEntityInput(op: 'view')] NodeInterface $album, bool $notify = TRUE): int {}

public function publishAccess(array $values, AccountInterface $account): AccessResultInterface {
  return AccessResult::allowedIf($values['notify'] === FALSE)
    ->orIf(AccessResult::allowedIfHasPermission($account, 'notify album subscribers'));
}

Prefer the entity operation when an entity access handler already knows the rule. Node access, for example, grants an author update access on their own album when they hold "edit own album content", so #[ToolEntityInput(op: 'update')] on the album parameter is the whole check.

When converting an existing plugin, note that the required op adds an access check on every entity passed in. A plugin that only checked a permission now also checks, for example, view access on each node. That is the right default, but it can deny calls the old plugin allowed.

A method with no declared check fails discovery. That is deliberate: a tool that anyone can call should say so with a permission such as access content. An entity operation only counts when it runs on every call, so it must sit on a required, single entity parameter. A method whose only check is on an optional entity, or on a list of entities that may be empty, fails discovery too, since a caller could leave the entity out and skip the check.

How discovery works

A compiler pass finds the services with #[ToolMethod] methods while the container is built and records them in the tool.method_tools parameter. It reads each service class's source for the attribute name before loading the class, so the scan costs about 10 ms on a site with 900 services instead of the 130 ms that autoloading them all would. A service that inherits an attributed method without naming the attribute in its own file can opt in with the tool.methods service tag. The tool manager reads that parameter during plugin discovery and merges the resulting definitions with the tools found under Plugin/tool/Tool. Definitions are cached like any other plugin definition, so a new attribute needs a cache rebuild to appear.

The pass runs before service decorators are applied. A decorated service's tools are described from the decorated class, where the attributes are, and run through the decorator, since that is the service the container returns. The decorator must therefore have every attributed method, and the method named in access, usually by implementing the same interface.

When a definition is invalid

A method whose definition cannot be built is left out, and the error is logged to the tool channel with the tool ID and the reason. The other tools on the site still load. Without that, one module with a mistake, or a change to a value object far from the tool, would take every tool offline for MCP, agents, and ECA. The same applies to a typed __invoke() plugin whose signature cannot be described, to two methods sharing an ID, and to a method reusing a plugin class's ID.

Check the log after adding an attribute:

drush watchdog:show --type=tool

Tests that build definitions with MethodToolDefinitionBuilder and no logger get the exception instead, so they fail loudly.

The plugin class

The plugin class for every method tool is Drupal\tool\Tool\Method\MethodTool. It fetches the service from the container, maps the validated inputs onto the method arguments by name, and publishes the return value as the output. An optional input left empty is not passed, so the method's own default applies.