OpenCode v2 Custom Tools 实战:开发数据库、工单与发布工具

**摘要:**OpenCode 自带的 read、edit、bash、webfetch 等工具已经能覆盖通用开发任务,但企业项目里真正有价值的动作,往往藏在数据库、工单、发布平台和内部服务中。Custom Tools 可以把这些能力封装成模型可理解、参数可校验、权限可控制的工具。本篇不只演示一个 Hello World,而是从目录约定、Zod 参数、会话上下文讲起,完整实现"只读数据查询、创建工单、发布前检查"三个工具,并给出权限、密钥、幂等和错误处理方案。

本文示例依据 OpenCode 当前官方 Custom Tools、Tools、Permissions 与 Plugins 文档整理。示例中的域名、令牌和数据均为占位内容,落地时请替换为自己的测试环境。

一、为什么需要 Custom Tools

大模型知道"应该查库存""应该建工单""应该在发布前检查 Git 状态",但如果没有受控接口,它通常只能生成一段命令或操作建议,无法稳定地完成业务动作。Custom Tools 的价值,是把自然语言意图压缩成一个明确契约:

  • **模型侧:**通过工具名、描述和参数说明判断何时调用。
  • **运行侧:**由 TypeScript 或 JavaScript 执行确定性逻辑。
  • **安全侧:**通过参数校验、权限规则和服务端鉴权限制边界。
  • **业务侧:**统一返回结构,便于审计、重试和后续编排。

它与 MCP 并不冲突。Custom Tools 更适合项目内、团队内、小而明确的能力;MCP 更适合跨客户端复用、独立部署、拥有完整资源模型的服务。一个实用判断是:如果能力与当前仓库强绑定,先做 Custom Tool;如果要供多个 IDE、多个 Agent 或多个团队共享,再考虑 MCP 服务化。

二、目录、命名与加载规则

2.1 项目级与全局级目录

官方文档给出两个放置位置:

text 复制代码
# 只对当前项目生效
.opencode/tools/

# 对当前用户的所有项目生效
~/.config/opencode/tools/

项目级工具适合仓库专属的数据库查询、发布检查和内部脚本;全局工具适合个人通用能力,例如时间转换、统一代码搜索或常用平台查询。团队协作时建议优先把项目级工具纳入版本控制,让工具定义与代码一起评审。

2.2 文件名就是工具名

单个默认导出的工具以文件名作为名称。例如 .opencode/tools/database.ts 会注册为 database。一个文件也可以导出多个具名工具,名称格式为 <文件名>_<导出名>。

typescript 复制代码
import { tool } from "@opencode-ai/plugin"

export const add = tool({
  description: "Add two numbers",
  args: {
    a: tool.schema.number(),
    b: tool.schema.number(),
  },
  async execute({ a, b }) {
    return String(a + b)
  },
})

export const multiply = tool({
  description: "Multiply two numbers",
  args: {
    a: tool.schema.number(),
    b: tool.schema.number(),
  },
  async execute({ a, b }) {
    return String(a * b)
  },
})

如果文件名是 math.ts,最终得到 math_add 和 math_multiply。自定义工具与内置工具重名时,自定义工具会优先,因此除非有意替换内置行为,否则应使用带业务语义的唯一名称。

三、一个可维护工具的标准结构

推荐使用 @opencode-ai/plugin 提供的 tool() 辅助函数。它把描述、参数 schema 和执行函数放在同一处,既便于模型理解,也便于 TypeScript 做类型推断。

typescript 复制代码
import { tool } from "@opencode-ai/plugin"

export default tool({
  description: "Return basic information about the active project",
  args: {},
  async execute(_args, context) {
    const { agent, sessionID, messageID, directory, worktree } = context

    return JSON.stringify({
      agent,
      sessionID,
      messageID,
      directory,
      worktree,
    })
  },
})

