译自 Dries Buytaert 博客原文:One description, inside and outside Drupal(2026 年 10 月 15 日,№ 1,448)。
我的网站有一个自己写的相册模块,我维护它大约二十年了,它帮我管理一万多张照片。在 DrupalCon Rotterdam 的主题演讲里,我谈到让这个模块与 AI 助手协同工作。台上没时间展开细节,所以在这篇文章里详细说。
为了让助手能找到并编辑我的照片,我用了贡献模块 Tool module,它提供 Tool API,可以把模块里的操作暴露成工具,比如查找照片、更新照片的说明或替代文本(alt)。
这些工具大多是我本来就有的 Drupal 服务上的公共方法,例如搜索照片的那个。通常只要加几个 PHP 属性,Tool API 就能把已有的方法暴露出去,让 AI 助手通过 MCP(Model Context Protocol)调用它。
要向其他软件描述一个工具,Tool API 需要知道它接受和返回什么数据:每个值叫什么、是什么类型、代表什么含义。
我的相册模块没有把照片存成 Drupal 实体(我知道,我知道),所以 Drupal 里没有照片的字段定义。它把照片存在自己的数据表里,每张照片加载成 Image 的实例,一个普通的 PHP 类。我在想暴露的属性的 getter 上加了 PHP 属性,比如这个用于替代文本的:
#[ToolProperty(description: 'Plain text describing the image for screen readers.', media_type: 'text/plain')]
public function getAlt(): string {
...
}Tool API 会根据这类属性生成 JSON Schema,一种描述 JSON 数据的格式。MCP 和 OpenAPI(描述 HTTP API 的标准)都使用这种格式,Drupal 的 Single Directory Components 也是——它用 JSON Schema 描述传给可复用界面组件的数据。
在相册模块里,我用同一份照片数据描述做三件事:告诉 AI 助手我的工具返回什么、为搜索照片的 React 应用生成 TypeScript 类型、定义 Drupal 组件的输入。
同一批工具还把我的站点接到各种工作流系统。用 ECA、FlowDrop、Maestro、n8n 或 Activepieces 搭的工作流,可以调用 AI 助手调用的同一个照片搜索工具,而不需要为我的相册模块单独做一次集成。
模块维护者只暴露一次操作,多种软件都能用。
这也是我在演讲里说,由 Michael Lander 及其团队构建的 Tool module 是当下 Drupal 里最重要的模块之一的原因。一个模块的能力可以同时供给助手、前端和工作流,维护者不必为每一种分别开发。
我认为每个 Drupal 模块都应该这样工作,所以我在演讲里重点讲了它,也因此在它周围发起了一项更大的行动,叫 Rosetta。
目标是让 Drupal 自身的系统和 Drupal 之外的软件,都基于共享的数据与操作描述来工作。下面我具体讲讲我用相册模块做了什么、验证了什么。
Drupal 需要共享的数据与操作描述
每年都有越来越多的站点工作发生在 Drupal 之外:发生在 AI 助手里,发生在用 JavaScript 框架构建的前端里,发生在把 Drupal 与其他系统连接的编排平台里。正如我在 AI 与 CMS 的大解绑 中解释的,这些系统仍然需要 Drupal 已经提供的内容管理、治理和可观测性。
Drupal 有实体、字段、权限、修订、工作流、内容审核,以及数以万计的贡献模块。但要让某个模块的能力被助手、工作流或前端用上,仍然常常得做一些集成工作。
来自我 DrupalCon Rotterdam 2026 主题演讲的一张幻灯片:Drupal 的能力已经具备,但让其他软件用上它们仍然太难。
安装一个 Drupal 模块,就应该让它的能力对已经接入站点的软件可用:给助手新工具,给工作流新操作,给前端开发者新能力。
在我的相册模块里,我应该只需要描述一次照片、实现一次照片搜索,而不是为每个助手、每个前端、每个工作流系统各写一套集成。
在 Drupal 里,我们早就知道为什么共享描述重要。当站点构建者给内容类型加一个字段,Drupal 会存下它的定义:类型、标签、是否必填。内容编辑表单、Views 和 JSON:API 都复用这份定义。
CCK(Drupal 的 Content Construction Kit)让站点构建者只定义一次字段,Drupal 就把这份定义用于编辑表单、显示和数据库。Views 让他们对任何"描述了自己数据"的模块的数据做列表、筛选和排序。Rosetta 就是把这种复用扩展到模块更多的数据和操作上。
共享的数据与操作描述,可能成为 Drupal 的下一个 CCK 与 Views 时刻。
用户界面也一直在变:网页,然后是原生移动应用,然后是无头前端,然后是聊天,现在是 AI 助手和智能体。我不知道智能体之后会是什么。但如果模块描述了自己的数据和操作,新的界面就能构建在我们已有的能力之上——下一个界面读一遍描述就开始干活。这不会让 Drupal 对变化免疫,但能让我们快速适应,而快速适应有助于我们赢。
问题于是变成:怎么让这件事对数以千计的模块维护者切实可行。Tool API 给了我们一个起点,它背后还有多年来在 Typed Data、Single Directory Components、JSON:API、Drupal Canvas Code Components、Modeler API 等方面的工作。我们需要这些工作背后的人一起来塑造这个方向,包括 Tool API(或它的某些部分)是否应该进入 Drupal Core。
所以我在组织 Rosetta Sprint:用几天时间,把这些项目背后的人聚到一起,比较现有方案、找出缺失的部分,并与相关维护者和 Core 提交者就下一步达成一致。
描述已有数据,把函数暴露为工具
Tool API 支持两种定义工具的方式:(1)写一个专门的插件类,Drupal 会把它发现为工具;或者(2)在注册为 Drupal 服务的类的既有公共方法上添加 PHP 属性。
我是从专门的插件类开始的,但最终转向了 PHP 属性。我开始用时属性支持还是实验性的,在使用过程中我帮着改进了 Tool API(#3583030、#3583031、#3583032、#3583041、#3583085)。属性支持现在已经合并,可在 Tool API 的开发版本中使用。专门的插件类仍然完全受支持。
这里我只讲基本部件,不写成完整教程。下面是搜索照片的方法如何变成 find_images 工具,稍有简化:
/**
* Searches the images.
*
* @param string $query
* Words to look for in the title, caption or alt text.
*
* @return \Drupal\album\Image[]
* The matching images.
*/
#[ToolMethod(
id: 'find_images',
label: 'Find images',
description: "Searches the site's images. …",
operation: ToolOperation::Read,
permission: 'access content',
)]
public function search(string $query): array {
// 搜索代码本身没有改动。
}方法体保持不变:接收搜索词,返回匹配的照片。#[ToolMethod] 属性给工具提供 ID、标签和描述,同时声明这个操作只读数据、调用者需要 access content 权限。搜索代码仍然必须自己落实"调用者能看到哪些照片"的限制。
Tool API 从方法签名里读出输入的名称和类型:query 和 string。类型信息缺失时,Tool API 会尝试从方法签名和 docblock 推断。这里的 Image[] 返回注解告诉 Tool API,该数组里装的是 Image 对象。
但知道结果是一组 Image 对象,并不能告诉其他软件一张照片有哪些属性。这些我用 Image 类 getter 上的 #[ToolProperty] 属性来描述,就像上面的 getAlt()。
这些属性还解释每个属性的含义。在 PHP 里,标题、说明和 URL 都可以是字符串,但其他软件需要知道哪个是纯文本、哪个含 HTML、哪个是 URL。我在演讲里演示过这个区别:
来自我 DrupalCon Rotterdam 2026 主题演讲的一张幻灯片:三个值在 PHP 里都是 string 类型,但 #[ToolProperty] 属性把标题描述为纯文本、把说明描述为 HTML、把 URL 描述为 URI。
Tool API 只包含我用 #[ToolProperty] 显式描述过的照片属性。这个属性可以放在公共类属性上,也可以放在 getter 方法上。放在 getUrl() 上时,它把返回值作为一个名为 url 的照片属性暴露出去,而不会把 getter 变成可调用的工具。
底层上,Tool API 用 Drupal 的 Typed Data API 表示这些描述,并能据此生成 JSON Schema。
Drush、MCP、ECA、FlowDrop、Maestro 以及我相册模块自己的 HTTP 路由,都可以调用同一个执行器 ToolRunner::run()。这意味着无论从哪条路调用工具,权限检查和输入校验都是同一套。这个执行器还能区分"输入无效""拒绝访问"和"执行失败",各个集成不必自己再判断一遍。
如前所述,PHP 属性不是定义工具的唯一方式。专门的插件类稍微啰嗦一些,但有一些优势。例如它不必放在它所暴露的那个模块里——一个独立模块可以定义工具、调用原模块的服务和 API,而完全不需要改原模块的代码。Tool Belt 目前就是用这种"独立模块"的路子,为 Drupal Core 的操作(如创建内容、更新字段)提供工具。这意味着我们不必等现有模块的维护者采用 Tool API,就能给它们加上工具支持。
如果你维护一个模块,试着把一个有用的操作暴露成工具。如果卡住了,或者 Tool API 描述不了你需要的数据,提个 issue。你的反馈能帮我们在 Tool API 发布第一个稳定 1.x 版本之前改进它。Tool API 还很年轻,你可以参与塑造它。
AI 助手可以不经开发者之手处理复杂任务
其实我是从搜索开始的。不久前我把博客的搜索能力暴露给 AI 智能体,并写了好几篇关于它的文章:作为 MCP 工具、通过 API 目录,以及用 Agentic Resource Discovery。那段代码最初是手写的,后来我也把它改成了用 Tool API。
再后来,我加了相册相关工具。相册模块一共提供七个工具。我最常用的是 find_images 和 get_image(查找和读取照片),以及 update_image(修改照片的标题、说明或替代文本)。
我用这些工具给整整几个相册写标题和替代文本。以下是我常让助手做的另外几件事:
- 找出所有相册里所有还没有替代文本的照片,并给出替代文本建议供我审阅。
- 找出说明文字里的拼写错误或语法问题。
- 列出某个相册里没有标题的照片,并给出标题建议供我审阅。
相册工具和 find_posts 经常配合使用。假如我有一个旅行相册和一篇关于同一次旅行的博客文章,AI 助手在为相册照片写替代文本时可以调用 find_posts,把那篇文章当上下文。它就能拿到诸如此类的细节:我们去了哪里、谁一起去的、这趟旅行的目的是什么。替代文本因此更好,而我不必特意把文章指给助手看。
相册模块的管理界面根本没法快速完成这些任务,有些任务压根没有界面。在这些工具出现之前,我要么在数据库上跑 SQL 查询,要么干脆不做。为了补上缺失的替代文本,我会先查出没有替代文本的照片,再在浏览器里逐个打开编辑界面。用这种方式更新几十张图片,很快就让人厌烦。
现在助手找到照片并起草替代文本,我在 Claude 里审阅并批准它的建议。这至少比我原来的流程快十倍。
我写 SQL 快三十年了,所以这些活儿我自己一直都能干。很多站点所有者不行。他们得找开发者帮忙,或者这件事永远不会被做。工具可以消除这种依赖。照片只是我的例子——模块暴露成工具的任何操作,都会变成站点所有者可以直接提出需求的事,而不必等开发者。
AI 助手可以不需要专门界面就处理复杂任务
当助手调用 get_image 时,它拿到照片的属性和照片本身,所以它看得到自己在描述什么。在 Claude 里,我做的界面还会在对话中显示照片。它用的是 MCP Apps,一个 MCP 扩展,允许服务端提供 HTML 界面、由客户端在对话里渲染。
每个模块都有些任务过于罕见、或过于特殊,不值得为它做一个界面。维护者可以为日常工作做界面,把罕见或专门的任务通过工具提供出去。我已经把相册模块界面的一些部分删掉了,比如那个用语言模型生成替代文本的按钮,因为现在这些活由助手干了。我预计还会删掉更多。
共享描述也让"针对使用者定制界面"更容易。Tool API 已经能从工具的输入生成表单,并在不经过表单直接调用工具时施加同样的校验约束。Michael Lander 在他的 Data Surface 原型 里进一步探索这一点:表单和工具使用同一套"可接受的值"与"校验规则"定义。
一个组织可以围绕自己的工作流构建管理界面,同时复用这些定义和校验规则。只要提示给对,助手现在已经能围绕这些工具搭界面。比如一批照片更新,它可以做一个图片加替代文本建议的表格,让我逐条审阅和批准。随着时间推移,我预计助手会自己判断什么时候一个界面有助于完成某个任务,并在没人要求的情况下把它建出来。
Michael 的工作建立在 Drupal 的配置校验之上,这正是我所说的"Drupal 拥有偶然的 AI 优势"的一个好例子。Wim Leers 在 2022 年开始倡导配置校验,当时 AI 并不是他的理由。几年之后,Michael 可以复用那些校验约束,从一份定义同时驱动表单和工具;而在 Validation-first 里,Wim 描述了同样的精确性如何帮助 AI 工具生成有效的 Drupal Canvas 代码组件。
JavaScript 前端可以用和 AI 助手相同的工具
但这还不是全部。相册的数据和操作被描述好之后,我决定把同一份数据、同一批工具也提供给前端开发者。我写了两段衔接代码:一条调用只读工具的共享 HTTP 路由,以及一个从这些定义生成 OpenAPI 文档的生成器。加起来不到 150 行 PHP。
路由用 URL 标识工具,用查询字符串提供参数。例如请求 /api/find-images?query=ski,会以 ski 作为查询调用 find_images,并把结果以 JSON 返回。我没有另写一套照片搜索,也没有为每个工具各写一条路由。
自动生成的 OpenAPI 文档在 https://dri.es/openapi.json,它描述这些端点、它们的参数和响应。它用的是和 MCP 工具相同的 #[ToolMethod] 与 #[ToolProperty] 属性。
OpenAPI 是一个有 15 年历史的 HTTP API 描述标准,所以大量开发者工具都支持它。例如 Swagger UI 会列出我的端点,并让你直接试用。这段视频先展示文档,然后 Swagger UI 读取它并为 "driesnote" 调用 API。
很多前端开发者用 TypeScript,它为 JavaScript 加上静态类型。一个类型可以把图片描述成"带字符串 alt 和字符串 title 的对象"。TypeScript 编译器在运行前依据这些类型检查代码,VS Code 这类编辑器会在你写代码时把类型错误标出来。
为了看看这给前端开发者带来什么,我让 Claude Code 做了一个小的 React 应用,从浏览器里搜索我的照片。这个应用对 Drupal 一无所知,做它也不需要任何 Drupal 知识。Claude Code 手上只有我站点的 URL 和它生成的 OpenAPI 文档。
我不是前端开发者,所以看着挺有意思。Claude 用一个叫 openapi-typescript 的代码生成器,不到一秒就把我的 OpenAPI 文档转成了 TypeScript 类型。然后一个小库 openapi-fetch 使用这些类型,于是编译器能检查应用代码里的请求路径和参数。
整个应用就是一个 45 行的 React 组件,我没有为它写任何额外的 Drupal 代码。在这段视频里,它搜索了 "driesnote"。
这条 HTTP 路由和 OpenAPI 生成器是我站点上的原型代码。它们没有任何一处是照片专用的,所以不该由每个站点或每个模块自己去写。它们属于"对任何模块的工具都适用"的共享基础设施。JSON-RPC 可能是一个落脚点:如果 JSON-RPC 支持 Tool API,它就能暴露其他模块的工具,而不需要每个模块自己写一套 JSON-RPC 集成。
工作流可以把模块操作当作工具调用
在 Drupal 内部,ECA、FlowDrop 和 Maestro 可以把工具当作工作流里的一步来调用。例如 ECA Tool 模块让工具可以作为 ECA 动作使用。
在演讲中,Shibin Das 展示了他用我相册模块的工具搭的一个 FlowDrop 工作流。它会搜索我的照片并列出我的相册,先是不带任何 AI 跑一遍,然后加一个由智能体决定用哪些工具跑一遍。
工作流系统也可以暴露自己的工具。ECA 把它的工作流叫"模型",ECA Tool 模块把模型里每个 Tool 事件暴露成一个独立工具。所以一个模型可以提供多个工具。Maestro 可以把公共工作流模板暴露成工具,用它们的变量作为输入。
既是大工作流又是工具的东西,可以像任何其他工具一样被使用。一个 Maestro 工作流可以调用 ECA 暴露的工具,用的还是它调用我的照片搜索时那套 Tool API 集成。两个系统之间不需要直接互相集成。我到现在仍觉得这有点绕,但我喜欢它,也看到了潜力。
Drupal 之外的自动化平台也需要一条调用这些工具的途径。去年,Jürgen Haas 和我做了 Orchestration 模块,把 Drupal 和 Activepieces 连起来。它让 Activepieces 可以调用通过 Tool API 定义的工具、运行 AI 智能体、触发 ECA 模型,同时 Drupal 里的事件也可以触发 Activepieces 里的工作流。我在 把 Drupal 与 Activepieces 连接起来 中描述过这套配置。其他平台可以通过 MCP 调用 Drupal 的工具,例如通过 n8n 的 MCP Client 节点。
工具可以组合。助手可以在一个任务里叠加它们,工作流可以把它们串起来,而一个工作流本身又可以是另一个工作流里的某个工具。一旦某个系统能调用 Drupal 的工具,它就能复用这条连接去调用来自不同模块的操作。
这就是编排转变:技术栈变成网络,工作流跨应用协调工作。Drupal 可以是这张网络里的一个节点,它的模块提供其他系统可以调用的操作。对于构建这些工作流的代理商来说,Tool API 让 Drupal 的能力变成可复用的步骤。
Drupal 组件也可以共享数据描述
所以共享描述能帮 JavaScript 前端和其他应用使用 Drupal 的数据,但它们同样能帮 Drupal 自己的渲染系统,即使完全不涉及 AI 助手或外部应用。
我的照片是用 Single Directory Components(SDC)渲染的。每个组件把 Twig 模板、CSS 和元数据放在一个文件夹里,它的 .component.yml 文件声明组件的输入(称为 props)并描述它们接受的数据。
我的相册模块已经用 #[ToolProperty] 属性描述了照片的属性,而我又在每个照片组件的 .component.yml 里维护着同一批属性的描述。当我改动某个照片属性的内容时,我得记得把两处描述都更新。
于是我写了个 Drush 命令,读 PHP 属性并生成共享的 schema.json 文件。相册模块生成的照片 schema 描述了 alt、caption 这类属性:它们的类型和内容。现在每个照片组件的 .component.yml 引用这份共享描述,而不是再描述一遍。当我改动照片属性的内容时,我改它的 PHP 属性,然后跑一遍命令。
把描述搬到共享文件里会带来新问题。任何读组件 .component.yml 的东西,现在看到的是对 schema.json 的引用,而不是属性定义本身。一个构建"组件目录"(展示每个组件期望什么数据)的工具,必须自己去找到并解析这个引用。Core 的组件 API 不会替它们做这件事:它返回引用,但不带上照片的属性定义。
Pierre Dureau 在 Core issue 3352063 里的合并请求补上了这个缺口,虽然还没合并。在我站上应用之后,Core 的组件 API 顺着这个引用加载到了照片属性定义,而 schema 文件一个字都不用改。
这对生成式 UI 很重要。OpenUI 和 A2UI 这类框架让 AI 智能体从组件目录里组装界面,所以它们需要知道有哪些组件存在、每个组件接受哪些 props。有了这个改动,集成方可以从 Drupal 的 API 构建出这份目录,而不必自己写一个引用解析器。直接读组件文件的工具仍然需要学会跟随这些引用。Storybook(一个开发和预览组件的工具)的 SDC 附加组件,目前还不支持我用的这种 Drupal 专有引用。
生成器也是一样。其他模块维护者不该各自写一个和我这个一样的生成器。我的实验用的是 Tool API 的属性描述,但"描述数据"和"生成 schema"在工具之外同样有用。其中一部分支持可以放进 Drupal Core,让 SDC 和 Icon API 都受益——后者用 JSON Schema 描述图标的设置,比如尺寸和颜色。
我的做法只覆盖了为渲染我模块照片而构建的组件。通用组件定义自己的输入,仍然需要一条拿到 Drupal 数据的路。Drupal 用 Typed Data 描述实体字段,而 SDC 用 JSON Schema 描述 props。UI Patterns 已经处理了两者之间的很大一部分工作,包括哪些数据源可以提供某个 prop。Rosetta 也应该建立在这些工作之上。
让共享描述成为常规做法
同一份照片描述和操作,现在同时支撑着 AI 助手、一个 React 应用、Drupal 内外的工作流,以及 Drupal 组件。
我希望更多模块维护者采用 Tool API:只描述一次自己模块的数据,把有用的操作暴露成工具。这应该成为开发和维护 Drupal 模块的日常部分。
我们还应该把 HTTP 集成、OpenAPI 生成器和组件 schema 生成器作为共享的 Drupal 基础设施提供出来。模块维护者不该各自去写那些衔接代码。
我的原型证明了这些在一个模块上是行得通的。Rosetta 要解决的是让这种复用在 Drupal 范围内成立。我们需要和相关维护者、Core 提交者一起决定:什么属于 Tool API,什么属于它的集成,什么属于 Drupal Core。
如果你维护一个模块,试着把一个有用的操作暴露成工具,并拿它和另一个系统配合使用。分享你试出来的做法,当发现 Tool API 描述不了或做不到什么时,提个 issue。
感谢 Jamie Abrahams、Mateu Aguiló Bosch、Ronald te Brake、Shibin Das、Pierre Dureau、Matt Glaman、Jürgen Haas、Marcus Johansson、Randy Kolenko、Michael Lander 和 Fons Vandamme 审阅本文。