读完这篇你能:把工具的参数写规矩——控制必填/可选、给出候选值、知道哪些校验不用自己做。
前置:第一个 MCP 能跑通。约 15 分钟。
换一个真实的案例
hello_world 到此为止。后面全专栏都用这个场景:给公司的日志聚合系统做一个 MCP,让 AI 能自己查日志、排查问题。
服务端提供两个工具:

| 工具 | 作用 |
|---|---|
list_services |
列出所有服务名 |
query_logs |
按服务、日志级别、时间范围查询日志 |
query_logs 的三个参数正好覆盖三种情况:service_name 必填、time_range 必填、log_levels 可选且值必须从固定集合里挑。这就是这篇要解决的全部问题。
第一版:直接把参数写出来
type queryLogsInput struct {
ServiceName string `json:"service_name" jsonschema:"服务名称"`
LogLevels []string `json:"log_levels" jsonschema:"日志级别"`
TimeRange string `json:"time_range" jsonschema:"时间范围"`
}
type queryLogsOutput struct {
Logs []string `json:"logs" jsonschema:"日志"`
}
func queryLogsTool(ctx context.Context, req *mcp.CallToolRequest, in queryLogsInput) (*mcp.CallToolResult, queryLogsOutput, error) {
return nil, queryLogsOutput{
Logs: []string{"log1", "log2", "log3"},
}, nil
}
注册时不需要写 schema:
mcp.AddTool(s, &mcp.Tool{
Name: "query_logs",
Description: "按服务名和时间范围查询日志,可筛选日志级别",
}, queryLogsTool)
结构体就是参数声明。 SDK 用 jsonschema.For[queryLogsInput] 从类型推断出 inputSchema,再把客户端传来的 arguments 反序列化填进来。你不用写 json.Unmarshal。
结构的规则也很朴素:
- 字段名 → JSON 属性名,由
json标签决定 - 字段类型 → JSON Schema 类型(
string/array/integer…) jsonschema标签 → 属性的 description,模型能读到,所以要当文档写
可选参数:加 omitempty
第一版有个副作用:三个字段默认都是必填。SDK 的规则是——没有 omitempty 的字段会进 schema 的 required 列表,客户端不传就校验失败。
log_levels 显然应该可选,加上:
LogLevels []string `json:"log_levels,omitempty" jsonschema:"日志级别(可选)"`
再连上去,会发现参数列表里级别已经不再是必填项:

omitempty在这里的作用是「不参与必填校验」。指针类型(*string)也是可选的,但多数场景omitempty更顺手。
候选值:得自己写 schema
现在 log_levels 是个自由文本,用户和模型都不知道能填什么——这正是 IDE 里最让人烦躁的那种参数。
SDK 从 Go 类型推断不出候选值,所以要显式构造一份 schema 去覆盖默认推断。建议单独放一个 schema.go:
package main
import (
"log"
"github.com/google/jsonschema-go/jsonschema"
)
// 服务端定义的合法日志级别,写进 query_logs 的 inputSchema。
var queryLogsAllowedLogLevels = []any{"debug", "info", "warn", "error"}
var queryLogsInputSchema *jsonschema.Schema
func init() {
s, err := jsonschema.For[queryLogsInput](nil)
if err != nil {
log.Fatalf("query-logs 输入 schema: %v", err)
}
// 多选:给数组的 items 加枚举,并禁止重复
if p := s.Properties["log_levels"]; p != nil {
p.UniqueItems = true
if p.Items == nil {
p.Items = &jsonschema.Schema{Type: "string"}
}
p.Items.Enum = append([]any(nil), queryLogsAllowedLogLevels...)
}
queryLogsInputSchema = s
}
注册时把它挂上去:
mcp.AddTool(s, &mcp.Tool{
Name: "query_logs",
Description: "按服务名和时间范围查询日志,可筛选日志级别",
InputSchema: queryLogsInputSchema, // 由不设置改为设置
}, queryLogsTool)
再连上去,级别就变成带下拉的多选框了:

如果只想要单选,把字段类型从 []string 改成 string,然后枚举直接加在属性上:
if p := s.Properties["log_level"]; p != nil {
p.Enum = append([]any(nil), queryLogsAllowedLogLevels...)
}

写法就是「先
jsonschema.For拿到推断结果,再按属性名改」。这个包里还能改Default、Minimum、Pattern等等,本质是把 JSON Schema 的关键字挂到对应属性上。想写什么查 json-schema.org 即可。
哪些校验不用你做
走到这里,参数层面已经覆盖了三类约束:
| 约束 | 在哪表达 | 谁执行 |
|---|---|---|
| 类型对不对 | Go 字段类型 | SDK 自动 |
| 必填还是可选 | 有没有 omitempty |
SDK 自动 |
| 值在不在候选集里 | Enum / Items.Enum |
SDK 自动 |
这三类校验都是 SDK 在进 handler 之前做的,handler 根本不会被执行,客户端直接收到 InvalidParams。所以别在 handler 里再写一遍 if in.ServiceName == ""。
schema 表达不了的东西只有一类——字段之间的关系,比如「start_time 不能晚于 end_time」。这类只能自己拦,返回什么错误见错误返回与重试。
现在你能做到:写清楚工具的参数——哪些必填、允许哪些值,并且知道校验不需要自己写。
下一篇:跑成 HTTP 服务——工具跑通了,让远程也能用。