用 Go 手写一个 MCP Server

用 Go 构建个人书签 MCP Server,涵盖工具、资源、提示模板、stdio/HTTP 启动、测试调试和接入 Agent。

outlier 17 分钟 6568 字 0 0
系列教程Agent从入门到放弃 第 23 / 32 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02大模型、Chatbot、Agent,一次把名字理清楚
  3. 03Agent 的最小定义,一个 while 循环
  4. 04Agent 简史,从 ReAct 到 Claude Code
  5. 05Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
  20. 20Function Calling 协议拆解
  21. 21工具设计,写给模型用的 API
  22. 22MCP 入门,工具界的 USB-C
  23. 23用 Go 手写一个 MCP Server
  24. 24MCP 进阶与边界
  25. 25手写 Agent ②,第一个工具,read_file 与 list_files
  26. 26手写 Agent ③,让它会改代码,edit_file
  27. 27手写 Agent ④,给它一个终端,run_bash 与权限
  28. 28手写 Agent ⑤,上下文管理
  29. 29手写 Agent ⑥,流式输出、错误处理与重试
  30. 30手写 Agent ⑧,Skills 与 MCP 接入
  31. 31Claude Code 解剖,对照自己的实现
  32. 32自动化,Hooks、自定义命令与 Headless 模式
查看教程目录

开场:浏览器里那 437 个书签

我的浏览器书签栏里有 437 个书签。我知道这个数字,是因为有一天我想找一篇半年前收藏的、讲 Go 内存模型的文章,翻了二十分钟没翻到,一气之下导出了书签文件数了一遍。

大部分书签的标题是网页原来的标题,比如"Untitled"或者"Home";没有任何分类;更不记得当初为什么要收藏它。那篇讲内存模型的文章,最后是在一个标题叫"Home"的书签里找到的。

那一刻我想:如果我能跟 Agent 说"帮我把这个链接收藏一下,打上 go 和内存模型的标签,备注一下是讲 happens-before 的",以后再问它"我收藏过哪些讲 Go 并发的文章",它就能帮我找出来,该多好。

于是有了这一篇的主角:一个个人书签管理的 MCP server。它很小,连测试在内不到六百行 Go 代码,但麻雀虽小五脏俱全:三个工具、一个资源、一个提示词模板,正好覆盖 MCP 的三种能力。写完之后,任何支持 MCP 的 Agent 都能用它。

读完本篇,你应该能回答这几个问题:

  • 用官方的 Go SDK 写一个 MCP server,需要哪几步?
  • 工具的参数 schema 是怎么从 Go 代码里生成出来的?
  • 写 MCP server 时,有哪些和"写普通 API"不一样的讲究?
  • 怎么调试和测试一个 MCP server?
  • 怎么把它接进 Agent 里实际使用?
  • 哪些能力应该做成工具,哪些能力干脆不该给模型?

一、整体结构

1.1 三个文件

配套仓库的 mcp-bookmark 目录,是一个独立的 Go 模块(有自己的 go.mod)。这是有意为之:仓库主模块的约定是"只用标准库",而写 MCP server 我们要用官方的 SDK modelcontextprotocol/go-sdk。把它放进一个独立的模块,就不会破坏主模块的约定。

它的代码分成三层,建议按这个顺序读:

flowchart TB M["main.go
启动:选传输方式(stdio / HTTP)"] --> S["server.go
MCP 这一层:把业务能力包装成 tools / resources / prompts"] S --> ST["store.go
业务逻辑:书签的增删查改,和 MCP 完全无关"] style S fill:#fff3e0,stroke:#e65100 style ST fill:#e8f5e9,stroke:#2e7d32
  1. store.go:纯粹的业务逻辑,书签存在一个 JSON 文件里,提供 Add、Search、Tag、Tags 几个方法。它不知道 MCP 的存在;
  2. server.go:MCP 这一层,把 store 的能力包装成 MCP 的工具、资源、提示词;
  3. main.go:程序入口,选择用 stdio 还是 HTTP 传输。

这种分层很重要:业务逻辑和协议层分开,业务逻辑可以单独测试,将来换一种协议(或者给它加一个命令行界面),业务逻辑一行都不用改。

1.2 为什么这次用 SDK

第 21 篇的 MCP 客户端,是两百来行标准库手写的;这一篇写 server,却用了官方 SDK。为什么不一视同仁?

因为两边的工作量很不一样。一个只会"列工具、调工具"的最小客户端,要处理的就是握手、发请求、按 id 收响应。而一个合格的 server,要做的事情多得多:

  • 从 Go 类型生成每个工具的 JSON Schema;
  • 收到调用时,按 schema 解析和校验参数;
  • 把处理函数返回的 error 打包成 isError 的结果;
  • 正确地实现资源、提示词、通知、协议版本协商;
  • 同时支持 stdio 和 HTTP 两种传输。

这些都手写,不是做不到,但会把书签这个主角淹没在协议细节里。这个系列的原则是"看清楚每一层",第 21 篇已经亲手看清了协议这一层,这一篇的重点是工具该怎么设计,协议的脏活交给 SDK 正合适。

1.3 能力一览

能力名字说明
tooladd_bookmark收藏网址,自动去重
toolsearch_bookmarks按关键词 / 标签查找
tooltag_bookmark增删标签
resourcebookmarks://tags所有标签及使用次数
promptreading_digest把某个标签下的书签整理成阅读清单

二、业务逻辑:一个 JSON 文件就够了

store.go 的核心数据结构很简单:

// Bookmark 是一条书签。
type Bookmark struct {
    ID        int       `json:"id"`
    URL       string    `json:"url"`
    Title     string    `json:"title"`
    Note      string    `json:"note,omitempty"`
    Tags      []string  `json:"tags,omitempty"`
    CreatedAt time.Time `json:"created_at"`
}

// Store 把书签存在一个 JSON 文件里。够用、可读、出了问题能直接打开文件看。
type Store struct {
    mu     sync.Mutex
    path   string
    NextID int         `json:"next_id"`
    Items  []*Bookmark `json:"items"`
}

为什么用 JSON 文件而不是数据库?因为对一个个人工具来说,够用、可读、出了问题能直接打开文件看,比"专业"更重要。第 17 篇选择文件式记忆,也是同样的道理。

有两个细节值得一提:

一是写文件的方式:

tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
    return err
}
return os.Rename(tmp, s.path) // 先写临时文件再改名,写到一半崩了也不会把原文件弄坏

先写到一个临时文件,再改名覆盖原文件。改名在大多数文件系统上是原子操作,所以就算写到一半程序崩了,原来的书签文件也是完好的。

二是标签的规范化: 模型给的标签,可能是 MCP、mcp、#mcp、 mcp ,normalizeTags 会把它们统一成小写、去掉 # 和空格、去重、排序。模型的输入是不可控的,在业务层做规范化,比在描述里写"标签请用小写"可靠得多。

三是那把锁。 Store 里有一个 sync.Mutex,每个方法进来先加锁。一个个人工具,为什么要考虑并发?因为 store 不应该假设调用它的人是"一个一个来"的:第 23 篇会给它加上 HTTP 模式,多个 Agent 可能同时连上来;即使在 stdio 模式下,一个 Agent 也可能在一次回复里并行地调用两个工具(第 19 篇),而 SDK 怎么调度这些调用,store 不需要关心。业务层自己保证线程安全,协议层怎么变都不怕。

2.1 ID 用自增的整数

书签的 ID 是一个从 1 开始自增的整数,而不是 UUID。这也是一个为模型考虑的选择:tag_bookmark 需要模型传一个 id,#7 这样的短数字,模型从上一次搜索结果里抄过来几乎不会出错;一串三十六个字符的 UUID,抄错一位就找不到了,而且每次出现都要多占十几个 token。

第 20 篇 5.3 节说"给 ID 的同时给出名字",这个 server 两件事都做了:搜索结果里每条书签都是"#7 加标题加网址",模型既能拿 id 去调用下一个工具,也能确认自己找对了那一条。

