Goal: Let a local agent read and build displays through MCP, with the same capabilities and access as a person in the Builder, and without the AI module.

Architecture: We write no MCP code. A new project, display_builder_tools, holds the agent operations as Tool API plugins. MCP Server exposes them through its Tool Bridge, Drush through tool:run, the AI chat through tool_ai_connector. One definition, three callers.

Tech stack: Drupal 11, Tool API 1.0.0-beta12 (drupal/tool), MCP Server 2.0.0-beta7, MCP Server Tool Bridge 1.0.0-beta5, AI 1.5.2, AI Agents 1.3.5.

Spec: none. This document carries the decisions. Pattern to copy: drupal/canvas_tools 1.0.0-beta2, by mglaman.

Global Constraints

  • display_builder and its submodules gain no dependency. Their composer.json and .info.yml files stay untouched by this plan. Allowed there: ui_patterns, ui_styles, ui_skins, ui_icons.
  • Nothing moves out of display_builder or its submodules.
  • display_builder_tools never requires drupal/ai, drupal/ai_agents or an MCP project. MCP stays a suggest.
  • Every instance read checks $instance->access('view'). Every instance write checks $instance->access('update'), as display_builder.routing.yml does.
  • Tools call InstanceInterface, never SourceTree.
  • No publish tool. A person publishes from the Builder.
  • Every write ends with SseState::record($instance_id), no session id.
  • No drupal.org issue links in docblocks, no TASKS/ references in code.
  • Comments and docblock prose wrap at 80 columns.

What moves

From display_builder and its submodules: nothing. The whole write surface already exists on InstanceInterface: attachToRoot(), attachToSlot(), moveToRoot(), moveToSlot(), setSource(), setThirdPartySettings() and remove(). Each creates a revision with a log message and the current user, so an agent's edit lands in the Builder history. collectDisplays() already lists displays read-only, access-checked, with a limit. The dependency arrow only points down:

ui_patterns, ui_styles, ui_skins, ui_icons
  ^
display_builder            unchanged
  ^
display_builder_tools      adds drupal/tool
  ^
display_builder_ai         adds tool_ai_connector, keeps ai and ai_agents

mcp_server + mcp_server_tool_bridge: optional, site's choice, read tool plugins

From display_builder_ai to display_builder_tools:

What Why it moves
15 tools, see the table below They only read or write displays
ComponentSourceBuilder and its test Depends on core and InstanceInterface only
GetAvailableComponentsTest Follows its tool
default_profile, excluded_component_ids, excluded_component_prefixes Read by moved tools
Their three SettingsForm fields A site without the AI module still needs them

Stays in display_builder_ai: DescribeImage, FetchUrl (a local agent does both itself), AiChatPanel, the DeepChat controller, the 13 agents, the assistant, DisplayBuilderAiTempStore, DisplayBuilderAiHelper, chat_timeout, the use display builder ai permission.

New in display_builder_tools: InstanceToolBase, four tools, the use display builder tools permission, a settings form, Tool Bridge config, a skill.

New in display_builder_ai: ActiveInstanceSubscriber, four post updates.

Why a separate project

Composer requirements belong to the project, not the submodule. A submodule of display_builder_ai would pull drupal/ai. A submodule of display_builder would put a beta Tool API in its require-dev and CI, and would add to the surface frozen at RC1. A separate project keeps the arrow clean, and follows a beta dependency at its own pace. Cost: one more project to release. The name sits close to display_builder_dev_tools, a local demo module. Accepted, since canvas_tools set the convention.

Tools

The rule is mechanical: display_builder_ai:X becomes plugin display_builder_X, seen by agents as tool:display_builder_X. Class: Drupal\display_builder_tools\Plugin\tool\Tool. All declare permission: 'use display builder tools'.

