Windmill 提供 MCP Server,可以将脚本/流程暴露给 AI Agent。但 MCP 的脚本调用能力无法同时满足「传参」和「异步执行」两个需求。本文将介绍如何通过编写 OpenClaw Tools Plugin 来绕过这些限制,实现灵活的异步调用。
1. 背景:为什么不直接用 Windmill MCP 链接到标题
Windmill MCP Server 暴露了若干工具供 AI Agent 调用,但在实际使用中发现几个问题:
| MCP 工具 | 传参 | 异步 | 说明 |
|---|---|---|---|
runScriptByPath |
❌ | ✅ | 只能传脚本路径,无法动态传入自定义参数 |
runScriptPreviewAndWaitResult |
✅ | ❌ | 支持传参,但会同步阻塞等待结果 |
| 自动生成的脚本工具 | ✅ | ❌ | 同步等待,且需 MCP 成功连接后才能使用 |
简单说:异步执行时不能传参,能传参的执行方式是同步等待。
这个限制来自 Windmill MCP 的实现:runScriptByPath 底层虽然支持异步模式,但 MCP 工具定义中没有暴露 args 参数,导致传入的自定义参数被忽略。
而 Windmill 原生 REST API 完全支持异步传参调用,因此考虑通过 OpenClaw Tools Plugin 直接封装 REST API。
2. OpenClaw Tools Plugin 结构 链接到标题
OpenClaw 支持通过 Plugin 扩展工具能力。插件放置在 ~/.openclaw/plugins/ 目录下。
目录结构 链接到标题
~/.openclaw/plugins/windmill-greeting/
├── package.json # npm 包信息
├── openclaw.plugin.json # 插件清单
└── dist/
├── index.js # 入口文件
└── tools/
└── windmill_greeting.js # 工具实现
插件清单(Manifest) 链接到标题
{
"id": "windmill-greeting",
"name": "Windmill Greeting",
"description": "Execute Windmill greeting script - synchronous and asynchronous modes",
"contracts": {
"tools": [
"windmill_greeting",
"windmill_greeting_async_submit",
"windmill_greeting_get_result"
]
},
"activation": {
"onStartup": true
}
}
关键点:
id是唯一标识,也作为配置中的 keycontracts.tools声明工具名称列表——OpenClaw 2026.3.24-beta.2+ 必须声明,否则工具不会注册activation.onStartup确保插件随 Gateway 启动
入口文件 链接到标题
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import {
windmillGreetingTool,
windmillGreetingAsyncSubmitTool,
windmillGreetingGetResultTool,
} from "./tools/windmill_greeting.js";
export default definePluginEntry({
id: "windmill-greeting",
name: "Windmill Greeting",
description: "Execute Windmill greeting script - synchronous and asynchronous modes",
register(api) {
api.registerTool(windmillGreetingTool);
api.registerTool(windmillGreetingAsyncSubmitTool);
api.registerTool(windmillGreetingGetResultTool);
},
});
definePluginEntry 是 OpenClaw Plugin SDK 的入口函数,接收插件定义对象。register 方法中通过 api.registerTool 注册工具。
参数 Schema 链接到标题
OpenClaw 使用 TypeBox 声明工具参数类型:
import { Type } from "@sinclair/typebox";
const parameters = Type.Object({
name: Type.String({
description: "The name to greet",
}),
});
注意:TypeBox 包名是
@sinclair/typebox,不是typebox。安装时需要用npm install @sinclair/typebox。
3. 同步工具 链接到标题
先实现同步版本,调用 Windmill 的 run_wait_result 端点,脚本执行完才返回结果。
REST API 链接到标题
POST http://<windmill>:3900/api/w/<workspace>/jobs/run_wait_result/p/<script-path>
Body: { "name": "张三" }
Authorization: Bearer <token>
实现 链接到标题
export const windmillGreetingTool = {
name: "windmill_greeting",
description: "Execute Windmill greeting script synchronously and return the greeting text",
parameters: Type.Object({
name: Type.String({
description: "The name to greet",
}),
}),
execute: async (_id, params) => {
const url = `${BASE_URL}/api/w/${WORKSPACE}/jobs/run_wait_result/p/${SCRIPT_PATH}`;
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: params.name }),
});
if (!response.ok) {
const errorText = await response.text();
return { content: [{ type: "text", text: `Error ${response.status}: ${errorText}` }] };
}
const result = await response.json();
return { content: [{ type: "text", text: `Greeting result: ${JSON.stringify(result)}` }] };
},
};
验证 链接到标题
部署插件后,通过自然语言触发:
运行 greeting 脚本,输入 name 为 张三
AI Agent 会自动调用 windmill_greeting,传入参数,等待脚本执行完毕,返回问候结果。
4. 异步工具(两阶段设计) 链接到标题
对于执行时间较长的任务(如视频处理、批量导出),同步等待会阻塞很久。异步模式分两步:
- 提交阶段:调用
run端点,立即返回 Job ID - 获取结果阶段:根据 Job ID 调用
get_result端点,获取执行结果
REST API 链接到标题
# 异步提交
POST http://<windmill>:3900/api/w/<workspace>/jobs/run/p/<script-path>
Body: { "name": "李四" }
→ 返回 Job ID(纯文本)
# 获取结果
GET http://<windmill>:3900/api/w/<workspace>/jobs_u/completed/get_result/<job-id>
→ 返回执行结果 JSON
异步提交工具 链接到标题
export const windmillGreetingAsyncSubmitTool = {
name: "windmill_greeting_async_submit",
description: "Submit a greeting job to Windmill asynchronously and return the job ID",
parameters: Type.Object({
name: Type.String({ description: "The name to greet" }),
}),
execute: async (_id, params) => {
const url = `${BASE_URL}/api/w/${WORKSPACE}/jobs/run/p/${SCRIPT_PATH}`;
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: params.name }),
});
if (!response.ok) {
const errorText = await response.text();
return { content: [{ type: "text", text: `Error ${response.status}: ${errorText}` }] };
}
const jobId = await response.text();
return { content: [{ type: "text", text: `Job submitted. Job ID: ${jobId}` }] };
},
};
查询结果工具 链接到标题
export const windmillGreetingGetResultTool = {
name: "windmill_greeting_get_result",
description: "Get the result of a completed Windmill greeting job by its job ID",
parameters: Type.Object({
job_id: Type.String({ description: "The job ID to retrieve result for" }),
}),
execute: async (_id, params) => {
const url = `${BASE_URL}/api/w/${WORKSPACE}/jobs_u/completed/get_result/${params.job_id}`;
const response = await fetch(url, {
headers: { "Authorization": `Bearer ${API_TOKEN}` },
});
if (!response.ok) {
const errorText = await response.text();
return { content: [{ type: "text", text: `Error ${response.status}: ${errorText}` }] };
}
const result = await response.json();
return { content: [{ type: "text", text: `Job result: ${JSON.stringify(result)}` }] };
},
};
异步模式使用示例 链接到标题
# 步骤 1:提交异步任务
提交 greeting 异步任务,输入 name 为 李四
# AI 会返回 Job ID:
# Job submitted. Job ID: 019e634f-2d21-2686-5598-a0719f753a64
# 步骤 2:查询结果(需等待任务完成后)
查下 greeting 任务结果
# AI 会自动调用 windmill_greeting_get_result,传入上一步的 job_id:
# Job result: "你好,李四!欢迎使用 Windmill。"
Agent 会根据上下文自动拼接两步:先提交异步任务,记住返回的 Job ID,再查询结果返回给用户。
5. 踩坑记录 链接到标题
5.1 contracts.tools 声明 链接到标题
OpenClaw 2026.3.24-beta.2 版本引入了 contracts.tools 声明机制。如果不在 openclaw.plugin.json 中显式声明工具名称,工具不会被注册,且不会报任何错误——你只会发现工具「不出现」。
{
"contracts": {
"tools": ["windmill_greeting", "windmill_greeting_async_submit", "windmill_greeting_get_result"]
}
}
这个字段必须在 manifest 中声明,且名称必须与代码中的 name 字段完全一致。
5.2 安全扫描拦截 链接到标题
OpenClaw 在安装 Plugin 时会执行安全扫描。如果 node_modules 中存在符号链接指向安装根目录之外的目标,安全扫描会直接阻止安装:
Plugin "windmill-greeting" installation blocked: code safety scan failed
(manifest dependency scan found node_modules symlink target outside install root)
解决方案:用 npm pack 打包为 .tgz 安装,或在目标机器上重新 npm install,确保 node_modules 结构干净。
5.3 typebox 包名 链接到标题
TypeBox 的正确包名是 @sinclair/typebox。如果错误地用 npm install typebox(无 scope),会导致类型定义加载失败。建议在 package.json 中显式声明:
{
"dependencies": {
"@sinclair/typebox": "^0.34.0"
}
}
6. 总结 链接到标题
与 OpenCode Plugin 的区别 链接到标题
| 对比项 | OpenCode Plugin | OpenClaw Plugin |
|---|---|---|
| SDK | @opencode-ai/plugin |
openclaw/plugin-sdk/plugin-entry |
| 注册方式 | export 插件对象 |
definePluginEntry + api.registerTool |
| 参数 Schema | tool.schema.string() |
TypeBox (@sinclair/typebox) |
| 安装目录 | .opencode/plugins/ |
~/.openclaw/plugins/ |
| 声明要求 | 文件名即注册 | 需 contracts.tools 声明 |
| 安全扫描 | 无 | 有(符号链接会阻塞) |
Plugin vs Bundled MCP 选型建议 链接到标题
| 场景 | 推荐方式 | 理由 |
|---|---|---|
| 调已有 Windmill 脚本 | MCP | 开箱即用,无需代码 |
| 需要传参 + 异步 | Tools Plugin | MCP 无法同时支持 |
| 需要复杂参数校验 | Tools Plugin | TypeBox 支持完整 JSON Schema |
| 快速原型验证 | MCP | 零配置,无需打包部署 |
| 深度定制逻辑 | Tools Plugin | 完全控制 HTTP 请求和错误处理 |