context.directory 表示当前会话工作目录,context.worktree 表示 Git worktree 根目录。拼接项目脚本或读取仓库文件时优先使用这两个值,不要把开发者本机的绝对路径写死在工具里。

3.1 描述决定模型会不会正确调用

描述不要只写"查询数据",而应明确使用时机、限制和副作用。例如:

写法 问题或收益
Query database 过于宽泛,模型不知道允许查什么
Read approved inventory or order summary views; never writes data 明确数据范围和只读属性
Create a production incident ticket after user approval 明确有外部副作用和审批要求

3.2 参数 schema 是第一道防线

tool.schema 本质上是 Zod。除了字符串和数字类型,还应充分使用 enum、min、max、regex 和 describe,尽量不要接收一段可以任意执行的 SQL 或 Shell。

typescript 复制代码
args: {
  dataset: tool.schema
    .enum(["inventory_summary", "order_summary"])
    .describe("Approved read-only dataset"),
  keyword: tool.schema
    .string()
    .trim()
    .min(1)
    .max(80)
    .describe("Warehouse code, SKU, or order number"),
  limit: tool.schema
    .number()
    .int()
    .min(1)
    .max(50)
    .default(20),
}

四、实战一:只读业务数据查询工具

下面以 SQLite 演示安全的只读查询。重点不是数据库类型,而是三个边界:表名来自白名单、查询参数使用占位符、返回行数有硬上限。生产环境可将 Python 脚本替换为访问只读副本或内部查询 API。

4.1 Python 查询脚本

python 复制代码
# .opencode/tools/query_readonly.py
import json
import sqlite3
import sys
from pathlib import Path

DATASETS = {
    "inventory_summary": {
        "sql": """
            SELECT warehouse_code, sku, available_qty, updated_at
            FROM inventory_summary
            WHERE warehouse_code LIKE ? OR sku LIKE ?
            ORDER BY updated_at DESC
            LIMIT ?
        """,
    },
    "order_summary": {
        "sql": """
            SELECT order_no, status, warehouse_code, updated_at
            FROM order_summary
            WHERE order_no LIKE ? OR warehouse_code LIKE ?
            ORDER BY updated_at DESC
            LIMIT ?
        """,
    },
}

dataset, keyword, limit_text, worktree = sys.argv[1:5]
if dataset not in DATASETS:
    raise SystemExit("dataset is not allowed")

limit = max(1, min(int(limit_text), 50))
db_path = Path(worktree) / "data" / "demo.db"

uri = f"file:{db_path.as_posix()}?mode=ro"
with sqlite3.connect(uri, uri=True, timeout=3) as conn:
    conn.row_factory = sqlite3.Row
    pattern = f"%{keyword}%"
    rows = conn.execute(
        DATASETS[dataset]["sql"],
        (pattern, pattern, limit),
    ).fetchall()

print(json.dumps([dict(row) for row in rows], ensure_ascii=False))

4.2 TypeScript 工具定义

typescript 复制代码
// .opencode/tools/business_lookup.ts
import path from "path"
import { tool } from "@opencode-ai/plugin"

export default tool({
  description:
    "Read approved inventory or order summary data. This tool never writes data.",
  args: {
    dataset: tool.schema.enum(["inventory_summary", "order_summary"]),
    keyword: tool.schema.string().trim().min(1).max(80),
    limit: tool.schema.number().int().min(1).max(50).default(20),
  },
  async execute(args, context) {
    const script = path.join(
      context.worktree,
      ".opencode/tools/query_readonly.py",
    )

    const result = await Bun.$`python3 ${script} ${args.dataset} ${args.keyword} ${args.limit} ${context.worktree}`.text()
    const rows = JSON.parse(result)

    return JSON.stringify({
      ok: true,
      dataset: args.dataset,
      count: rows.length,
      rows,
    })
  },
})

Bun 的模板命令会把插值参数作为独立参数处理,比拼接字符串安全得多。但脚本内部仍要做白名单和范围校验,因为工具参数未来可能来自不同模型、不同客户端或手工调用。