2.2 搜索为什么这么朴素

Search 的实现朴素得有点过分:

// Search 按关键词(匹配标题、URL、备注)和标签查找,结果按时间从新到旧。
for i := len(s.Items) - 1; i >= 0 && len(out) < limit; i-- {
    b := s.Items[i]
    if query != "" && !strings.Contains(strings.ToLower(b.Title+" "+b.URL+" "+b.Note), query) {
        continue
    }
    // ……标签过滤
}

没有分词,没有向量,没有模糊匹配,就是一个不区分大小写的子串查找。为什么不做得更"智能"一点?

回想第 18 篇:调用这个工具的是一个有循环的 Agent。 搜"go 并发"没搜到,它会自己换成"goroutine"再搜一次,或者换成按标签 go 列出来再自己挑。工具只要做到"精确、可预期、结果可解释",聪明的部分交给模型。反过来,如果工具自己做了一套模糊匹配,返回了一堆"可能相关"的结果,模型反而不知道这些结果为什么会出现,也不知道该怎么调整搜索词。

不过工具有一个地方是帮了模型一把的:搜不到的时候,它会把现有的标签全列出来("没有找到匹配的书签。现有标签:blog, go, mcp")。模型一看就知道该用哪个标签再搜一次。这正是第 20 篇说的"错误信息就是提示词":没搜到也是一种结果,而且是一个可以带上下一步提示的结果。

三、MCP 这一层

3.1 创建 server

s := mcp.NewServer(&mcp.Implementation{Name: "bookmark", Version: "0.1.0"}, &mcp.ServerOptions{
    Instructions: "个人书签管理。收藏前先用 search_bookmarks 看看是否已经收藏过。",
})

Instructions 就是第 21 篇握手时,server 在 initialize 响应里返回的那段使用说明。host 可以把它放进上下文,告诉模型这个 server 整体上该怎么用。

它和工具描述的分工是:工具描述讲"这一个工具怎么用",instructions 讲"这几个工具怎么配合"。 比如"收藏前先用 search_bookmarks 看看是否已经收藏过",这句话放进 add_bookmark 的描述里也行,但它其实是一个跨工具的工作流程,放在 instructions 里更合适。

写 instructions 时,可以想想第 15 篇讲的那些:它是一段会进入上下文的 prompt,要短、要具体、要说清楚"为什么"。不过别忘了第 21 篇 6.1 节说的:host 用不用它,是 host 的决定。mini-agent 现在就没有用它。所以重要的约束,不要只写在 instructions 里,工具自己的描述和行为(比如 add_bookmark 本身就会去重)才是更可靠的保障。

3.2 定义一个工具

用 SDK 定义工具,最妙的地方在于:参数的 JSON Schema 是从一个普通的 Go 结构体自动生成的。

type AddInput struct {
    URL   string   `json:"url" jsonschema:"要收藏的网址,必须以 http:// 或 https:// 开头"`
    Title string   `json:"title,omitempty" jsonschema:"标题;不填就用网址"`
    Note  string   `json:"note,omitempty" jsonschema:"一句话备注:为什么收藏它"`
    Tags  []string `json:"tags,omitempty" jsonschema:"标签,例如 [\"go\", \"mcp\"]"`
}
  • json 标签决定了参数的名字,带 omitempty 的是可选参数,不带的是必填参数;
  • jsonschema 标签里的文字,会变成这个参数的 description。模型看到的就是这些字。

然后注册工具:

mcp.AddTool(s, &mcp.Tool{
    Name:        "add_bookmark",
    Description: "收藏一个网址。同一个网址重复收藏时会返回已有的那条,不会重复添加。",
}, func(ctx context.Context, req *mcp.CallToolRequest, in AddInput) (*mcp.CallToolResult, any, error) {
    b, dup, err := store.Add(in.URL, in.Title, in.Note, in.Tags)
    if err != nil {
        return nil, nil, err // AddTool 会把 error 打包成 isError=true 的结果,模型能看到并自我纠正
    }
    if dup {
        return text("这个网址已经收藏过了,没有重复添加:\n" + format(b) + "\n如需补充标签,用 tag_bookmark。"), nil, nil
    }
    return text("已收藏:\n" + format(b)), nil, nil
})

