AI agent tools¶
Let AI agents work on a Sites platform through MCP, and add a tool of your own.
The Sites modules ship Tool API plugins, one per action: create a site, change its settings, assign content, set an override. Each tool is an adapter over the service that owns the action, so an agent meets the rules of the admin UI. Agent tools lists them with their inputs.
flowchart TB
client["MCP client"] -- "tools/list, tools/call" --> server["MCP Server<br/>drush --uri=HOST mcp:server ACCOUNT"]
server -- "one mcp_tool_config row per tool" --> bridge["MCP Server Tool Bridge"]
bridge -- "access(), then execute()" --> tool["Tool API plugin<br/>sites:site_set_enabled"]
tool --> service["Owning service<br/>sites.lifecycle"]
| Project | Role |
|---|---|
drupal/tool |
The #[Tool] plugin type: typed inputs, checkAccess(), doExecute(). |
drupal/mcp_server |
The MCP server: stdio through Drush, HTTP at /mcp. |
drupal/mcp_server_tool_bridge |
Exposes a Tool API plugin as an MCP tool. |
drupal/tool_belt (optional) |
Generic entity tools: list, load, read, create, update. |
None of them is a dependency of Sites. The tools live in the module that owns the action and are discovered once Tool API is installed.
Set it up¶
composer require drupal/tool drupal/mcp_server drupal/mcp_server_tool_bridge drupal/tool_belt
drush en tool mcp_server mcp_server_tool_bridge tool_belt_content tool_belt_entity
drush updb
Each module ships a bridge row per tool in config/optional; it is installed
with the module when the bridge is present, and by drush updb when the
bridge came later. Disable a row on /admin/config/services/mcp-server/tools
to withdraw a tool.
Give the agent an account of its own. The account argument of mcp:server
names the account the tools run as; it is no authentication, since whoever
starts Drush has full access already. Connect a client with one server per
hostname:
{
"mcpServers": {
"acme": {
"command": "vendor/bin/drush",
"args": ["--uri=https://acme.example.com", "mcp:server", "agent"]
},
"hub": {
"command": "vendor/bin/drush",
"args": ["--uri=https://platform.example.com", "mcp:server", "agent"]
}
}
}
Over HTTP, /mcp takes the Access MCP server permission and a session;
the request's hostname picks the site.
The site context¶
The server negotiates the site once, from --uri, and every call runs in it.
- On a site's hostname, the account's roles on that site apply, content
it creates belongs to the site, and a tool whose
site_idis optional acts on that site. - On the hub, tools take a
site_id. The tools that assign content, set overrides, or enable, arrange or delete sites always take one.
sites:current_site tells an agent where it is; sites:site_list names the
sites it reaches.
What an agent may do¶
There is no permission per tool. A tool's checkAccess() asks the owning
service with the server's account: sites:site_set_enabled asks
SiteLifecycleInterface::enableAccess(), which needs Manage site settings
on that site. Creating, arranging, deleting and cloning sites and raw
configuration overrides take a platform administrator: Administer sites
from a global role. The bridge checks access before every call and returns
the reason of a refusal. The tool list is the same for every account. Tools
that can take something away are flagged destructive.
Content is edited on its canonical site; other sites change their version with an override:
sites:entity_sitesnames the canonical site of an entity.sites:entity_accesssays whether an update is allowed here, and why not.- On the canonical site,
tool_belt:entity_updateedits the entity. On another site,sites_content_overrides:override_setchanges that site's version.
The generic entity tools cannot write role assignments, memberships or configuration overrides; the Sites tools do, through their services.
Add a tool¶
Put the rule in the owning service first, then write the plugin in the module
that owns the action. Drupal\sites\Plugin\tool\Tool\SiteSetEnabled:
#[Tool(
id: 'sites:site_set_enabled',
label: new TranslatableMarkup('Enable or disable a site'),
description: new TranslatableMarkup('Enables or disables a site whose settings you manage. A disabled site keeps its content and settings but stops answering on its hostname.'),
operation: ToolOperation::Write,
destructive: TRUE,
input_definitions: [
'site_id' => new InputDefinition(
data_type: 'string',
label: new TranslatableMarkup('Site ID'),
description: new TranslatableMarkup('The machine name of the site.'),
),
'enabled' => new InputDefinition(
data_type: 'boolean',
label: new TranslatableMarkup('Enabled'),
description: new TranslatableMarkup('True to enable the site, false to disable it.'),
),
],
)]
final class SiteSetEnabled extends SiteLifecycleToolBase {
protected function doExecute(array $values): ExecutableResult {
$site = $this->siteManager->getSite((string) $values['site_id']);
$enabled = !empty($values['enabled']);
$this->lifecycle->setEnabled($site, $enabled);
return ExecutableResult::success($enabled
? $this->t('@site is enabled and answering on its hostname.', ['@site' => $site->label()])
: $this->t('@site is disabled. Its content and settings are untouched.', ['@site' => $site->label()]));
}
protected function checkAccess(array $values, AccountInterface $account, bool $return_as_object = FALSE): bool|AccessResultInterface {
$site = $this->siteManager->getSite((string) ($values['site_id'] ?? ''));
$access = $site === NULL
? AccessResult::forbidden('No such site.')
: $this->lifecycle->enableAccess($account, $site);
return $return_as_object ? $access : $access->isAllowed();
}
}
execute()does not check access; the bridge callsaccess()first.- Set
operationto what the tool does: the bridge marksRead,ExplainandTransformtools read-only for clients,WriteandTriggertools not. Setdestructive: TRUEon a tool that deletes, or that takes something away when an input is left out. - Give an input with a fixed set of values a
Choiceconstraint, like theoperationinput ofsites:entity_access. - Ship the bridge row as
config/optional/mcp_server_tool_bridge.mcp_tool_config.<id>.ymlwith an enforced dependency on your module, and create it in a post-update function for existing installations, as_sites_install_tool_bridge_rows()insites.post_update.phpdoes. A post-update creates the rows it names only, so a row an administrator deleted stays deleted. - Add
drupal/tooltorequire-devto test the tool.
Operating notes¶
drush mcp:serverboots Drupal once. Restart it after a module update or a changed tool row.- Many clients read the tool list once per session; restart the client after enabling tools.