练习 22:MCP 客户端——接入别人的工具

到上一章为止,注册表里的每一个工具都是我们自己写的 Go 代码:read_file 是我们写的,bash 是我们写的,连 workflow 也是我们写的。但工具生态的另一 半不长在你的代码库里——别人已经把"查数据库""操作浏览器""读某个 SaaS 的 工单"写成了现成的服务,你不该为了用上它们而重写一遍。MCP(Model Context Protocol)就是干这个的:一个服务器进程把它的工具声明成"名字 + 一句话 描述 + 参数 schema",任何客户端用同一套握手和调用方法就能接上。这一章 给 harness 写一个最小的 MCP 客户端:读 mcp.json 里配置的服务器,起 子进程,在标准输入输出上说 JSON-RPC,把对方声明的工具接进我们练习 6 就定下的那张注册表——注册表本身一个字不用改。

敲进去

在练习 21 的代码上继续写。先是配置:哪些服务器、怎么启动。

// ---- MCP 层:接入别人的工具 ----

// mcpConfigFile 是工作目录下的服务器清单,格式跟 Claude Code 的 mcp.json
// 完全一致——和练习 16 认 Claude Code 的 SKILL.md 是同一个理由:兼容通行
// 格式,别人写好的配置抄过来就能用。
const mcpConfigFile = "mcp.json"

type mcpServerConfig struct {
	Command string            `json:"command"`
	Args    []string          `json:"args"`
	Env     map[string]string `json:"env"`
}

type mcpConfig struct {
	Servers map[string]mcpServerConfig `json:"mcpServers"`
}

// loadMCPConfig 读工作目录下的 mcp.json。文件不存在是正常状态——没配
// 外部服务器的项目跟上一章的行为完全一样,不是错误。
func loadMCPConfig() mcpConfig {
	cfg := mcpConfig{Servers: map[string]mcpServerConfig{}}
	data, err := os.ReadFile(mcpConfigFile)
	if err != nil {
		return cfg
	}
	if err := json.Unmarshal(data, &cfg); err != nil {
		fmt.Fprintf(os.Stderr, "警告: %s 不是合法 JSON,忽略: %v\n", mcpConfigFile, err)
	}
	return cfg
}

然后是协议。MCP 跑在 JSON-RPC 2.0 上,一帧就是一个 JSON 对象:

// mcpMessage 是 JSON-RPC 2.0 的一帧。请求、通知、响应三种形状共用这一个
// 结构:有 Method 有 ID 是请求,有 Method 没 ID 是通知,没 Method 是响应
// ——线上的字节本来就是这么区分的,不用三个类型。
type mcpMessage struct {
	JSONRPC string          `json:"jsonrpc"`
	ID      int             `json:"id,omitempty"`
	Method  string          `json:"method,omitempty"`
	Params  any             `json:"params,omitempty"`
	Result  json.RawMessage `json:"result,omitempty"`
	Error   *struct {
		Code    int    `json:"code"`
		Message string `json:"message"`
	} `json:"error,omitempty"`
}

// mcpClient 管着一个外部服务器子进程:往它的标准输入写请求,从它的标准
// 输出读响应。mu 保证同一时刻只有一个在途请求——练习 20 教过的规矩:
// 并发安全由持有共享状态的这一层自己负责,不指望调用方(比如几个并发的
// 子 agent 同时用同一个外部工具)替它小心。
type mcpClient struct {
	name   string
	stdin  io.WriteCloser
	dec    *json.Decoder
	mu     sync.Mutex
	nextID int
}

// startMCPServer 启动配置里的一条命令,接管它的标准输入输出。子进程的
// 标准错误直通我们的终端——那是服务器的日志通道,不是协议通道,MCP 的
// 协议规定 stdout 只许出现 JSON-RPC 帧,日志必须走 stderr。
func startMCPServer(name string, cfg mcpServerConfig) (*mcpClient, error) {
	cmd := exec.Command(cfg.Command, cfg.Args...)
	cmd.Env = os.Environ()
	for k, v := range cfg.Env {
		cmd.Env = append(cmd.Env, k+"="+v)
	}
	cmd.Stderr = os.Stderr
	stdin, err := cmd.StdinPipe()
	if err != nil {
		return nil, err
	}
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return nil, err
	}
	if err := cmd.Start(); err != nil {
		return nil, err
	}
	return &mcpClient{
		name:  name,
		stdin: stdin,
		dec:   json.NewDecoder(bufio.NewReader(stdout)),
	}, nil
}