处理函数的第三个参数直接就是 AddInput 结构体,SDK 帮你做了 JSON 解析和校验。你只需要写业务逻辑,不用再操心参数是字符串还是对象、字段有没有拼错。

第 21 篇用探针看到的那段 tools/list 响应:

{"name":"add_bookmark",
 "description":"收藏一个网址。同一个网址重复收藏时会返回已有的那条,不会重复添加。",
 "inputSchema":{"type":"object","properties":{
   "url":{"type":"string","description":"要收藏的网址,必须以 http:// 或 https:// 开头"}, ……}}}

就是从这几行 Go 代码生成的。

flowchart LR G["Go 结构体
json + jsonschema 标签"] -->|"SDK 自动生成"| J["JSON Schema"] J -->|"tools/list"| H["Host"] H -->|"翻译成工具定义"| M(("模型看到的")) style G fill:#e8f5e9,stroke:#2e7d32 style M fill:#e3f2fd,stroke:#1565c0

3.3 第 20 篇的原则,在这里怎么落地

写 MCP server 的工具,和第 20 篇讲的工具设计是同一回事。看看 add_bookmark 用了哪些:

描述里写清楚了行为边界:"同一个网址重复收藏时会返回已有的那条,不会重复添加"。模型就不用担心重复调用会产生重复数据。

参数描述带格式要求和例子:"必须以 http:// 或 https:// 开头","例如 [\"go\", \"mcp\"]"。

返回值带下一步:重复收藏时,返回"如需补充标签,用 tag_bookmark"。

错误信息告诉模型怎么改:store.Add 校验 URL 时返回的错误是:

url 必须以 http:// 或 https:// 开头,收到的是 "go.dev/blog"

tag_bookmark 找不到书签时:

没有 id 为 7 的书签,先用 search_bookmarks 找到正确的 id

还有一个 MCP 特有的细节:处理函数返回的 error,SDK 会把它打包成 isError: true 的工具结果,而不是协议错误。这正是第 21 篇说的"工具执行出错"那一种:模型能看到错误信息,能自己纠正。

3.4 工具注解

search_bookmarks 和 tag_bookmark 还多了一个字段:

mcp.AddTool(s, &mcp.Tool{
    Name:        "search_bookmarks",
    Description: "按关键词和/或标签查找书签,结果从新到旧排列。两个参数都不填时列出最近的书签。",
    Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
}, ……)

mcp.AddTool(s, &mcp.Tool{
    Name:        "tag_bookmark",
    Description: "给一条书签添加或移除标签。",
    Annotations: &mcp.ToolAnnotations{IdempotentHint: true},
}, ……)

工具注解(annotations)是给 host 看的提示,不是给模型看的:

  • ReadOnlyHint:这个工具只读,不会修改任何东西。host 可以据此决定"调用前不用问用户";
  • IdempotentHint:重复调用同样的参数,效果和调用一次一样;
  • 还有 DestructiveHint(会删除或覆盖数据)等。

注意它们叫 Hint:它们是 server 自己声称的,host 不应该无条件相信。 一个恶意的 server 完全可以给一个删库的工具标上 ReadOnlyHint。第 23 篇讲信任问题时会再回到这里。

3.5 没有删除工具,是故意的

再看一眼 1.3 节那张能力表:收藏、查找、打标签。你可能会发现少了点什么:没有删除。

这不是忘了写,而是一个设计决定。对一个个人书签工具来说,删除是唯一一个"做错了就收不回来"的操作;而 Agent 调用工具,是模型自己决定的(第 21 篇)。用户说"把那些没用的书签清理一下",模型对"没用"的理解,未必和你一样。

所以这个 server 的选择是:不给模型删除的能力。 想删,自己打开 JSON 文件删,或者以后单独写一个命令行工具。这和第 20 篇"读和写要分开设计"是同一个思路,只是更进一步:最危险的那个写操作,干脆不做成工具。

如果你真的需要它,至少做到这几点:

  • 单独一个工具 delete_bookmark,不要塞进 tag_bookmark 里;
  • 标上 DestructiveHint: true,让 host 有机会在调用前问用户;
  • 一次只能删一条,按 id 删,不支持"按条件批量删";
  • 不真删,而是挪到一个"回收站"里,留一条后路。

一个工具能做的最坏的事,就是这个工具的风险上限。 设计工具时,先想清楚这个上限是什么,再决定要不要给。

3.6 返回给模型的是文本

还有一个容易被忽略的选择:工具返回的是一段给人看的文本,而不是一个 JSON 对象。format 函数把一条书签格式化成这样:

#1 Go Blog
    https://go.dev/blog
    标签:go

为什么不直接返回 JSON?第 20 篇说过,模型读文本最自然:一行一个字段、有缩进、有中文的标签名,模型能直接理解,也能直接转述给用户;JSON 里的引号、括号、转义字符,既占 token,又没有带来额外的信息。

注意 AddTool 的处理函数有三个返回值,中间那个 any 我们一直传的是 nil。它是留给"结构化输出"用的:协议允许工具在文本之外,再附带一份机器可读的结构化结果,给那些需要程序化处理结果的 host 用。对一个主要给模型读的工具来说,文本就够了。

3.7 资源和提示词

资源用 URI 来标识,读取时返回内容:

s.AddResource(&mcp.Resource{
    URI:         "bookmarks://tags",
    Name:        "tags",
    Description: "所有标签及其使用次数",
    MIMEType:    "application/json",
}, func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
    data, _ := json.Marshal(store.Tags())
    return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{
        {URI: req.Params.URI, MIMEType: "application/json", Text: string(data)},
    }}, nil
})

