MCP Tool 不只是带 Description 的函数,而是一份跨越四个信任边界的能力契约:
用户意图 -> 模型选择 -> MCP 请求 -> 业务副作用
每一层都可能独立失败:模型可能选错 Tool,生成符合 Schema 但语义错误的参数,调用当前 Principal 无权执行的操作,或误读本来正确的结果。生产设计必须让每类失败都可观察、可隔离。
本文采用 MCP 2026-07-28 的协议模型。如果目标 Client 或 Server 仍协商旧版本,应单独维护并测试兼容矩阵,不能混用不同版本的假设。
核心结论
- 为每个 Tool 设计选择、输入、输出和副作用四份契约。
- 暴露业务意图,不暴露通用基础设施能力。
- 身份、Tenant、所有权、价格与 Policy 不能来自模型参数。
- 机器可消费结果使用
outputSchema和structuredContent。 - 区分 Tool Error、Protocol Error,以及
input_required与完成。 - Tool Annotations 只是 Hint,绝不是鉴权。
- 跨请求状态使用不透明、具备完整性保护的 Handle。
- 对 Result 大小、分页、重试、墙钟时间和副作用设置边界。
- 使用真实任务分布评测 Description,并覆盖“不应调用”样本。
2026-07-28 协议背景
当前协议在 Protocol Session 层采用无状态模型:
- Client 通过
server/discover获取支持版本与能力; - 每个请求都在
_meta中携带必需的协议与 Client 元数据; - 旧式
initialize/initializedSession 握手不再是当前模型; - 返回结果的方法使用
resultType,通常为complete或input_required; - 多轮请求显式携带
inputResponses与不透明requestState; - 长连接变更通知通过显式 Subscription 提供,而不是隐式 Session Stream。
维护良好的 SDK 应负责构造和校验协议元数据。Tool Handler 可专注业务契约,但作者仍需理解这套模型,避免把安全状态存入 Connection,或假设一次握手可以授权后续调用。
每个 Tool 的四份契约
1. 选择契约
名称与 Description 帮助模型判断是否调用。
优先使用只表达一个业务动作的名称:
| 模糊名称 | 更好名称 | 原因 |
|---|---|---|
process |
invoice_list_overdue |
明确资源与操作 |
get_data |
customer_get_profile |
明确作用域 |
manage_order |
order_preview_refund |
分离预览与提交 |
http_request |
领域专用读取 Tool | 避免任意网络访问权限 |
有效 Description 应说明:
- Tool 做什么;
- 何时使用;
- 何时不使用;
- 输入的权威语义;
- 输出形状和限制;
- 只读、预览还是提交。
不要把凭证、隐藏 Policy 或长篇教程写进 Description。Description 是由 Server 提供的不可信文本,也会占用模型上下文。
2. 输入契约
使用目标实现支持的最窄 JSON Schema:
- 明确
required; - 使用
additionalProperties: false; - 稳定业务值使用 Enum;
- 为字符串、数组与数字设限;
- 每个字段只表达一个含义;
- 使用 Cursor 分页,而不是无上限 Limit;
- 接收用户真正知道的引用,不暴露内部数据库 Key。
Schema 只能证明形状,不能证明意图。合法 order_reference 可能属于另一 Tenant;合法金额可能已经过期;合法路径经过 Symlink 解析后可能逃逸允许目录。
这些字段绝不能由模型提供:
principal_id, tenant_id, roles, is_admin, approved,
authoritative_price, credential, policy_decision
它们必须来自已认证服务上下文和可信 System of Record。
3. 输出契约
MCP 同时支持:
content:供人类或模型消费的内容块;structuredContent:机器可读 JSON 值。
Tool 声明 outputSchema 后,structuredContent 必须符合该 Schema。类型化 SDK Helper 可以推断和校验这份契约。重要字段应保持稳定:
{
"items": [
{
"invoice_reference": "INV-42",
"currency": "USD",
"amount_minor": 1299
}
],
"next_cursor": ""
}
金额使用整数最小单位,单位和时区必须明确,枚举稳定,Cursor 保持不透明。当调用者需要判断证据时,还应附加来源或新鲜度元数据。
不要把整行数据库记录、完整网页、日志流或异常直接塞进 Result。最小化字段,限制字节数与条目数,脱敏秘密和个人数据,并用 Reference 返回大型 Artifact。
4. 副作用契约
明确 Tool 属于哪一类:
- 纯读取;
- 对外部可见的读取;
- 预览;
- 追加型写入;
- 幂等更新;
- 破坏性或不可逆操作。
写 Tool 必须定义并执行前置条件、Idempotency Key 作用域、冲突语义、确认与结果未知超时恢复。Description 中写“会要求确认”不是安全控制;可信代码应把批准绑定到规范化参数和资源版本的 Digest。
Annotations 只是 Hint
readOnlyHint、destructiveHint、idempotentHint 与 openWorldHint 可以帮助 Host 标记或排序 Tool,但它们未经验证。恶意或有 Bug 的 Server 完全可以把破坏性 Tool 标成 Read-only。
Server 必须执行真实行为约束。Host 应将 Annotation 与自己的 Allowlist、信任 Policy、用户同意和实际 Tool 身份结合。
Error 与多轮 Result
必须区分三类结果:
| 结果 | 表达方式 | 示例 |
|---|---|---|
| 协议失败 | JSON-RPC / MCP Error | 未知 Method、畸形协议请求 |
| Tool 执行失败 | 带 isError 的 Tool Result |
业务状态无效、下游不可用 |
| 需要更多输入 | resultType: "input_required" |
用户选择、凭证、确认 |
可恢复 Tool Error 应返回稳定 Code、是否值得重试,以及安全的下一步操作。不能暴露 Stack Trace、SQL、凭证、内部主机名,也不能通过错误泄露无权查看的资源是否存在。
2026-07-28 的 Multi-Round Trip Result(MRTR)流程如下:
tools/call
-> resultType: input_required
inputRequests: {Server 分配的 ID ...}
requestState: 不透明状态
-> Client 满足请求
-> 带 inputResponses + 同一 requestState 再次 tools/call
-> resultType: complete
收到 requestState 时应按攻击者可控输入处理。已认证部署应将它绑定 Principal、Tenant、Operation、Expiry 与原始规范化参数。未认证 Server 必须加密、签名并校验它。不能把原始凭证或无限制内部状态序列化进 Handle。
可运行的 Go SDK 示例
官方 Go SDK v1.8.0 支持 2026-07-28 规范,并要求 Go 1.25 或更高版本。其泛型 AddTool Helper 会推断输入输出 Schema、校验输入、填充 structuredContent,并将 Handler Error 转为 Tool Error。
这个本地 stdio 示例把 Invoice 限定到 Server 进程捕获的可信 Tenant。远程 Server 应从已认证请求 Middleware 获取同样的 Principal,绝不能从 ListInvoicesInput 接收。
package main
import (
"context"
"errors"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type ListInvoicesInput struct {
Status string `json:"status" jsonschema:"invoice status: open or overdue"`
Limit int `json:"limit" jsonschema:"maximum number of invoices to return"`
}
type Invoice struct {
Reference string `json:"invoice_reference"`
Currency string `json:"currency"`
AmountMinor int64 `json:"amount_minor"`
}
type ListInvoicesOutput struct {
Items []Invoice `json:"items"`
NextCursor string `json:"next_cursor"`
}
type storedInvoice struct {
TenantID string
Status string
Invoice
}
type InvoiceService struct {
tenantID string
invoices []storedInvoice
}
func (s *InvoiceService) ListInvoices(
ctx context.Context,
request *mcp.CallToolRequest,
input ListInvoicesInput,
) (*mcp.CallToolResult, ListInvoicesOutput, error) {
if err := ctx.Err(); err != nil {
return nil, ListInvoicesOutput{}, err
}
if input.Status != "open" && input.Status != "overdue" {
return nil, ListInvoicesOutput{}, errors.New("invalid_status")
}
if input.Limit < 1 || input.Limit > 50 {
return nil, ListInvoicesOutput{}, errors.New("invalid_limit")
}
output := ListInvoicesOutput{Items: []Invoice{}}
for _, invoice := range s.invoices {
if invoice.TenantID != s.tenantID || invoice.Status != input.Status {
continue
}
output.Items = append(output.Items, invoice.Invoice)
if len(output.Items) == input.Limit {
break
}
}
return nil, output, nil
}
func boolPointer(value bool) *bool {
return &value
}
func main() {
service := &InvoiceService{
tenantID: "tenant-a",
invoices: []storedInvoice{{
TenantID: "tenant-a",
Status: "overdue",
Invoice: Invoice{
Reference: "INV-42",
Currency: "USD",
AmountMinor: 1299,
},
}},
}
server := mcp.NewServer(
&mcp.Implementation{Name: "invoice-server", Version: "v1.0.0"},
nil,
)
mcp.AddTool(server, &mcp.Tool{
Name: "invoice_list",
Description: "List open or overdue invoices visible to the authenticated " +
"tenant. Use for invoice lookup, not for payment or reminders. " +
"Returns at most 50 invoice summaries.",
Annotations: &mcp.ToolAnnotations{
ReadOnlyHint: true,
OpenWorldHint: boolPointer(false),
},
}, service.ListInvoices)
if err := server.Run(
context.Background(),
&mcp.StdioTransport{},
); err != nil {
log.Fatal(err)
}
}
类型化 Helper 会校验 JSON 形状,但 Handler 仍要执行 Status 词表和 Limit 规则。更重要的是,模型输入中没有 Tenant 身份。真实 Repository Query 应在数据库 Predicate 中加入 Tenant Scope,并测试任何跨 Tenant Reference 都无法改变结果。
State 与 Handle
协议请求无状态,并不代表业务没有状态。持久状态应放入权威系统,并返回范围化 Handle:
handle -> principal + tenant + operation + resource version + expiry
可以使用服务端记录,或通过认证加密 Token 承载。Handle 应不透明、不可猜测、在必要时可撤销,并在 Principal、Purpose、Version 或 Expiry 不再匹配时拒绝。
不能依赖:
- 横向扩容部署中的进程本地 Map;
- 把 HTTP Connection 当作用户身份;
- 将 Client 提供的 Session ID 当成授权;
- 没有 Operation Digest 的可重放 Approval Token;
- 把模型记忆当作工作流状态。
Tool Discovery 与上下文预算
Tool 数量没有通用上限。应测量:
- 序列化 Schema Token;
- 选错 Tool 与不调用比例;
- 参数准确率;
- 首 Token 与任务延迟;
- 任务成功与恢复;
- 逐 Tool Policy 违规。
先通过确定性路由与 Policy 过滤,再暴露最小相关集合。Discovery 不会授予权限:Server 已安装或 Tool 已列出,并不代表它值得信任。
测试策略
契约测试
- 最小、典型、最大与畸形输入;
- 未知字段、Enum 变体、Unicode 与边界值;
- 输出一致性与稳定 Error Code;
- 取消、超时、重复请求与幂等;
- 使用其他 Tenant 标识符执行鉴权测试;
- Result 大小与分页限制。
协议测试
server/discover与协商的 Protocol Version;- 每个请求必需的
_meta; - Tool List 与 Call Round Trip;
resultType的 complete 与input_required;inputResponses/requestState完整性;- 部署支持的兼容 Profile;
- Transport 断连与重试行为。
官方 Conformance Suite 和 MCP Inspector 可以覆盖协议层,但业务鉴权测试仍应留在自己的测试套件。
模型参与测试
为这些情况准备真实表达:
- 正确选择;
- 在相似 Tool 之间选择;
- 没有 Tool 适用时放弃调用;
- 缺少信息时主动询问;
- 保留 ID、日期、货币与单位;
- Tool Result 中出现恶意文本;
- Tool Error 后错误声称成功。
Description、Schema、Model、路由或 Tool Catalog 变化时,应重跑同一语料。报告置信区间并按任务类型切片,不要只发布一个聚合分数。
上线检查清单
- [ ] 每个 Tool 只暴露一个窄化业务动作。
- [ ] Description 写清使用、不使用、输出和副作用边界。
- [ ] 输入输出 Schema 被版本化和审查。
- [ ] 模型参数中不存在可信身份与 Policy。
- [ ] 返回数据或执行副作用前完成对象级鉴权。
- [ ] Result 有大小边界、脱敏与 Provenance。
- [ ] Error、完成和需要输入三种状态明确区分。
- [ ] 写操作具备确认与幂等语义。
- [ ] State Handle 不透明、范围化、完整性受保护且会过期。
- [ ] Annotation 与行为一致,但从不被当作安全控制。
- [ ] 确定性、协议、模型与对抗测试进入 CI。
- [ ] Trace 记录版本和决策,不记录秘密。
总结
好的 MCP Tool 设计既要减少模型歧义,也要把确定性权限留在 Server。名称与 Description 引导选择,Schema 定义机器契约,结构化 Result 支持组合,显式 Result Type 支持可靠恢复;这些能力都不能替代认证、对象级鉴权、幂等与审计。
Host 侧执行循环可阅读 LLM Tool Calling 架构指南,Transport 行为可继续阅读 Go MCP Streamable HTTP 指南。