// call 发一个请求,等它的响应。一次只有一个在途请求,所以"等"就是顺着
// 流往下读:读到的帧如果带 Method,那是服务器发来的通知,这本书不处理,
// 跳过;直到读到 ID 对得上的响应为止。
func (c *mcpClient) call(method string, params any, result any) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.nextID++
	id := c.nextID
	b, err := json.Marshal(mcpMessage{JSONRPC: "2.0", ID: id, Method: method, Params: params})
	if err != nil {
		return err
	}
	if _, err := c.stdin.Write(append(b, '\n')); err != nil {
		return fmt.Errorf("写入 MCP 服务 %q 失败(进程退出了?): %w", c.name, err)
	}
	for {
		var m mcpMessage
		if err := c.dec.Decode(&m); err != nil {
			return fmt.Errorf("读取 MCP 服务 %q 失败: %w", c.name, err)
		}
		if m.Method != "" || m.ID != id {
			continue // 通知,或不属于这次请求的帧——跳过,接着读
		}
		if m.Error != nil {
			return fmt.Errorf("MCP 错误 %d: %s", m.Error.Code, m.Error.Message)
		}
		if result != nil && len(m.Result) > 0 {
			return json.Unmarshal(m.Result, result)
		}
		return nil
	}
}

// notify 发一个通知——没有 ID 的请求,服务器不会回复,发完就走。
func (c *mcpClient) notify(method string) error {
	b, err := json.Marshal(mcpMessage{JSONRPC: "2.0", Method: method})
	if err != nil {
		return err
	}
	_, err = c.stdin.Write(append(b, '\n'))
	return err
}

有了收发,握手和列工具就是三次普通调用:

// initialize 是 MCP 的三步握手:客户端报上版本和身份,服务器答复它的;
// 然后客户端发一条 initialized 通知表示"我这边好了"。版本我们报
// 2024-11-05——这本书只说 stdio 这一种传输方式,报更新的版本反而名不
// 副实;服务器答复的版本如果不一样,记下来继续用,不较真。
func (c *mcpClient) initialize() error {
	var res struct {
		ProtocolVersion string `json:"protocolVersion"`
		ServerInfo      struct {
			Name    string `json:"name"`
			Version string `json:"version"`
		} `json:"serverInfo"`
	}
	params := map[string]any{
		"protocolVersion": "2024-11-05",
		"capabilities":    map[string]any{},
		"clientInfo":      map[string]any{"name": "learnharness", "version": "0.1"},
	}
	if err := c.call("initialize", params, &res); err != nil {
		return err
	}
	if err := c.notify("notifications/initialized"); err != nil {
		return err
	}
	fmt.Fprintf(os.Stderr, "[MCP 服务 %q 握手完成:%s v%s,协议 %s]\n",
		c.name, res.ServerInfo.Name, res.ServerInfo.Version, res.ProtocolVersion)
	return nil
}

// mcpRemoteTool 是服务器在 tools/list 里声明的一个工具:名字、一句话
// 描述、参数 schema——和我们的 toolSpec 一一对应,这不是巧合,MCP 的
// 工具声明和各家模型协议的 function calling 本来就是同一种东西。
type mcpRemoteTool struct {
	Name        string         `json:"name"`
	Description string         `json:"description"`
	InputSchema map[string]any `json:"inputSchema"`
}

func (c *mcpClient) listTools() ([]mcpRemoteTool, error) {
	var res struct {
		Tools []mcpRemoteTool `json:"tools"`
	}
	if err := c.call("tools/list", map[string]any{}, &res); err != nil {
		return nil, err
	}
	return res.Tools, nil
}