五、实战二:创建工单工具

创建工单属于外部写操作,不能沿用只读查询的权限策略。下面的实现包含四个关键点:令牌只从环境变量读取、请求带超时、使用幂等键、返回最少必要信息。

typescript 复制代码
// .opencode/tools/ticket_create.ts
import { tool } from "@opencode-ai/plugin"

export default tool({
  description:
    "Create one incident ticket in the internal ticket system after user approval.",
  args: {
    summary: tool.schema.string().trim().min(8).max(120),
    description: tool.schema.string().trim().min(20).max(4000),
    severity: tool.schema.enum(["low", "medium", "high", "critical"]),
    service: tool.schema.string().trim().regex(/^[a-z0-9-]{2,40}$/),
  },
  async execute(args, context) {
    const baseUrl = process.env.TICKET_API_BASE
    const token = process.env.TICKET_API_TOKEN
    if (!baseUrl || !token) {
      throw new Error("Ticket API environment variables are not configured")
    }

    const controller = new AbortController()
    const timer = setTimeout(() => controller.abort(), 8_000)

    try {
      const response = await fetch(`${baseUrl}/api/tickets`, {
        method: "POST",
        headers: {
          "content-type": "application/json",
          authorization: `Bearer ${token}`,
          "idempotency-key": `${context.sessionID}:${context.messageID}`,
        },
        body: JSON.stringify(args),
        signal: controller.signal,
      })

      if (!response.ok) {
        const detail = (await response.text()).slice(0, 500)
        throw new Error(`Ticket API failed: ${response.status} ${detail}`)
      }

      const data = await response.json() as { id: string; url: string; status: string }
      return JSON.stringify({
        ok: true,
        ticketId: data.id,
        status: data.status,
        url: data.url,
      })
    } finally {
      clearTimeout(timer)
    }
  },
})

5.1 为什么需要幂等键

网络超时并不等于服务端没有创建工单。如果客户端在结果不明时自动重试,可能生成两个相同工单。把 sessionID 和 messageID 组合成幂等键,服务端就能识别同一次意图。真正的生产系统还应在服务端保存幂等结果,并设置合理的有效期。

5.2 不要把 Token 设计成工具参数

如果把 Token 放进 args,它就可能出现在模型上下文、调用记录和错误日志中。正确做法是由运行环境注入密钥,工具只接收业务参数。日志里同样不要打印完整请求头、连接串或用户隐私字段。

六、实战三:发布前检查工具

第三个工具不真正发布,而是做可重复、无副作用的预检:检查 Git 是否干净、读取包版本、验证必要文件是否存在。这样可以把"检查"和"发布"拆开,让 Agent 先给出证据,再决定是否进入高风险步骤。

typescript 复制代码
// .opencode/tools/release_preflight.ts
import { existsSync } from "fs"
import path from "path"
import { tool } from "@opencode-ai/plugin"

export default tool({
  description:
    "Run read-only release checks: Git status, package version, and required files.",
  args: {
    requireCleanTree: tool.schema.boolean().default(true),
  },
  async execute(args, context) {
    const root = context.worktree
    const packageFile = path.join(root, "package.json")
    const changelogFile = path.join(root, "CHANGELOG.md")

    if (!existsSync(packageFile)) {
      return JSON.stringify({ ok: false, errors: ["package.json is missing"] })
    }

    const pkg = await Bun.file(packageFile).json()
    const gitStatus = await Bun.$`git -C ${root} status --short`.text()
    const dirty = gitStatus.trim().length > 0

    const errors: string[] = []
    if (!pkg.version) errors.push("package.json has no version")
    if (!existsSync(changelogFile)) errors.push("CHANGELOG.md is missing")
    if (args.requireCleanTree && dirty) errors.push("Git worktree is not clean")

    return JSON.stringify({
      ok: errors.length === 0,
      version: pkg.version ?? null,
      dirty,
      changedFiles: gitStatus.trim().split("\n").filter(Boolean).slice(0, 30),
      errors,
    })
  },
})

