Skip to content

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_id is 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:

  1. sites:entity_sites names the canonical site of an entity.
  2. sites:entity_access says whether an update is allowed here, and why not.
  3. On the canonical site, tool_belt:entity_update edits the entity. On another site, sites_content_overrides:override_set changes 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 calls access() first.
  • Set operation to what the tool does: the bridge marks Read, Explain and Transform tools read-only for clients, Write and Trigger tools not. Set destructive: TRUE on 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 Choice constraint, like the operation input of sites:entity_access.
  • Ship the bridge row as config/optional/mcp_server_tool_bridge.mcp_tool_config.<id>.yml with an enforced dependency on your module, and create it in a post-update function for existing installations, as _sites_install_tool_bridge_rows() in sites.post_update.php does. A post-update creates the rows it names only, so a row an administrator deleted stays deleted.
  • Add drupal/tool to require-dev to test the tool.

Operating notes

  • drush mcp:server boots 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.