最关键的一段是适配器——把远端工具包进练习 6 的 tool 接口:

// mcpTool 把一个远端工具包进练习 6 的 tool 接口。注册表分不出它和
// read_file 有什么区别——这正是这一章的全部要点:接入别人的工具,
// 改动的只有"多一种来源",没有第二套分发机制。
type mcpTool struct {
	client *mcpClient
	remote mcpRemoteTool
}

// definition 的两个细节:名字带上 mcp__<服务名>__ 前缀,既避免和内置
// 工具撞名,也让日志里一眼看出这个调用出了进程(服务名进了工具名,所以
// mcp.json 里的服务名只能用字母、数字、下划线和连字符);Parameters 直接
// 透传服务器声明的 schema——参数长什么样是工具作者说了算,我们不翻译。
func (t mcpTool) definition() toolSpec {
	return toolSpec{
		Name:        "mcp__" + t.client.name + "__" + t.remote.Name,
		Description: "[来自 MCP 服务 " + t.client.name + "] " + t.remote.Description,
		Parameters:  t.remote.InputSchema,
	}
}

func (t mcpTool) execute(args string) string {
	var arguments map[string]any
	if err := json.Unmarshal([]byte(args), &arguments); err != nil {
		return "错误: 参数不是合法 JSON: " + err.Error()
	}
	var res struct {
		Content []struct {
			Type string `json:"type"`
			Text string `json:"text"`
		} `json:"content"`
		IsError bool `json:"isError"`
	}
	err := t.client.call("tools/call",
		map[string]any{"name": t.remote.Name, "arguments": arguments}, &res)
	if err != nil {
		// 这一层的错误是"调用没送到工具手上"——进程死了、协议错了。
		return "错误: " + err.Error()
	}
	var b strings.Builder
	for i, c := range res.Content {
		if i > 0 {
			b.WriteString("\n")
		}
		if c.Type == "text" {
			b.WriteString(c.Text)
		} else {
			fmt.Fprintf(&b, "[未处理的内容类型 %q]", c.Type)
		}
	}
	if res.IsError {
		// 这一层的错误是"工具收到了调用,干活失败了"——和上面那种要分开:
		// isError 是结果的一部分,进程还活着,下一次调用照常。
		return "错误: 工具执行失败: " + b.String()
	}
	return b.String()
}

把整条链串起来,一个服务器连不上不影响别的:

// connectMCPServers 把 mcp.json 里每个服务器的工具接进 toolList。一个
// 服务器连不上只警告、跳过——外部依赖挂了不该拖垮整个 harness,这和
// 练习 16"一份写坏的 SKILL.md 不中断发现"是同一条纪律。服务名排序遍历,
// 保证工具列表的顺序每次启动都一样。
func connectMCPServers(toolList []tool) []tool {
	cfg := loadMCPConfig()
	names := make([]string, 0, len(cfg.Servers))
	for name := range cfg.Servers {
		names = append(names, name)
	}
	sort.Strings(names)
	for _, name := range names {
		client, err := startMCPServer(name, cfg.Servers[name])
		if err != nil {
			fmt.Fprintf(os.Stderr, "警告: MCP 服务 %q 启动失败,跳过: %v\n", name, err)
			continue
		}
		if err := client.initialize(); err != nil {
			fmt.Fprintf(os.Stderr, "警告: MCP 服务 %q 握手失败,跳过: %v\n", name, err)
			continue
		}
		remotes, err := client.listTools()
		if err != nil {
			fmt.Fprintf(os.Stderr, "警告: MCP 服务 %q 列工具失败,跳过: %v\n", name, err)
			continue
		}
		schemaCost := 0
		for _, rt := range remotes {
			toolList = append(toolList, mcpTool{client: client, remote: rt})
			raw, _ := json.Marshal(rt.InputSchema)
			schemaCost += estimateText(rt.Name + rt.Description + string(raw))
		}
		// 又一笔要当场算清的账(练习 17 的老规矩):这些声明进的是 tools
		// 数组,跟 system prompt 一样每一轮都要重发一遍。
		fmt.Fprintf(os.Stderr, "[MCP 服务 %q:接入 %d 个工具,声明约 %d tokens,随 tools 数组每轮都算钱]\n",
			name, len(remotes), schemaCost)
	}
	return toolList
}

