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:
permissionon#[ToolMethod]: the same declaration as#[Tool(permission: ...)], in the syntax of a route's_permissionrequirement. Names joined with,are all required; of names joined with+, any one is enough. It is checked first, and catalogs can filter on it throughToolManager::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 asarray{account: UserInterface}, fails discovery, because no operation can be checked there.accesson#[ToolMethod]: the name of a public method on the same service that receives the resolved input values and the account and returns anAccessResultInterface. 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.