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_builderand its submodules gain no dependency. Theircomposer.jsonand.info.ymlfiles stay untouched by this plan. Allowed there:ui_patterns,ui_styles,ui_skins,ui_icons.- Nothing moves out of
display_builderor its submodules. display_builder_toolsnever requiresdrupal/ai,drupal/ai_agentsor an MCP project. MCP stays asuggest.- Every instance read checks
$instance->access('view'). Every instance write checks$instance->access('update'), asdisplay_builder.routing.ymldoes. - Tools call
InstanceInterface, neverSourceTree. - 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_idis a required input. A tool never guesses a target.propsandslotsareMapInputDefinition.componentsis aListInputDefinitionof maps. They replaceprops_json,slots_jsonandcomponents_json, so a client sends objects, not escaped strings. Task 2 must prove this first.- Read tools keep today's YAML payload in one
stringoutput namedresult.
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
- A display with
built: FALSEis targeted. It has no instance yet. Refuse, and point atdisplay_builder_list_displays, which gives its URL. Test in Task 6. - A full root or slot, or an unknown node. Return the reason, never "Tool execution failed: OutOfRangeException". Test in Task 5.
- A node moved into its own descendant. Refuse, tree unchanged. Test in Task 6.
- A client sends
propsas a JSON string. Decode it or refuse clearly, never store it as a prop value. Test in Task 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 aibut lacks instance access. Passinstance_idas a tool input.testAddComponentNeedsUpdateAccessandtestSetLayoutNeedsUpdateAccessassertgetSources()is unchanged.testGetCurrentDisplayNeedsViewAccessandtestGetDisplayContextNeedsViewAccessassert the output has no tree and nonode_count.testCreatePageNeedsCreateAccessasserts nopage_layoutrow. -
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. InCreatePage, 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: adddrupal/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 ofai_agents.ai_agent.display_builder_component_agent.ymlusing it. -
Step 1: The agent accepts
tool:display_builder_add_componentintools,tool_settingsandtool_usage_limits. Expect it to load and list. -
Step 2: A prototype subscriber on
AgentToolPreExecuteEventsetsinstance_id. The tool receives it through the connector. Check a default information tool too. -
Step 3: A real chat turn sends nested
propsandslotsas maps. Expect them intact afterunescapePropertyKeys(). -
Step 4:
ddev drush mcp:server <user>, thentools/listadvertisespropsasobject, and atools/callwith nested data passes the bridge's schema check. -
Step 5: Record the outcome here. If maps fail, keep the
*_jsonstrings. If the connector fails, keep thin function call wrappers indisplay_builder_ai, callingToolRunner, 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(packageUser interface, core^11, depsdisplay_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, withprotected 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:$modulesholds neitherainorai_agents. -
Step 2: Run them, expect FAIL.
-
Step 3: Implement the module, the base class and the two read tools.
use display builder toolsgetsrestrict 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 --shortshows nocomposer.jsonor.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.phpfromdisplay_builder_ai - Test:
tests/src/Kernel/ToolDiscoveryTest.php
Interfaces:
-
Produces: config
display_builder_tools.settingswithdefault_profile,excluded_component_ids,excluded_component_prefixes. -
Step 1: Write the failing tests.
testSoftDependencyToolsHidden: withoutui_skinsanddisplay_builder_page_layout, the definitions lack those tools.testSoftDependencyToolsListedwith them enabled. The moved catalog test goes throughToolRunner. -
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.phpand its test fromdisplay_builder_ai. New service iddisplay_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()hasrenderedByNULL;testFullRootReportsReason;testUnknownParentReportsReason;testPropsAsJsonString;testPublishedOutputUnchanged:hasUnpublishedChanges()is TRUE andgetBuildablePlugin()->getSources()still holds the published tree. -
Step 2: Run them, expect FAIL.
-
Step 3: Port. Drop the
$instance->save()afterattachToSlot()andattachToRoot():setNewPresent()already saves. Drop the helper'sbeta1branches.CreatePageToolreaches the instance throughplugin.manager.display_buildable, and outputsinstance_id. CatchInvalidNodeExceptionand\OutOfRangeException, rethrow as\InvalidArgumentException, whichToolBasereports 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 loopsplugin.manager.display_buildabledefinitions, each built with no configuration, asInstancesPanel::buildProviderItems()does. -
display_builder_update_node(instance_id, node_id, props, slots)merges into the node throughComponentSourceBuilder, thensetSource(). -
display_builder_move_node(instance_id, node_id, parent_node_id, slot_id, position)picksmoveToRoot()ormoveToSlot(). -
display_builder_remove_node(instance_id, node_id)callsremove(). -
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(adddisplay_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 13ai_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_toolsandtool_ai_connector, since a new dependency is not enabled on an existing site;testSettingsMoved: all three keys, values kept;testAgentsRewritten: a customizedsystem_promptsurvives;testPermissionGranted: roles withuse display builder aigainuse 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*_jsoninputs. 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_imageandfetch_urlkeep 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.phpwith 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 ascanvas_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_configentities exist, and eachtool_idis 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.
SseStateshould 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,
aiandai_agentsdo not declare Drupal 12, sodisplay_builder_toolsis^11. The^12indisplay_builder_ai.info.ymlis already unreachable. - AI 2.0 makes Tool API a requirement and is expected to replace function calls.
DescribeImageandFetchUrlport then.ActiveInstanceSubscriberdepends on anai_agentsevent, 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.
SseStateandComponentLibraryDefinitionsare not listed contracts.display_builder_aidepends on them today, so this adds no new risk.
Noticed, out of scope
display_builder_page_builder_agenttells the model to usedisplay_builder_ai:fetch_url, but no agent is granted that tool.
Comments