main() 里接线,加在 skill 之后、subAgent 之前:

// MCP 工具在这里接入——排在 subAgent 之前,所以子 agent 的工具集里
// 也有它们:外部工具没有防递归的顾虑,分身也用得上"别人的工具"。
toolList = connectMCPServers(toolList)

最后,给客户端找个陪练。新建 timeserver/ 子目录,写一个最小的 MCP 服务器——两个工具都是模型自己搞不定的事:它不知道现在几点(训练 数据有截止日),也算不稳两个日期隔几天。完整文件就这一份,敲进 timeserver/main.go

// timeserver 是一个最小的 MCP 服务器,给练习 22 的客户端当陪练:
// initialize / tools/list / tools/call 三个方法,两个工具。真实项目里
// 服务器一般用官方 SDK 写,这里手写协议是为了让你看清线上到底流过什么
// ——它和客户端说的是同一种话,一行一个 JSON 对象。
package main

import (
	"bufio"
	"encoding/json"
	"fmt"
	"os"
	"time"
)

// message 和客户端侧是同一个形状——JSON-RPC 的帧不分客户端服务器。
type message struct {
	JSONRPC string          `json:"jsonrpc"`
	ID      int             `json:"id,omitempty"`
	Method  string          `json:"method,omitempty"`
	Params  json.RawMessage `json:"params,omitempty"`
	Result  any             `json:"result,omitempty"`
	Error   *rpcError       `json:"error,omitempty"`
}

type rpcError struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

func main() {
	dec := json.NewDecoder(bufio.NewReader(os.Stdin))
	enc := json.NewEncoder(os.Stdout) // Encode 自带换行,正好一行一帧
	reply := func(id int, result any) {
		_ = enc.Encode(message{JSONRPC: "2.0", ID: id, Result: result})
	}
	replyErr := func(id, code int, msg string) {
		_ = enc.Encode(message{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}})
	}

	for {
		var m message
		if err := dec.Decode(&m); err != nil {
			return // 标准输入关了(客户端退出),我们也退出
		}
		switch m.Method {
		case "initialize":
			reply(m.ID, map[string]any{
				"protocolVersion": "2024-11-05",
				"capabilities":    map[string]any{"tools": map[string]any{}},
				"serverInfo":      map[string]any{"name": "timeserver", "version": "0.1"},
			})
		case "notifications/initialized":
			// 通知没有 ID,不需要回复
		case "tools/list":
			reply(m.ID, map[string]any{"tools": []map[string]any{
				{
					"name":        "current_time",
					"description": "返回服务器本地的当前日期、时间和星期几。",
					"inputSchema": map[string]any{"type": "object", "properties": map[string]any{}},
				},
				{
					"name":        "days_between",
					"description": "计算两个日期之间隔多少天(格式 2026-01-02,结束早于开始时为负数)。",
					"inputSchema": map[string]any{
						"type": "object",
						"properties": map[string]any{
							"start": map[string]any{"type": "string", "description": "开始日期,YYYY-MM-DD"},
							"end":   map[string]any{"type": "string", "description": "结束日期,YYYY-MM-DD"},
						},
						"required": []string{"start", "end"},
					},
				},
			}})
		case "tools/call":
			var p struct {
				Name      string            `json:"name"`
				Arguments map[string]string `json:"arguments"`
			}
			if err := json.Unmarshal(m.Params, &p); err != nil {
				replyErr(m.ID, -32602, "参数不是合法 JSON: "+err.Error())
				continue
			}
			text, err := runTool(p.Name, p.Arguments)
			if err != nil {
				// 工具收到了调用但干活失败:isError 是结果的一部分,
				// 不是协议错误——客户端那侧对这两种要分开处理。
				reply(m.ID, map[string]any{
					"content": []map[string]any{{"type": "text", "text": err.Error()}},
					"isError": true,
				})
				continue
			}
			reply(m.ID, map[string]any{
				"content": []map[string]any{{"type": "text", "text": text}},
			})
		default:
			if m.ID != 0 {
				replyErr(m.ID, -32601, "没有这个方法: "+m.Method)
			}
		}
	}
}

