**摘要:**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 |
八、错误处理:让失败可判断、可恢复
一个好工具不能只在成功路径上工作。至少要区分以下四类失败:
- **参数错误:**由 Zod 在执行前阻止。
- **环境错误:**缺少环境变量、脚本或依赖时给出明确提示。
- **业务错误:**例如库存记录不存在,应返回可解释结果,而不是伪造成功。
- **结果不明:**超时后不要盲目重放有副作用的请求,应先查询幂等键或资源状态。
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 到企业落地的演进路线
- **第一阶段:**只读工具,固定数据源,结果限制在几十条。
- **第二阶段:**加入可逆写入,默认 ask,服务端实现幂等。
- **第三阶段:**统一结果协议、审计日志、指标和告警。
- **第四阶段:**将跨团队能力迁移为插件包或 MCP 服务。
不要一开始就给 Agent 一个"万能内部 API"。高质量工具通常很窄:名称清楚、参数有限、结果稳定、风险可预测。工具越窄,模型越容易正确选择,权限也越容易审计。
十三、结语
Custom Tools 真正解决的不是"让 OpenCode 多一个函数",而是把企业系统能力转换成 Agent 可以安全调用的契约。项目级目录负责就近集成,Zod 负责输入边界,context 负责定位会话与 worktree,权限配置负责授权,后端服务负责最终鉴权和幂等。
如果只记住一条实践原则,可以是:**让每个工具只做一件边界清楚、结果可验证的事。**从只读查询和发布预检开始,等审计、权限和错误处理成熟后,再逐步接入工单、部署和生产操作。