返回 JSON 字符串比一大段自由文本更适合后续 Agent 判断。建议统一使用 ok、errors、warnings 和业务数据字段;同时限制列表长度,避免一次调用把大量日志塞进上下文。

七、用权限把工具分级

OpenCode 当前稳定文档使用 permission 配置工具行为,取值为 allow、ask 或 deny。Custom Tool 也按工具名匹配,因此可以把只读能力自动放行,把外部写操作设为询问。

json 复制代码
{
  "$schema": "https://opencode.ai/config.json",
  "permission": {
    "business_lookup": "allow",
    "release_preflight": "allow",
    "ticket_create": "ask",
    "bash": "ask",
    "edit": "ask"
  }
}

权限不是工具内部校验的替代品,而是第二层防线。即使 ticket_create 被配置为 allow,服务端仍应校验用户身份、服务范围、字段长度、频率和数据权限。

7.1 推荐的风险分级

级别 典型工具 建议
只读 查询、状态检查、文档检索 白名单和限流到位后可 allow
可逆写入 创建草稿、建普通工单 默认 ask,并返回资源 ID
高风险写入 发布、删除、付款、改权限 始终 ask,服务端再次鉴权
任意执行 原始 Shell、任意 SQL 尽量不用;至少 ask 或 deny

八、错误处理:让失败可判断、可恢复

一个好工具不能只在成功路径上工作。至少要区分以下四类失败:

  1. **参数错误:**由 Zod 在执行前阻止。
  2. **环境错误:**缺少环境变量、脚本或依赖时给出明确提示。
  3. **业务错误:**例如库存记录不存在,应返回可解释结果,而不是伪造成功。
  4. **结果不明:**超时后不要盲目重放有副作用的请求,应先查询幂等键或资源状态。
typescript 复制代码
type ToolResult<T> =
  | { ok: true; data: T; warnings?: string[] }
  | { ok: false; code: string; message: string; retryable: boolean }

function failure(code: string, message: string, retryable = false) {
  return JSON.stringify({ ok: false, code, message, retryable })
}

不要把完整堆栈、响应头或数据库连接信息直接返回给模型。详细错误应进入受控日志,工具返回给模型的内容只保留定位问题所需的信息。

九、Custom Tools 与插件工具如何选择

除了 `.opencode/tools/`,OpenCode 插件也能在插件返回对象的 tool 字段中注册工具。两种方式的核心结构一致,但使用场景不同。

方案 适合场景 特点
项目 Custom Tool 仓库专属脚本、发布检查 简单直接,便于随项目版本控制
全局 Custom Tool 个人跨项目通用能力 配置一次,多项目可用
插件注册 Tool 工具与事件钩子、日志、配置联动 扩展能力更强,适合产品化
MCP Server 跨客户端、跨团队、独立服务 边界清晰,但部署与治理成本更高
typescript 复制代码
import { type Plugin, tool } from "@opencode-ai/plugin"

export const InternalToolsPlugin: Plugin = async () => ({
  tool: {
    service_health: tool({
      description: "Read the health state of an approved internal service",
      args: {
        service: tool.schema.enum(["order", "inventory", "billing"]),
      },
      async execute({ service }) {
        return JSON.stringify({ service, status: "healthy" })
      },
    }),
  },
})

十、测试与验收清单

10.1 最小测试矩阵

  • 正常参数是否得到预期结构。
  • 空字符串、超长字符串、非法枚举是否被拒绝。
  • 路径中包含空格时是否仍能正确调用脚本。
  • 依赖、环境变量或数据库缺失时是否返回清晰错误。
  • API 返回 4xx、5xx 或超时时是否区分可重试性。
  • 外部写操作重复调用时是否具备幂等保护。
  • 日志和返回值中是否没有 Token、连接串和个人数据。