var weekdays = [...]string{"星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"}

func runTool(name string, args map[string]string) (string, error) {
	switch name {
	case "current_time":
		now := time.Now()
		return fmt.Sprintf("%s %s", now.Format("2006-01-02 15:04:05"), weekdays[now.Weekday()]), nil
	case "days_between":
		start, err := time.Parse("2006-01-02", args["start"])
		if err != nil {
			return "", fmt.Errorf("start 不是合法日期(要 YYYY-MM-DD): %v", err)
		}
		end, err := time.Parse("2006-01-02", args["end"])
		if err != nil {
			return "", fmt.Errorf("end 不是合法日期(要 YYYY-MM-DD): %v", err)
		}
		days := int(end.Sub(start).Hours() / 24)
		return fmt.Sprintf("%d 天", days), nil
	default:
		return "", fmt.Errorf("没有这个工具: %s", name)
	}
}

跑起来

go build -o ex22 .
go build -o timeserver ./timeserver

实验一:接上自己写的服务器。 写一份 mcp.json

cat > mcp.json << 'EOF'
{
  "mcpServers": {
    "time": { "command": "./timeserver" }
  }
}
EOF

问一个模型自己答不了的问题:

./ex22 "今天是几月几号、星期几?距离 2027-01-01 还有多少天?"

实验二:接上别人写的服务器。 官方的 filesystem 服务器是用 TypeScript 写的、发布在 npm 上——作者不认识我们,我们没读过它的源码。 造一个它专属的目录,把它加进配置(需要装 Node.js,brew install node):

mkdir -p docs && printf '# 项目手册\n\n发布窗口每周四 20:00。\n' > docs/handbook.md
cat > mcp.json << EOF
{
  "mcpServers": {
    "time": { "command": "./timeserver" },
    "fs": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "$PWD/docs"]
    }
  }
}
EOF
./ex22 "名叫 fs 的 MCP 服务管着一个目录。请用 fs 的工具查一下它允许访问哪些目录、里面有什么文件,把里面的手册读出来,告诉我下一个发布窗口是星期几、几点,以及从今天算起最近的一个发布日是几月几号。"

你应该看到什么

实验一:模型答不了的问题,工具替它答

[MCP 服务 "time" 握手完成:timeserver v0.1,协议 2024-11-05]
[MCP 服务 "time":接入 2 个工具,声明约 173 tokens,随 tools 数组每轮都算钱]
[round 1 输入 2012 tokens,命中缓存 1280]
[round 1] mcp__time__current_time({})
[round 1] mcp__time__days_between({"start": "2026-01-02", "end": "2026-01-02"})
[round 2 输入 2178 tokens,命中缓存 2048]
[round 2] mcp__time__days_between({"end": "2027-01-01", "start": "2026-08-07"})

今天是 **2026年8月7日,星期五**。
距离 **2027-01-01** 还有 **147 天**。

[共 3 轮 · 最后一轮输入 2293 tokens(命中缓存 2176)· finish_reason=stop]

答案是对的,但看一眼 round 1:DeepSeek 把两个调用同轮发了出来,其中 days_between 的参数是编的——起止日期都填了 2026-01-02,这个日期是从 工具描述的格式示例里抄的,因为它此刻根本不知道今天是几号。这个调用如实 返回了"0 天",模型在 round 2 拿着 current_time 给的真实日期重新调了 一次,才有了正确答案。第二个调用明明依赖第一个的结果,模型不一定等—— 它先猜一把,错了再改。工具这边不需要为此做任何事,把真实结果交回去, 模型自己会把账对上。

实验二:别人写的服务器,接上就能用