Tool From Operation Extra access
display_builder_list_displays new Read collectDisplays() filters
display_builder_get_current_display port Read instance view
display_builder_get_display_context port Read instance view
display_builder_get_available_components port Read none
display_builder_get_component_details port Read none
display_builder_get_prop_sources port Read none
display_builder_get_field_sources port Read none
display_builder_get_entity_fields port Read none
display_builder_get_views port Read none
display_builder_get_view_display port Read none
display_builder_get_ui_styles port Read none
display_builder_get_ui_skins port Read none
display_builder_get_available_icons port Read none
display_builder_create_page port Write, adds page_layout createAccess()
display_builder_add_component port Write, adds instance update
display_builder_set_layout port Write, destructive instance update
display_builder_update_node new Write, adds instance update
display_builder_move_node new Write, adds instance update
display_builder_remove_node new Write, destructive instance update
  • instance_id is a required input. A tool never guesses a target.
  • props and slots are MapInputDefinition. components is a ListInputDefinition of maps. They replace props_json, slots_json and components_json, so a client sends objects, not escaped strings. Task 2 must prove this first.
  • Read tools keep today's YAML payload in one string output named result.

Soft dependencies have no attribute in Tool API. #[Hook('tool_info_alter')] removes display_builder_get_ui_skins without ui_skins, display_builder_create_page without display_builder_page_layout, and display_builder_get_views plus display_builder_get_view_display without views.

The chat's active instance

Today a tool falls back on the tempstore written by AiChatPanel.php:149. That fallback leaves the tools. tool_ai_connector forwards only values the model sent, so ToolInputTransformEvent never fires for an omitted input and cannot fill it.

ai_agents has the right hook. AiAgentEntityWrapper::executeTool() dispatches AgentToolPreExecuteEvent before $tool->execute(), default information tools included. ActiveInstanceSubscriber sets instance_id from the tempstore when empty, for tools starting with tool:display_builder_. On AgentToolFinishedExecutionEvent for create_page, it stores the new instance as active. The tools stay strict, and all chat logic stays in the chat module.

Review Focus

  1. A display with built: FALSE is targeted. It has no instance yet. Refuse, and point at display_builder_list_displays, which gives its URL. Test in Task 6.
  2. A full root or slot, or an unknown node. Return the reason, never "Tool execution failed: OutOfRangeException". Test in Task 5.
  3. A node moved into its own descendant. Refuse, tree unchanged. Test in Task 6.
  4. A client sends props as a JSON string. Decode it or refuse clearly, never store it as a prop value. Test in Task 5.
  5. A write on a published display. Output stays as published until a person publishes. Test in Task 5.

Task 1: Close the access gap in the current tools

Ships on its own, before everything else, in display_builder_ai 1.0.x. 12 of the 17 tools check no permission. get_current_display returns any instance by id. The write tools check only use display builder ai. No tool calls entity access. Read in code, not reproduced yet: step 2 settles it.

Files:

  • Modify: web/modules/custom/display_builder_ai/src/Plugin/AiFunctionCall/AddComponent.php, SetLayout.php, GetCurrentDisplay.php, GetDisplayContext.php, CreatePage.php

  • Test: web/modules/custom/display_builder_ai/tests/src/Kernel/InstanceAccessTest.php

  • Step 1: Write the failing tests. A user holds use display builder ai but lacks instance access. Pass instance_id as a tool input. testAddComponentNeedsUpdateAccess and testSetLayoutNeedsUpdateAccess assert getSources() is unchanged. testGetCurrentDisplayNeedsViewAccess and testGetDisplayContextNeedsViewAccess assert the output has no tree and no node_count. testCreatePageNeedsCreateAccess asserts no page_layout row.

  • Step 2: Run them, expect FAIL. ddev exec "SIMPLETEST_DB=sqlite://localhost/:memory: phpunit web/modules/custom/display_builder_ai/tests/src/Kernel/InstanceAccessTest.php"

  • Step 3: Implement. After each load, refuse with a plain message when $instance->access($operation, $this->currentUser) fails. Inline in the four tools: Task 3 replaces this with a base class. In CreatePage, use $storage->getAccessControlHandler()->createAccess().

  • Step 4: Run them, expect PASS. Then the whole kernel suite.

  • Step 5: Commit message. fix: Check instance access in the AI tools