提示词模板接收参数,返回一组预先写好的消息:

s.AddPrompt(&mcp.Prompt{
    Name:        "reading_digest",
    Description: "把某个标签下的书签整理成一份阅读清单",
    Arguments:   []*mcp.PromptArgument{{Name: "tag", Description: "标签名", Required: true}},
}, func(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
    tag := req.Params.Arguments["tag"]
    // ……查出这个标签下的所有书签,拼成一条 user 消息:
    // "下面是我收藏的「xx」相关书签,请按主题分组、给每组写一句话导读,并建议一个阅读顺序:……"
})

第 21 篇说过,资源是"应用决定什么时候读",提示词是"用户主动选用"。比如用户在 host 里选择了 reading_digest 这个模板,填上 go,host 就会调用它,拿到那条拼好的消息,作为用户的输入发给模型。

四、启动:stdio 和 HTTP

main.go 很短,核心是选择传输方式:

// stdio 模式下 stdout 是协议通道,日志只能写 stderr,否则会把客户端搞崩。
log.SetOutput(os.Stderr)

// ……打开 store,创建 server

if *httpAddr == "" {
    if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
        log.Fatal(err)
    }
    return
}
// ……HTTP 模式,第 23 篇讲

第一行就是第 21 篇反复强调的那件事:stdio 模式下,stdout 是协议通道。 Go 的 log 包默认写 stderr,但显式设置一下更保险,也是给读代码的人一个提醒。

不传 -http 参数时,就是 stdio 模式:server.Run 会一直从 stdin 读请求、往 stdout 写响应,直到客户端关闭连接。

五、调试和测试

5.1 MCP Inspector

MCP 官方提供了一个调试工具 MCP Inspector:一个网页界面,可以连上你的 server,点一点就能列出工具、填参数调用、读资源、试提示词。

cd mcp-bookmark
npx @modelcontextprotocol/inspector go run .

(需要 Node.js。具体的界面和用法以官方文档为准。)

它特别适合用来检查:工具描述读起来清不清楚?参数的 schema 生成得对不对?错误信息是不是一句人话?你在 Inspector 里看到的,就是模型将会看到的。