Secure MCP Filesystem Server running on stdio
[MCP 服务 "fs" 握手完成:secure-filesystem-server v0.2.0,协议 2024-11-05]
Client does not support MCP Roots, using allowed directories set from server args: [
  '/…/ex22-test/docs'
]
[MCP 服务 "fs":接入 14 个工具,声明约 1838 tokens,随 tools 数组每轮都算钱]
[MCP 服务 "time" 握手完成:timeserver v0.1,协议 2024-11-05]
[MCP 服务 "time":接入 2 个工具,声明约 173 tokens,随 tools 数组每轮都算钱]
[round 1 输入 4184 tokens,命中缓存 1280]
[round 1] mcp__fs__list_allowed_directories({})
[round 2] mcp__fs__directory_tree({"path": "/…/ex22-test/docs"})
[round 2] mcp__fs__list_directory({"path": "/…/ex22-test/docs"})
[round 3] mcp__fs__read_text_file({"path": "/…/ex22-test/docs/handbook.md"})
[round 4] mcp__time__current_time({})

**手册内容**
- 发布窗口:**每周四 20:00**
**推算**
- 今天是 2026-08-07(星期五)……
- 从今天算起最近的一个发布日是 **2026 年 8 月 13 日(星期四)**

[共 5 轮 · 最后一轮输入 4895 tokens(命中缓存 4864)· finish_reason=stop]

三件事值得对着日志看。第一,头两行不是我们打印的——那是 filesystem 服务器自己的启动日志,从它的标准错误直通了我们的终端,协议帧一条都没 混进来,stdout 归协议、stderr 归日志这条线是真的。第二,这个我们没读过 源码的服务器带来了 14 个工具,其中 read_filewrite_fileedit_file 三个跟我们的内置工具同名——靠 mcp__fs__ 前缀相安无事, 模型按任务点名用了 fs 的版本。第三,最后一轮模型把两个来源的工具串成了 一条推理链:fs 给的手册内容(每周四)加 time 给的今天日期(周五), 推出下周四是 8 月 13 日——两个互不相识的服务器,在同一张注册表里协作。

本机 Ollamaqwen3:4b-instruct,接 timeserver 跑实验一)这次表现 比 DeepSeek 还稳:round 1 只调 current_time,等真实日期回来了 round 2 才调 days_between,参数一次写对,没有猜。三轮拿到同样的正确答案, 全程 17 秒。

发生了什么

MCP 的工具声明和 function calling 的工具声明,是同一种东西。 对比 mcpRemoteTool 和练习 5 就有的 toolSpec:名字、一句话描述、参数 schema,三个字段一一对应。所以 mcpTool.definition() 干的事几乎只是 转个手——名字加前缀,描述加来源标记,schema 原样透传。这不是我们运气好, 是 MCP 有意为之:它把"给模型声明一个工具"这件各家模型协议早就长成同一个 形状的事,从模型协议里搬出来,变成工具作者和 harness 作者之间的合同。 合同的一边是别人写的服务器,另一边就是你练习 6 写的那张注册表——所以 接入"别人的工具"没有引入第二套分发机制,registry.execute 一个字没改, mcpTool 和 readFileTool 在它眼里是同一个接口的两个实现。

进程边界天然是一条纪律边界。 内置工具跟 harness 活在同一个进程里, 出了 bug 大家一起死;MCP 工具在自己的进程里跑,我们跟它只有三条管道的 关系。这一章的错误处理全部围绕这条边界展开:启动失败、握手失败只警告 跳过(外部依赖挂了不拖垮 harness);isError 和 RPC 错误分成两层(前者 是"工具干活失败",结果的一部分,进程还活着;后者是"调用没送到",多半 是协议或进程出了问题);stdout 只许协议帧、日志必须走 stderr(实验二里 那两行英文启动日志就是这条规矩在工作)。你不能审别人服务器的代码,但 你能守住管道的规矩。

