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 是唯一标识,也作为配置中的 key
  • contracts.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. 异步工具(两阶段设计) 链接到标题

对于执行时间较长的任务(如视频处理、批量导出),同步等待会阻塞很久。异步模式分两步:

  1. 提交阶段:调用 run 端点,立即返回 Job ID
  2. 获取结果阶段:根据 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 请求和错误处理