Task 2: Spike, prove the chat survives on tool_ai_connector

Throwaway branch, nothing kept but the answers. Run this before the RC1 gate: if it fails, the design changes. tool_ai_connector labels itself experimental.

Files:

  • Modify: root composer.json: add drupal/tool:^1.0@beta, drupal/mcp_server:^2.0@beta, drupal/mcp_server_tool_bridge:^1.0@beta. Demo root only.

  • Create: a scratch module with AddComponentTool, and a copy of ai_agents.ai_agent.display_builder_component_agent.yml using it.

  • Step 1: The agent accepts tool:display_builder_add_component in tools, tool_settings and tool_usage_limits. Expect it to load and list.

  • Step 2: A prototype subscriber on AgentToolPreExecuteEvent sets instance_id. The tool receives it through the connector. Check a default information tool too.

  • Step 3: A real chat turn sends nested props and slots as maps. Expect them intact after unescapePropertyKeys().

  • Step 4: ddev drush mcp:server <user>, then tools/list advertises props as object, and a tools/call with nested data passes the bridge's schema check.

  • Step 5: Record the outcome here. If maps fail, keep the *_json strings. If the connector fails, keep thin function call wrappers in display_builder_ai, calling ToolRunner, until AI 2.0.

Task 3: Project, base class, instance reads

Gate: starts after display_builder RC1. The tools wrap the Instance API, and betas still rename outright. The maintainer creates the drupal.org project.

Files:

  • Create: web/modules/custom/display_builder_tools/ with .info.yml (package User interface, core ^11, deps display_builder:display_builder, tool:tool), composer.json, .permissions.yml, .services.yml
  • Create: src/Plugin/tool/Tool/InstanceToolBase.php, GetCurrentDisplayTool.php, GetDisplayContextTool.php
  • Test: tests/src/Kernel/InstanceToolAccessTest.php

Interfaces:

  • Produces: abstract class InstanceToolBase extends ToolBase, with protected const string ACCESS_OPERATION = 'update', overridden to 'view' by read tools; protected function checkAccess(array $values, AccountInterface $account, bool $return_as_object = FALSE): bool|AccessResultInterface; protected function loadInstance(string $instance_id): InstanceInterface; protected function recordChange(InstanceInterface $instance): void.

  • Step 1: Write the failing tests. Run through ToolRunner, as MCP does. testViewAccessRequired, testUnknownInstanceRefused, testPermissionRequired, testNoAiModuleEnabled: $modules holds neither ai nor ai_agents.

  • Step 2: Run them, expect FAIL.

  • Step 3: Implement the module, the base class and the two read tools. use display builder tools gets restrict access: true. Drop the tempstore fallback.

  • Step 4: Run them, expect PASS.

  • Step 5: Check the constraint. git -C web/modules/custom/display_builder status --short shows no composer.json or .info.yml.

  • Step 6: Commit message. feat: Add the project and the instance read tools

Task 4: Catalog tools

Files:

  • Create: the ten catalog tools, src/Hook/ToolInfoHooks.php, src/Form/SettingsForm.php, config/install/display_builder_tools.settings.yml, config/schema/
  • Move: GetAvailableComponentsTest.php from display_builder_ai
  • Test: tests/src/Kernel/ToolDiscoveryTest.php

