程云来 / 杭州
返回文章·Agent 与 Harness
·约 5 分钟

思想|MCP Tools,外部服务的工具怎么接进来?

从查询运行许可规则出发,追踪服务目录、模型工具定义、原名路由与调用反馈,分清发现能力和允许执行。


目录已经分开,工具还得一个个重写吗?

上一课把两位队友的文件放到了各自的目录。现在用户多问了一句:“运行main.py之前的许可规则,去文档服务查一下,再和本地策略对照。”

本地read_file可以读policy.txt,却查不了文档服务。你当然可以再写一个search_docs,把连接、参数、错误和反馈都接好。但如果另一套服务已经提供搜索能力,还需要把它的业务代码再写进Harness吗?

新的交接是:服务告诉Harness自己有哪些工具、每个工具接受什么输入;模型选择其中一个;Harness再按约定调用服务。双方需要共享工具契约,不需要把查询实现塞进同一个进程。

MCP(Model Context Protocol)提供这样的协议。本课只沿工具这条路径走:tools/list取得目录,tools/call发起调用。输入由工具的JSON Schema说明,结果可以带内容和错误标记,具体报文见MCP工具规范。

连上服务,为什么模型还不知道能用什么?

先有连接,才有后续发现。MCP生命周期规范要求初始化与能力协商;本课的真实客户端会确认服务声明了tools能力,再进入工作循环。

但连接成功只说明这条会话可以通信。模型请求里如果仍只有read_file,它不会凭空知道search.docs接受query。Harness还要读取目录,检查支持范围,再把工具定义加入下一次请求。

connectedMODEL REQUESTBUILT-IN TOOLSTOOLS / INPUTMODEL RESPONSESTDIO / JSON-RPCTOOLS / LISTTOOLS / CALLDOCS SERVERHarness模型MCP 服务
服务已连接,工具还没接入模型

宿主启动自己登记的docs服务,SDK完成初始化与tools能力核对。模型目前仍只看见原来的本地工具;连接成功没有自动扩大工具池。

允许查询
  1. 服务已连接,工具还没接入模型:宿主启动自己登记的docs服务,SDK完成初始化与tools能力核对。模型目前仍只看见原来的本地工具;连接成功没有自动扩大工具池。
  2. Harness 请求服务目录:通过tools/list读取工具元数据。若目录分页,就继续取下一页;这里只获取名称、说明与输入schema,不执行查询。
  3. 目录回来,先验证契约:服务提供search.docs和必填字符串query。Harness完整读取并检查支持范围;目录里写的readOnlyHint不能代替宿主许可。
  4. 同一快照交出定义与路由:模型看见mcp__docs__search_docs;Harness同时保存它到docs服务原名search.docs的路由。后续模型请求使用新定义,系统说明也按实际能力重算。
  5. 模型提出调用,仍由 Harness 执行:模型回复工具请求,带call_2和query参数。它没有直接连到服务;Harness先从当前工具快照查路由,再验证输入。
  6. 宿主允许,再调用原工具名:本次许可通过,Harness向docs发送tools/call,工具名仍用search.docs。MCP消息自己的JSON-RPC ID由SDK管理。
  7. 服务返回文本,Harness 转为反馈:服务返回运行许可规则。Harness检查文本和isError,转换成原循环的tool_result,继续关联模型提出的call_2。
  8. 把本次反馈交给下一次判断:完整工具请求与反馈已经进入历史。下一次模型调用读到实际查询结果,才能继续读本地策略或回答用户。
拒绝查询
  1. 服务已连接,工具还没接入模型:宿主启动自己登记的docs服务,SDK完成初始化与tools能力核对。模型目前仍只看见原来的本地工具;连接成功没有自动扩大工具池。
  2. Harness 请求服务目录:通过tools/list读取工具元数据。若目录分页,就继续取下一页;这里只获取名称、说明与输入schema,不执行查询。
  3. 目录回来,先验证契约:服务提供search.docs和必填字符串query。Harness完整读取并检查支持范围;目录里写的readOnlyHint不能代替宿主许可。
  4. 同一快照交出定义与路由:模型看见mcp__docs__search_docs;Harness同时保存它到docs服务原名search.docs的路由。后续模型请求使用新定义,系统说明也按实际能力重算。
  5. 模型提出调用,仍由 Harness 执行:模型回复工具请求,带call_2和query参数。它没有直接连到服务;Harness先从当前工具快照查路由,再验证输入。
  6. 宿主拒绝,停在本地:即使服务声明只读,本次拒绝仍然有效。Harness生成关联的未执行反馈,没有向服务发送tools/call。
  7. 拒绝也有对应的反馈:这条tool_result仍关联call_2,并标记is_error。拒绝不会让工具请求悬空,也不会被包装成查询成功。
  8. 把本次反馈交给下一次判断:下一次模型调用读到本次未执行的原因,如实报告缺少证据。重新调用模型不会悄悄重发外部工具。