名字前缀是最便宜的命名空间。 实验二里内置 read_file 和 fs 服务器的 read_file 同场出现,没有任何冲突处理代码——因为后者在模型眼里叫 mcp__fs__read_file,撞名在构造名字的那一刻就被排除了。这个约定直接 抄自 Claude Code(octo 也是这么做的):双下划线分隔是因为模型协议对 工具名有格式限制,点号斜杠都不许出现。顺带的好处是日志可读性:看到 mcp__ 前缀就知道这个调用出了进程。

工具多了,账单先出事,然后是判断力。 实验一只接 timeserver,首轮 2012 tokens;实验二加上 fs 服务器,首轮 4184——多出来的两千多就是那 14 个工具的声明,而且跟 system prompt 一样每轮重发。这次任务用上了 fs, 这笔钱花得值;可要是任务只问时间,这两千 tokens 就是纯浪费,接的服务器 越多浪费越狠。更隐蔽的代价是判断力:几十个工具同时摆在面前,模型选错 工具的概率跟着涨。octo 对这个问题的解法叫 tool search:工具一多,就把 MCP 工具的完整 schema 从 tools 数组里撤下来,换成两个固定的桥工具 (一个按名字取 schema,一个按名字转发调用),工具的名字加一句话描述 则常驻 system prompt。你应该觉得眼熟——这就是练习 17 的 skill 清单: L1(名字 + 一句话,便宜,常驻)和 L2(正文/schema,贵,按需加载)。 octo 在这上面还踩过一个坑:第一版把名字也一起藏了,结果用户提到某个 外部系统时,模型看不见对应工具的存在,经常直接断言"没有这个工具"—— schema 可以藏,名字不能藏,模型不知道的工具等于不存在。这一章不实现 tool search,账已经算给你看了,桥留在加分练习里。

常见问题

  • 服务器进程死了会怎样:下一次调用在写入或读取时报错,作为工具 结果回给模型——harness 不倒,模型知道这个工具废了,会换路子或者如实 告诉你。不做自动重连:重连后服务器的状态跟死前对不上,假装无事发生 比明确报错更危险。
  • 服务器不回话会怎样call 里的 dec.Decode 会一直等下去——这一章 没有做调用超时。octo 给每次调用兜了默认超时,这本书把它留成加分练习: 你在练习 7 已经给 bash 做过一模一样的事。
  • MCP 工具不经过权限系统,合理吗:不合理,这是这一章故意留的洞。 练习 9 的三档权限只拦 bash——MCP 工具是外部代码,能做的事不比 bash 少,现在却全部直接放行。实验二里 filesystem 服务器自带"只许访问指定 目录"的边界,但那是服务器作者的善意,不是你 harness 的闸门。下一章 的沙箱管的就是"执行边界"这件事,加分练习里也有一个更快的止血方案。
  • 能不能把 harness 自己的工具也变成 MCP 服务器给别人用:能,而且 这正是 MCP 生态的另一半玩法——你写的 timeserver 就是个完整示例,任何 MCP 客户端(Claude Code、octo、别人的 harness)改一行配置就能接上它。 声明格式是通用合同,方向反过来也成立。

加分练习

  1. call 加超时:用练习 20 的 goroutine + channel 工具箱把 dec.Decode 包一层,超时就报错返回。想清楚超时之后那个迟到的响应 怎么办——它还在管道里,下一次 call 会先读到它,ID 对不上号的防御 分支这时候就不是"不该发生"了。
  2. mcp__ 前缀的工具接上练习 9 的权限系统:默认 ask 档,每次调用 停下来问人。跑一遍实验二感受一下"每一步都要批"有多烦,再想想哪些 工具值得进 allow 名单——read 类放行、write 类必问是一个起点。
  3. 实现最小版 tool search:接入的工具超过某个数量时,tools 数组里不再 放 MCP 工具的完整声明,换成两个桥工具 mcp_describe(按名字返回 schema)和 mcp_call(按名字转发调用),工具名字加一句话描述写进 system prompt。用实验二的配置对比首轮 token 数。
  4. mcp.json 接一个你真正会用的第三方服务器(GitHub、数据库、 浏览器自动化都有现成的),跑一个真实任务。留意它声明了几个工具、 多少 tokens——接得越多,加分练习 3 越值得做。