Interfaces:

  • Produces: config display_builder_tools.settings with default_profile, excluded_component_ids, excluded_component_prefixes.

  • Step 1: Write the failing tests. testSoftDependencyToolsHidden: without ui_skins and display_builder_page_layout, the definitions lack those tools. testSoftDependencyToolsListed with them enabled. The moved catalog test goes through ToolRunner.

  • Step 2: Run them, expect FAIL.

  • Step 3: Port the tools and #[Hook('tool_info_alter')]. Bodies stay as they are. Only the plugin shell and the config name change.

  • Step 4: Run them, expect PASS.

  • Step 5: Commit message. feat: Add the catalog tools

Task 5: Write tools

Files:

  • Move: ComponentSourceBuilder.php and its test from display_builder_ai. New service id display_builder_tools.component_source_builder.

  • Create: AddComponentTool.php, SetLayoutTool.php, CreatePageTool.php

  • Test: tests/src/Kernel/WriteToolsTest.php

  • Step 1: Write the failing tests. testAddComponentNeedsUpdateAccess; testWriteLeavesRevisionAndUser; testWriteRecordsSseChange: readLatest() has renderedBy NULL; testFullRootReportsReason; testUnknownParentReportsReason; testPropsAsJsonString; testPublishedOutputUnchanged: hasUnpublishedChanges() is TRUE and getBuildablePlugin()->getSources() still holds the published tree.

  • Step 2: Run them, expect FAIL.

  • Step 3: Port. Drop the $instance->save() after attachToSlot() and attachToRoot(): setNewPresent() already saves. Drop the helper's beta1 branches. CreatePageTool reaches the instance through plugin.manager.display_buildable, and outputs instance_id. Catch InvalidNodeException and \OutOfRangeException, rethrow as \InvalidArgumentException, which ToolBase reports as input failure.

  • Step 4: Run them, expect PASS.

  • Step 5: Commit message. feat: Add the write tools

Task 6: The four new tools

A local agent has no sub-agent to fall back on. Today it could only add or replace everything.

Files:

  • Create: ListDisplaysTool.php, UpdateNodeTool.php, MoveNodeTool.php, RemoveNodeTool.php
  • Test: tests/src/Kernel/NodeToolsTest.php, ListDisplaysToolTest.php

Interfaces:

  • display_builder_list_displays(limit: int = 50) outputs a list of maps: instance_id, kind, label, built, empty, disabled, url. It loops plugin.manager.display_buildable definitions, each built with no configuration, as InstancesPanel::buildProviderItems() does.

  • display_builder_update_node(instance_id, node_id, props, slots) merges into the node through ComponentSourceBuilder, then setSource().

  • display_builder_move_node(instance_id, node_id, parent_node_id, slot_id, position) picks moveToRoot() or moveToSlot().

  • display_builder_remove_node(instance_id, node_id) calls remove().

  • Step 1: Write the failing tests. testListOnlyWhatUserMayBuild, testUnbuiltDisplayIsMarked, testWriteOnUnbuiltDisplayRefused, testUpdateKeepsOtherProps, testMoveIntoOwnDescendantRefused, testRemoveDropsSubtree, each new tool's update access.

  • Step 2: Run them, expect FAIL.

  • Step 3: Implement the four tools.

  • Step 4: Run them, expect PASS.

  • Step 5: Commit message. feat: Add display listing and node editing tools

Task 7: Move the chat onto the tools