10.2 用真实提示词做验收

text 复制代码
查询仓库 WH-SH-01 中包含 SKU-8899 的库存摘要,最多返回 10 条。

先检查当前仓库能否发布,只做预检,不要执行发布。

为 order-service 创建一个 high 级别故障工单;创建前先把内容给我确认。

验收时不仅要看工具能否运行,还要看模型是否选对工具、参数是否正确、遇到副作用是否停在授权边界,以及结果是否包含可验证证据。

十一、常见误区

11.1 把任意 SQL 暴露给模型

即使数据库账号是只读的,任意 SQL 仍可能造成全表扫描、隐私数据泄露或绕过业务隔离。优先提供固定数据集、固定查询模板和明确上限。

11.2 用工具描述代替权限控制

"请不要删除数据"只是提示,不是安全边界。真正的限制必须同时存在于 schema、工具实现、OpenCode 权限和后端鉴权中。

11.3 返回过多原始数据

一次把几万行查询结果返回给模型,会增加成本并稀释关键信息。让工具先过滤、分页或聚合,只返回完成当前任务所需的数据。

11.4 把本机路径和密钥写进代码

使用 context.worktree、context.directory 和环境变量,让工具可以在不同开发者、CI 和 worktree 环境中复用。

十二、从 Demo 到企业落地的演进路线

  1. **第一阶段:**只读工具,固定数据源,结果限制在几十条。
  2. **第二阶段:**加入可逆写入,默认 ask,服务端实现幂等。
  3. **第三阶段:**统一结果协议、审计日志、指标和告警。
  4. **第四阶段:**将跨团队能力迁移为插件包或 MCP 服务。

不要一开始就给 Agent 一个"万能内部 API"。高质量工具通常很窄:名称清楚、参数有限、结果稳定、风险可预测。工具越窄,模型越容易正确选择,权限也越容易审计。

十三、结语

Custom Tools 真正解决的不是"让 OpenCode 多一个函数",而是把企业系统能力转换成 Agent 可以安全调用的契约。项目级目录负责就近集成,Zod 负责输入边界,context 负责定位会话与 worktree,权限配置负责授权,后端服务负责最终鉴权和幂等。

如果只记住一条实践原则,可以是:**让每个工具只做一件边界清楚、结果可验证的事。**从只读查询和发布预检开始,等审计、权限和错误处理成熟后,再逐步接入工单、部署和生产操作。

参考资料

相关推荐
羊仔AI探索3 小时前
离世仅仅半个月,张雪峰被蒸馏了
人工智能·aigc·蒸馏·张雪峰·skills
zhippt_10143 小时前
只剩截图没有源文件?图转PPT把图片还原成可编辑幻灯片
前端·人工智能·powerpoint
小oo呆3 小时前
【自然语言处理与大模型】自然语言处理的漫漫长路之深度学习时代
人工智能·深度学习·自然语言处理
JavaPub-rodert3 小时前
AI Agent 的记忆系统怎么设计?从短期记忆、长期记忆到 RAG,讲透 Memory 工程化
人工智能
智恒百亿3 小时前
一台 5090 八卡服务器怎么分给一个团队用?多模型多任务的显存分配实战
java·大数据·人工智能
MCT1236543 小时前
AI VRM 高频设计|功率电感磁材选型与电源损耗、效率关系
人工智能·dc-dc电源·功率电感·一体成型电感
汤姆宝库3 小时前
安全深拆:越狱、Sleeper与可解释性
人工智能
skywalk81633 小时前
lightmem记忆系统实战存取.小浣熊一句话:从lightmem记忆中,取出FreeBSD linux仿真环境安装启动workbuddy的信息,整理成文档
人工智能·硬件架构·记忆·lightmem
Solange__3 小时前
在线3D预览器、Blender和游戏引擎怎么选?按5类任务比较格式、隐私与编辑性
人工智能·游戏·3d·游戏引擎·blender·1024程序员节·关卡设计