第 21 篇的探针也是一个调试工具,它的好处是能看到原始的 JSON-RPC:

go -C mcp-bookmark build -o /tmp/mcp-bookmark .   # 独立模块,用 -C 进它的目录编译
go run ./examples/ch21-mcp-probe -- /tmp/mcp-bookmark -db /tmp/bm.json

5.2 用内存传输写测试

调试工具适合手动检查,回归测试还是要靠代码。go-sdk 提供了一个内存传输,可以把 server 和一个客户端直接连起来,不用启动子进程。server_test.go 里是这样做的:

// connect 用内存传输把 server 和一个官方 SDK 客户端连起来,不需要起子进程。
func connect(t *testing.T, store *Store) *mcp.ClientSession {
    t.Helper()
    ctx := context.Background()
    st, ct := mcp.NewInMemoryTransports()
    if _, err := NewServer(store).Connect(ctx, st, nil); err != nil {
        t.Fatal(err)
    }
    cs, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil).Connect(ctx, ct, nil)
    // ……
    return cs
}

然后像真正的客户端一样调用工具,检查结果。测试里覆盖了几个关键场景:正常收藏、重复收藏、URL 格式错误(要返回 isError 并且错误信息里有提示)、打标签、搜索、数据真的写进了文件。

还有一个我很喜欢的测试:

func TestToolSchemaHasDescriptions(t *testing.T) {
    // ……列出所有工具,检查每个参数都有 description
    for name, p := range props {
        if p.(map[string]any)["description"] == nil {
            t.Errorf("%s.%s 没有描述,模型会不知道它是干什么的", tool.Name, name)
        }
    }
}

它检查的不是功能,而是"工具定义对模型是否友好"。 以后谁新加了一个参数、忘了写 jsonschema 标签,这个测试就会失败。第 20 篇说工具描述就是 prompt,那 prompt 的质量也可以有测试。

cd mcp-bookmark && go test ./...

六、接进 Agent

6.1 接进 mini-agent

mini-agent 读取一个 .mcp.json 格式的配置文件来连接 MCP server。仓库里有一份示例 agent/mcp/example.mcp.json:

{
  "mcpServers": {
    "bookmark": {
      "command": "/tmp/mcp-bookmark",
      "args": ["-db", "/tmp/bookmarks.json"]
    }
  }
}

command 和 args 就是启动 server 的命令。编译好之后:

要让 Agent 真正用起来,需要接真实模型:先 export DEEPSEEK_API_KEY=sk-...。这一步要启动子进程,在线 Playground 演示不了。
go -C mcp-bookmark build -o /tmp/mcp-bookmark .   # 独立模块,用 -C 进它的目录编译
export DEEPSEEK_API_KEY=sk-...
go run ./cmd/mini-agent -mcp agent/mcp/example.mcp.json

启动信息里会显示连上了 1 个 MCP server。试着说:

把 https://go.dev/blog 收藏一下,打上 go 和 blog 标签,备注是 Go 官方博客。

然后:

我收藏过哪些和 go 有关的东西?

第 31 篇会讲 mini-agent 这一侧是怎么接入 MCP 的。

6.2 接进其他 Agent

大纲里这一篇的动手作业是"写完后接入 Claude Code 实际使用"。截至写作时,Claude Code 可以用 claude mcp add 添加一个 stdio 的 server,其他编码 Agent 也大多支持 .mcp.json 或类似的配置方式,具体命令和配置格式以各自的文档为准。核心信息就两样:启动 server 的命令,以及它的参数。

接好之后,留意它的工具列表里是不是多了 add_bookmark 这几个工具,然后像上面一样用自然语言让它收藏、查找。同一个 server,不改一行代码,接进了两个完全不同的 Agent。 这就是第 21 篇说的 N+M。

6.3 让 Agent 来"测试"你的 server

go test 测的是 server 的功能对不对。但一个 MCP server 真正的用户是模型,它用得顺不顺手,只有让模型用一用才知道。