Files:

  • Delete: the 15 ported function calls, ComponentSourceBuilder.php, ComponentSourceBuilderTest.php, GetAvailableComponentsTest.php

  • Create: web/modules/custom/display_builder_ai/src/EventSubscriber/ActiveInstanceSubscriber.php

  • Modify: display_builder_ai.info.yml (add display_builder_tools:display_builder_tools, tool:tool_ai_connector), composer.json, .services.yml, SettingsForm.php, config/schema, config/install/display_builder_ai.settings.yml, the 13 ai_agents.ai_agent.*.yml, tests/smoke.php, kernel $modules

  • Modify: display_builder_ai.post_update.php

  • Test: tests/src/Kernel/ActiveInstanceSubscriberTest.php, PostUpdateTest.php

  • Step 1: Write the failing tests. testEmptyInstanceFilledFromTempStore; testExplicitInstanceKept; testOtherToolsUntouched; testCreatePageBecomesActive; testModulesInstalled: display_builder_tools and tool_ai_connector, since a new dependency is not enabled on an existing site; testSettingsMoved: all three keys, values kept; testAgentsRewritten: a customized system_prompt survives; testPermissionGranted: roles with use display builder ai gain use display builder tools.

  • Step 2: Run them, expect FAIL.

  • Step 3: Implement. Rewrite agent YAML with the mechanical rule: keys in tools, tool_settings, tool_usage_limits, default_information_tools, and the 24 lines naming *_json inputs. The first post update installs the two modules. The others move settings, grant the permission, and apply the same map to stored config, on shipped agent ids only, keeping customized prompts. describe_image and fetch_url keep their ids.

  • Step 4: Run them, expect PASS.

  • Step 5: Run the smoke test. ddev drush php:script web/modules/custom/display_builder_ai/tests/smoke.php with a real provider. Expect the orchestrator to mutate the instance.

  • Step 6: Confirm by hand. A chat turn builds a page in an open Builder, which refreshes.

  • Step 7: Commit message. task: Run the chat on Display Builder Tools

Task 8: MCP config, skill, docs

Files:

  • Create: config/optional/mcp_server_tool_bridge.mcp_tool_config.<id>.yml, one per tool, shape as canvas_tools, 19 files

  • Create: .agents/skills/build-display/SKILL.md, README.md

  • Modify: web/modules/custom/display_builder_ai/README.md

  • Test: tests/src/Kernel/McpToolConfigTest.php

  • Step 1: Write the failing test. With the bridge enabled, 19 mcp_tool_config entities exist, and each tool_id is a known tool plugin.

  • Step 2: Run it, expect FAIL.

  • Step 3: Add the 19 config files.

  • Step 4: Run it, expect PASS.

  • Step 5: Write the skill. Framework-neutral rules from the agent prompts: read the catalog first, real component ids only, fill every slot, drafts stay drafts, a person publishes.

  • Step 6: Write the READMEs. Setup covers the two permissions, a dedicated Drupal account, and claude mcp add drupal -- ddev drush mcp:server <user>. The account argument is attribution, so never document uid 1.

  • Step 7: Verify for real. A local agent lists displays, builds a page with an open Builder. The Builder refreshes without reload. History shows each step, under the MCP account. The published page stays unchanged. SseState should relay across processes: this step is its first real run.

  • Step 8: Commit message. docs: Add the MCP setup, the skill and the Tool Bridge config

Every task runs the module validation order before its commit message: formatters in sequence, then phpcs, PHPStan and PHPMD, then cspell. Commands are in the display-builder-validation skill. New projects need their own phpcs.xml.dist and phpstan.neon, copied from display_builder_ai.

Risks

  • Tool API, MCP Server and the Bridge have no stable release, so no security coverage yet. Tool API says its APIs may change between betas. Plugin shells may need rework. Logic should not.
  • Tool API, ai and ai_agents do not declare Drupal 12, so display_builder_tools is ^11. The ^12 in display_builder_ai.info.yml is already unreachable.
  • AI 2.0 makes Tool API a requirement and is expected to replace function calls. DescribeImage and FetchUrl port then. ActiveInstanceSubscriber depends on an ai_agents event, and AI Agents moves into AI core at 2.0.
  • STDIO has no language negotiation, so a translated display gets edits in the default language. Not handled here. The skill must say so.
  • SseState and ComponentLibraryDefinitions are not listed contracts. display_builder_ai depends on them today, so this adds no new risk.

Noticed, out of scope

  • display_builder_page_builder_agent tells the model to use display_builder_ai:fetch_url, but no agent is granted that tool.

Comments

mogtofu33 created an issue.