先走允许路径,再切到拒绝路径。前半段完全一样:连接、发现和向模型开放能力都能完成。区别出现在实际调用之前,宿主是否允许这次外部访问。

目录里的description和readOnlyHint来自服务。它们可以帮助理解用途,不能自行批准一次操作。本课会故意在服务声明只读时拒绝查询,观察有没有真正发出tools/call。

模型看到的名字,未必就是服务收到的名字

服务提供的原名是search.docs。接到本地工具池后,我们给模型的名称是mcp__docs__search_docs:它既指出来源,又符合当前模型工具名称的约束。

这个前缀是本册的宿主命名策略,并不是MCP要求所有客户端必须使用的名称。执行时,Harness要保留一条明确路由,把外显名称找回原服务和原名。

下面只运行一次名字查找,不发送MCP请求:

PyPython · 浏览器本地运行
未运行
routes = {"mcp__docs__search_docs": ("docs", "search.docs")}
requested = "mcp__docs__search_docs"
server, remote_name = routes[requested]
print(server, remote_name)
输出

点击行号设置断点,再点“调试”;也可以直接运行。

代码只读;执行发生在浏览器中,不会发送到博客服务器。

输出是docs和search.docs。模型选的是本地外显名称,服务收到的仍是自己的原名。

这里有个容易踩到的细节:search.docs和search_docs本来是两个不同名称,把点替换成下划线以后却会撞名。仅仅“清理特殊字符”并不能保证唯一。本册在生成名称后再次查重,发生冲突就拒绝整次组装,不悄悄覆盖其中一个工具。

什么时候算真正拥有了这项能力?

模型可见定义和执行路由必须一起发布。只发定义不建路由,模型会选择一个无法执行的工具;只建路由不发定义,模型没有得到这项能力的契约。

发布以后,还得按每次请求固定可用的快照。假如模型同一批回复里同时提出“发现目录”和“调用刚发现的工具”,第二项尚未出现在它刚才收到的定义里。即使宿主已经完成第一项,也不能顺势执行第二项;新能力从下一次模型请求开始生效。

系统说明同样要跟着真实工具变化。本册已有的能力缓存会把工具目录作为输入:新增工具后重组说明,目录未变时继续复用。工具列表、说明和实际路由不能各自保留一个不同年代的版本。

有一条反馈,不等于查询成功了

模型提出call_2,Harness必须把本次结果对应回call_2。MCP传输层还有自己的JSON-RPC请求ID,由SDK管理;两种ID分别负责各自的交接,不能直接混用。

服务返回isError时,Harness把它映射为原循环的is_error。宿主拒绝、参数错误和传输失败也需要关联反馈,否则对话里会留下一项没有结果的工具请求。错误反馈让模型知道缺少什么证据,不会把“调用结束”包装成“拿到了规则”。

还有一个转折:客户端等超时以后,服务可能已经开始工作。实践篇会让本地服务记录已接收请求,再延迟返回;客户端先得到错误,服务随后仍然完成。此时重新调用模型可以读取错误,但不能因此自动重发外部工具,尤其不能据超时断定副作用没有发生。

本课用官方Python SDK 1.26.0跑真实stdio通信,模型响应仍是固定替身。我们会查询本地文档服务,再读本地策略文件;协议确实走过另一进程,业务系统与在线模型没有接入。三段适配函数的每一步,都能在实践篇的代码与图里一起观察。

参考学习方向为Learn Claude Code s19,规范与SDK以上述固定版本为准,来源于2026-10-20核对。名字映射、支持的schema子集和宿主许可规则是本册的教学设计。