一个简单的办法:准备五六个典型的请求,用 headless 模式让 mini-agent 跑一遍,加上 -v 看它的工具调用过程(第 33 篇):

go run ./cmd/mini-agent -mcp agent/mcp/example.mcp.json -v -p "我收藏过哪些讲并发的文章?"

重点看三件事:它有没有一次就选对工具?参数有没有填错?有没有哪一步它犹豫了、换了好几种参数才成功?每一次犹豫,都是工具描述、参数描述或者错误信息可以改进的地方。改完之后再跑一遍,这就是一个最小的"工具评测",第 36 篇会把它做成一套完整的流程。

七、动手作业

  1. 加一个 list_tags 工具:它和 bookmarks://tags 这个资源返回的内容一样。想一想:同一份数据,做成工具和做成资源,区别在哪里?(提示:回到第 21 篇 3.1 节,谁来决定什么时候读。)
  2. 按 3.5 节的四条要求,实现一个 delete_bookmark:标上 DestructiveHint,删除时挪进回收站。然后在 mini-agent 里说"把所有没打标签的书签清理掉",看看它会怎么做;
  3. 给 search_bookmarks 加一个"按收藏时间过滤"的参数,比如 since。参数的格式怎么设计,模型才最不容易填错?写好 jsonschema 标签,跑一遍 TestToolSchemaHasDescriptions;
  4. 用 6.3 节的方法,对比改动前后:同样几个请求,模型的工具调用次数有没有变少。

八、踩坑记录:写 MCP server 的七个坑

坑 1:业务逻辑和协议层写在一起。
所有逻辑都写在工具的处理函数里,想单独测试业务逻辑就很麻烦,想加一个命令行界面也得重写。store 和 server 分开,store 不 import 任何 MCP 的东西。

坑 2:往 stdout 打日志。
第 21 篇的坑 1,在写 server 时最容易犯。调试的时候随手一个 fmt.Println,客户端就崩了。log.SetOutput(os.Stderr) 放在 main 的第一行。

坑 3:参数没有描述。
结构体字段忘了写 jsonschema 标签,生成的 schema 里就只有名字和类型。模型看到一个叫 id 的整数参数,不知道它是什么的 id、从哪里拿。用测试兜底,像 TestToolSchemaHasDescriptions 那样。

坑 4:把工具错误当成协议错误。
URL 格式不对,直接让整个请求失败(返回 JSON-RPC 错误),模型就看不到"必须以 http:// 开头"这句提示了。业务上的错误,用处理函数的 error 返回,让 SDK 打包成 isError: true。

坑 5:指望模型遵守格式,而不是自己规范化。
在描述里写"标签请使用小写、不要带 #",模型大部分时候会遵守,偶尔不会。在业务层统一规范化,一劳永逸。

坑 6:数据文件写坏了。
直接覆盖写文件,写到一半程序被杀,书签文件就只剩半截,下次启动解析失败,所有数据丢了。先写临时文件再改名。这和 MCP 无关,但只要是要长期保存用户数据的工具,都值得这么做。

坑 7:什么能力都往 server 里加。
"既然能收藏,那也加个删除吧""既然能打标签,那也加个批量重命名吧"。每多一个工具,就多一份定义要进模型的上下文(第 23 篇会算这笔账),也多一种可能被误用的能力。一个 server 只做它最核心的那几件事,危险的操作想清楚了再加,或者干脆不加。

一句话总结

用官方 SDK 写 MCP server,就是把业务逻辑包装成工具、资源和提示词:参数的 JSON Schema 从 Go 结构体的标签自动生成,处理函数返回的 error 自动变成模型能看到的工具错误;业务和协议分层,stdout 只留给协议,用 Inspector 调试、用内存传输写测试,写完就能接进任何支持 MCP 的 Agent。

flowchart LR ST["store.go
业务逻辑"] --> SV["server.go
tools / resources / prompts"] SV --> MN["main.go
stdio / HTTP"] MN --> A1["mini-agent"] MN --> A2["其他 Agent"] style SV fill:#fff3e0,stroke:#e65100

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