Furfly Code 是个终端 AI 编程助手,Python 实现,Claude Code 风格。它的工具集一直是闭合的 6 个内置工具——读/写/改文件、bash、glob、grep。Agent 的能力边界等于源码里硬编码的那张清单,要接外部世界(GitHub、Linear、数据库、知识库、自定义能力)只能改源码加内置工具,扩展成本高、还和上游耦合。
MCP(Model Context Protocol)要凿开的就是这堵墙。它是个客户端/服务器协议:需要被调用的能力(比如 GitHub 搜索、数据库查询)封装成一个个外部 Server,Furfly 作为客户端按统一协议连上去,Server 把自己暴露的工具清单交给客户端,客户端把这些工具注册进 Agent 的工具集。Agent 调用这些远端工具时和调用内置工具走同一条路径,无感一致。扩展模型从「改源码加内置工具」变成「配置文件声明一个 Server」。
这事听起来只是加个客户端,底下却是一串绕不开的决策。最先撞上的是这个张力:一个叫 search 的远端工具,背后可能只读检索,也可能顺手改库、发请求、调外部 API——从工具名你判断不了,缺省权限该定什么?顺着这条线还有:连接生命周期绑在哪个 event loop 上?单 Server 挂了会不会拖死启动?这篇复盘这次接入的后端设计——协议怎么落到代码、权限怎么零改动接入、故障怎么隔离,以及顺带做的配置两层合并。
基于 Furfly Code(https://github.com/ILoveFurina/Furfly-Code)的实战。写于 2026-07-05。
一、破口:从改源码到配 Server
「闭合」不是形容,是代码里写死的。new_default_registry() 注册 6 个内置工具,Registry.register 重名即抛 ValueError,Registry.definitions() 导出的条数恒为 6。Agent 每轮从 Registry 取定义发往 LLM、按名执行。要加一个能力,得写一个 BaseTool 子类、塞进 new_default_registry、跑测试、发版——每个新能力都得进主仓库。
MCP 把这条路径短路了。外部 Server 按统一协议暴露工具,客户端接进来注册进同一个 Registry,Agent 调用 mcp__github__search 和调用 read_file 走同一条 Registry.execute 路径。扩展模型从「改源码加内置工具」变成「配置文件声明一个 Server」。
接入落在 src/furflycode/mcp/ 一个包里三个模块:client.py(JSON-RPC + 双传输)、tool.py(McpRemoteTool 适配器)、loader.py(并发连接 + 注册编排)。下面按协议、权限、故障、配置四条线展开。
二、协议落到代码——三步握手与双传输
要把 MCP 接进来,得先把它是什么讲清楚。按 2025-03-26 版规范,客户端生命周期是三步:
initialize请求——客户端发协议版本、自己支持的可选能力(capabilities,比如是否提供文件根目录、是否支持采样,Furfly 这里给空表示不声明任何可选能力)、客户端实现信息(clientInfo,名字+版本号,供服务器识别);服务器回它自己的能力和实现信息。这一步 MUST 是第一个交互,发完 initialize 之前什么正经请求都不能发。notifications/initialized通知——客户端收到 initialize 响应后,回一个无 id 的通知,表示「就绪,可以开始正常操作」。tools/list→tools/call——进入操作阶段,列工具清单、按需调用。
规范把这三步画成一张时序图,原文:
The client MUST initiate this phase by sending an
initializerequest containing: Protocol version supported, Client capabilities, Client implementation information. ... After successful initialization, the client MUST send aninitializednotification to indicate it is ready to begin normal operations.
Furfly 把这三步握手落在 McpClient.connect() 里(src/furflycode/mcp/client.py:337-361):
async def connect(self, timeout: float = DEFAULT_CONNECT_TIMEOUT) -> None:
await asyncio.wait_for(self._transport.start(), timeout)
# 1. initialize 握手
await asyncio.wait_for(
self._transport.request(
"initialize",
{
"protocolVersion": PROTOCOL_VERSION, # "2025-03-26"
"capabilities": {},
"clientInfo": _CLIENT_INFO,
},
),
timeout,
)
# 2. notifications/initialized(通知,无响应)
await self._transport.notify("notifications/initialized", {})
# 3. tools/list
list_result = await asyncio.wait_for(
self._transport.request("tools/list", {}), timeout
)
self._tools = self._parse_tools(list_result)
三步都在 asyncio.wait_for(timeout=10s) 里跑,超时即握手失败。PROTOCOL_VERSION = "2025-03-26" 对齐规范版本号。tools/list 返回的工具清单缓存进 self._tools,供后续适配器注册用。
2.1 JSON-RPC id 异步配对
三步握手底下是 JSON-RPC 2.0 的请求/响应配对。MCP 不做 batch(一消息一请求),每条带 id 的请求都要等对应 id 的响应。Furfly 的实现是 Transport 抽象基类(client.py:60-156)里一套 _next_id 自增 + dict[int, Future] 配对:
def _allocate_id(self) -> int:
self._next_id += 1
return self._next_id
def _dispatch(self, msg: dict[str, Any]) -> None:
"""按 id 派发响应到 Future;通知(无 id)v1 忽略。"""
if "id" not in msg:
return # 通知(server→client,无 id):v1 忽略 list_changed 等。
msg_id = msg["id"]
...
future = self._pending.pop(msg_id, None)
if future is None or future.done():
return
if "error" in msg:
future.set_exception(JsonRpcError(msg["error"]))
else:
future.set_result(msg.get("result", {}))
request() 分配 id、建 Future、塞 _pending、await self._send(payload)、await fut。响应来了由子类的 reader 调 _dispatch 按 id 派发。传输断开时 _fail_all 把所有 in-flight Future 设 TransportClosedError,避免请求永远挂着。
通知(notifications/initialized 这类无 id 消息)走 notify()——只发不等。_dispatch 对无 id 消息直接 return,v1 不处理 notifications/tools/list_changed 等 server→client 通知(这是 v1 的显式 scope 边界,§四会讲)。
2.2 stdio 与 Streamable HTTP 双传输
MCP 规范定义两种标准传输:stdio(本地子进程)和 Streamable HTTP(远程)。Transport 抽象只要求子类实现 _send / start / stop,配对逻辑完全复用。
stdio 传输(client.py:158-238):asyncio.create_subprocess_exec 起子进程,不经 shell;stdin 写行分隔 JSON,stdout 按行读响应。一个后台 _read_loop 任务异步派发:
async def _read_loop(self) -> None:
try:
while True:
line = await self._proc.stdout.readline()
if not line:
break # EOF:子进程关闭 stdout
...
msg = json.loads(text)
if isinstance(msg, dict):
self._dispatch(msg)
except Exception:
pass
finally:
self._fail_all("stdio 子进程 stdout 已关闭") # EOF 即断开
子进程 stdout 关闭(EOF)即视为传输断开,_fail_all 所有 in-flight 请求。这是 §四故障隔离的关键——子进程崩了,挂在它上面的请求不会永远挂。
Streamable HTTP 传输(client.py:241-318):远程 Server 用 HTTP 提供。「Streamable HTTP」是 MCP 2025-03-26 版定义的传输——服务器可对客户端的 POST 请求回一个普通 JSON 响应,也可升级成 SSE(Server-Sent Events,服务器单向推送的事件流)来串发多个消息。规范要求客户端 Accept 头同时列 application/json 和 text/event-stream,让服务器二选一。Furfly 的 httpx.AsyncClient POST JSON-RPC 到 url,按响应 content-type 分流:
req_headers = {
**self._headers,
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
}
async with self._client.stream("POST", self._url, json=payload, headers=req_headers) as resp:
if resp.status_code >= 400:
self._fail_all(f"HTTP {resp.status_code}")
return
ctype = resp.headers.get("content-type", "")
if "text/event-stream" in ctype:
await self._consume_sse(resp)
else:
body = await resp.aread()
msg = json.loads(body)
if isinstance(msg, dict):
self._dispatch(msg)
_consume_sse 按行扫 data: 字段、空行冲刷一个事件、_dispatch 派发。拿到匹配响应(_pending 空)即停——v1 不消费后续 server→client 通知。(实现里有一处半成品痕迹,文末附注交代。)
2.3 协议层不该塞进工具基类里
MCP 工具要被 Agent 当成内置工具调用,前提是它能伪装成内置工具——也就是 MCP 适配器要去 import 工具基类 BaseTool,把远端工具包一层。这就引出一个依赖方向问题:MCP 客户端要 import BaseTool,那工具基类那层要不要反过来知道 MCP 的存在?
答案是不该。工具基类那层是仓库里最干净的一块——6 个内置工具只依赖 Python 标准库,不知道 JSON-RPC、不知道 httpx、不知道子进程。这份干净是有代价维护的,不能为了接 MCP 就让它开始 import httpx。所以 MCP 客户端单独成一个包,单向去 import 工具基类,工具基类不回头 import MCP。协议的脏活(JSON-RPC、HTTP、子进程)全留在 MCP 包自己内部。
这条边界还有个附带好处:MCP 协议层可以单独测——起一个最小 MCP 测试 Server 跑 stdio 握手/list/call/错误,不用拉起整个 TUI。编排入口只有一个 loader.py,外面只调它,不直接碰客户端内部。
三、不透明工具的诚实——权限怎么零改动接入
协议接通了,下一个决策最值得讲:远端工具的缺省权限该定什么?
开头提过那个张力,这里展开。本地内置工具的权限分类是清楚的——read_file 读、edit_file 改、bash 执行,源码里 category() 写得明明白白(tool/__init__.py:126 是 abstract 方法,每个子类各自返回)。但 MCP 工具对 Furfly 不透明:一个叫 search 的 Server 工具,背后可能只读检索,也可能顺手改库、发请求、调外部 API。从工具名和 description 你判断不了——MCP 规范没有标准字段声明副作用。
面对这种不透明,缺省定 execute(最严档)是诚实的——它诚实对应「不受本地沙箱保护」这个事实,而不是假装能探测副作用。
3.1 缺省 execute 是诚实,不是偷懒
先交代 Furfly 的权限模式有四档,按从严到宽排成单调阶梯:strict(每次工具调用都问用户)、accept-edits(只读和改文件类自动放行,执行类仍问)、auto(只读/改文件类放行,执行类查破坏性命令表,非破坏性放行、破坏性仍问)、bypass(全放行)。四档都允许用户手动循环切换。在这个阶梯下,execute 类工具的待遇是:strict 全 Ask、accept-edits 仍 Ask、auto 查破坏性表(远端工具无 command 可查,保守视作破坏→Ask)。缺省 execute = 最保守——任一模式都不会自动放行一个你不了解的远端工具。
只读 Server(文档检索、搜索)用户显式标 category: read 即可在 accept-edits/auto 下自动放行。配置权交给用户:你信任这个 Server 只读,就标 read;不信任或不确定,缺省 execute 兜底。docs/mcp-client.md 里那个配置示例就是这么设计的:
mcp_servers:
github:
type: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_TOKEN: ${GITHUB_TOKEN}
category: read # 可选,read | edit | execute,缺省 execute
替代方案被否决的两条也值得讲:(a) 全默认 execute 不给配——只读 Server 体验重,每调一次弹确认;(b) 探测式分类——MCP 无标准字段声明副作用,不可靠。诚实缺省 + 用户可覆盖,比假装能探测强。
3.2 适配器实现 category(),权限服务一行没改
这里有个值得讲的架构点:MCP 接进来,权限服务为什么一行代码都没改?
先看权限服务查工具分类的方式。它不是硬编码一张「工具名 → 分类」的表——那样每加一个工具就得改权限服务。它问一个回调:「这个工具名,它的分类是什么?」回调去注册中心查,注册中心再问工具自己。这样权限服务就跟工具来源解耦了:它不关心工具是内置的还是外部 MCP 注册的,只要工具能回答「我是什么分类」,权限服务就认。问不到的工具一律按最严的 execute 处理,保守兜底。
MCP 适配器要接入这条链,只需实现一个 category() 方法返回配置值:
_CATEGORY_MAP: dict[str, ToolCategory] = {
"read": ToolCategory.READ,
"edit": ToolCategory.EDIT,
"execute": ToolCategory.EXECUTE,
}
class McpRemoteTool(BaseTool):
def __init__(self, server_name, tool, client, category="execute"):
self._category = _CATEGORY_MAP.get(category, ToolCategory.EXECUTE)
def name(self) -> str:
return f"mcp__{self._server_name}__{self._tool.name}"
def category(self) -> ToolCategory:
return self._category
category 字符串在 config 层用(避免 import 工具层),适配器边界映射成枚举。回调查注册中心时拿到这个值,回灌给权限服务——权限服务完全不知道 MCP 的存在,按现有逻辑裁定。
这是这次接入最有说服力的一点:一个设计良好的扩展点,新能力接进来不用改旧代码。 权限服务用「问回调」而不是「查硬编码表」查分类,这个选择在 MCP 出现之前就做对了——它没假定工具来源是闭合的,所以 MCP 这个新工具源接进来时,权限服务不需要回头改自己。反过来说,如果权限服务当初硬编码了「内置 6 工具的分类表」,这次接入就得改权限服务——扩展一个能力要动核心层,就是设计有问题的信号。
3.3 hard_constraints 自动注入外部提示
权限链管的是「能不能调」,还有一道防线是「模型知不知道这是外部工具」。McpRemoteTool.hard_constraints() 返回一句固定提示(tool.py:56-60):
def hard_constraints(self) -> str:
return (
f'此工具由外部 MCP Server "{self._server_name}" 提供,'
"结果不受本地沙箱保护,调用前请确认必要性。"
)
这背后还有一条设计:每个工具自己声明「调用我需要注意什么」,由适配器在边界拼进发往模型的工具描述末尾。这样工具级规则是单一事实来源——edit_file 的「编辑前必先 read_file」约束声明在 edit_file 自己身上,不重复塞进全局系统提示;MCP 工具的「不受本地沙箱保护」也声明在 MCP 适配器自己身上。模型看到 mcp__github__search 的描述时,末尾就带着这句提示,知道这工具不受本地路径沙箱与 bash 黑名单保护,引导它谨慎调用。
对 MCP 来说零新基础设施:工具描述里拼约束的那条路径早就在了,内置工具一直在用。MCP 工具只是多了一句外部提示。
四、单 Server 挂了不拖死启动——故障隔离
接入外部 Server 必然引入故障源:Server 慢、Server 挂、Server 配置错。一个核心要求是单 Server 故障不阻塞启动、不影响其他 Server、不崩溃会话。下面两条分别管「启动时不拖死」和「运行时崩了不传染」。
4.1 并发连接 + 各自超时 + 失败不抛
loader.py 的 _connect_one 是单 Server 连接的封装——失败转进度回调,不抛:
async def _connect_one(name, cfg, registry, on_progress) -> McpClient | None:
client = _build_client(name, cfg)
timeout = cfg.timeout if cfg.timeout else _DEFAULT_CONNECT_TIMEOUT # 10s
try:
await client.connect(timeout=timeout)
except asyncio.TimeoutError:
on_progress(name, "timeout", f"连接超时({timeout}s)")
await client.disconnect()
return None
except Exception as e: # 单 Server 失败不阻塞其他
on_progress(name, "error", f"连接失败: {e}")
await client.disconnect()
return None
# 注册工具(mcp__ 前缀隔离,理论上不重名;防御性跳过冲突)
for tool in client.tools():
try:
registry.register(McpRemoteTool(name, tool, client, cfg.category))
except ValueError:
on_progress(name, "error", f"工具 {tool.name} 注册重名,跳过")
on_progress(name, "ok", f"已注册 {registered} 个工具")
return client
load_and_register 用 asyncio.gather(*tasks, return_exceptions=True) 并发连所有 Server。串行连接下最慢的 Server 拖累整体启动;并发 + 各自 10s 超时让最坏延迟 = max(单 Server 超时) 而非 sum。失败的那个其工具不注册,其余正常——单 Server 慢/挂不阻塞启动。
启动时 on_mount 起后台任务调 load_and_register,连接期间界面处于 INITIALIZING 状态挡住提交,连完放输入框。这部分是 TUI 集成层,本篇不展开——后端只管「连完把工具注册进 Registry」,编排时机是 TUI 的事。
4.2 子进程崩溃不重启,调用回 Result(is_error=True)
stdio Server 的子进程崩了(exit code 非 0、管道断)怎么办?这次的取舍是不自动重启,调用回错误结果:
async def call_tool(self, tool_name: str, args: dict[str, Any]) -> Result:
prefix = f"MCP Server '{self.name}'"
if not self._transport.is_connected:
return Result(is_error=True, content=f"{prefix} 已断开")
try:
result = await self._transport.request("tools/call", {"name": tool_name, "arguments": args})
except TransportClosedError as e:
return Result(is_error=True, content=f"{prefix} 已断开: {e}")
except JsonRpcError as e:
return Result(is_error=True, content=f"{prefix} 调用错误: {e}")
except Exception as e: # noqa: BLE001 — 所有失败包成结果回灌
return Result(is_error=True, content=f"{prefix} 调用异常: {e}")
...
这复用了 Furfly 现有的契约:工具失败一律包成 Result(is_error=True) 回灌给模型,循环不中断。注册中心已会吞异常,Agent 循环拿到错误结果回灌给模型,模型自行决定下一步——重试、换工具、或告诉用户。会话不崩。
为什么不自动重启?自动重启涉及重连状态机、工具列表刷新、in-flight 请求处理,复杂度不低,收益有限——用户重启会话就恢复。崩了标记断开、调用回清晰错误,比半吊子重启可靠。这是 v1 显式的 scope 边界(proposal 里列了 8 条「v1 不做」,不重启是其中之一)。
4.3 命名空间防冲突,权限可按 Server 通配
最后一条故障相关的决策是命名空间。Registry.register 重名即抛错,跨 Server 同名(两个 Server 都叫自己的工具 search)必须解决。给工具名加 mcp__<server>__<tool> 前缀:
def name(self) -> str:
return f"mcp__{self._server_name}__{self._tool.name}"
<server> 是配置里的 map key。模型看到与调用的都是带前缀的全名。照搬 Claude Code 的 mcp__<server>__<tool> 约定,用户已熟,interop 一致。
前缀不只是防冲突,还是功能:权限规则的 tool_glob 可按 Server 通配——mcp__github__* 整 Server 放行/拒绝,mcp__github__search 单工具放行。这比同名直接报错让用户改体验好得多(跨 Server 冲突频繁),比 server.tool 点号风格干净(点号与某些工具名含点冲突,且权限 glob 语义不清)。
五、顺带做的基建——配置两层合并
MCP Server 列表天然需要跨项目复用:个人的 GitHub token 该在用户级(跨项目共用),项目专用 Server 在项目级。这就要求配置支持两层合并。顺带让 providers/permissions 也能跨项目复用,消除「每个项目重抄一份 provider 配置」的重复。
5.1 用户级打底 + 项目级覆盖
Config.load(path) 现在内部先读用户级 ~/.furflycode/config.yaml(不存在跳过)、再读项目级 path,在 raw dict 层合并后再走现有 _from_dict 校验。两层都缺沿用现有 ConfigError。
合并发生在 raw dict 层(解析为 dataclass 前),合并后再走现有校验——校验只在合并后做一次,不重复校验逻辑,且 _from_dict 无需感知「值来自哪个文件」。
5.2 按字段形状分语义
「深合并」对不同形状意思不同,光说「深合并」没定死。这里按字段形状分语义:
| 字段形状 | 合并行为 |
|---|---|
标量(max_iterations、permissions.mode) |
项目覆盖用户 |
map(mcp_servers) |
按 key 深合并:同名 Server 字段级合并,单边存活 |
list of dict 按 name 键(providers) |
按 name 深合并:同名 entry 字段级合并,单边存活 |
list of dict 无 name(permissions.rules) |
拼接(L3 Deny>Ask>Allow 聚合自然裁定冲突) |
list of 标量(permissions.allow_paths) |
并集去重 |
mcp_servers 按 key 深合并的直觉:用户级配了 linear(个人 token),项目级配了 github,合并后两个都在;项目级再配 linear 的 category,就把用户级那个 linear 的 category 覆盖掉,其它字段保留。providers 按 name 深合并与 mcp_servers 心智一致;rules/allow_paths 本质是约束集合,多了只更严或更宽,不丢,且 L3 引擎本来就按优先级聚合多条规则。
替代方案被否决的两条:(a) 全字段浅替换——mcp_servers 整个替掉会丢用户级的 linear,不符「打底+覆盖」直觉;(b) 在 dataclass 层合——要在两个 Config 间合,重复校验且 dataclass 不保留「缺失 vs 显式空」信息。
5.3 ${VAR} 未定义 fail-fast
MCP Server 的 env(stdio)和 headers(HTTP)值支持 ${VAR} 从 os.environ 展开。一个关键决策是未定义变量启动时 fail-fast 报错,不静默展开为空串。理由很具体:未定义变量若静默展开为空串,会把空 token 当真 token 发给 Server——Authorization: Bearer 后面是空的,可能触发诡异的 401 而非清晰的配置错误。fail-fast 让配置问题在启动时暴露,而不是上线后靠一个莫名 401 倒查。
这是「防空 token 当真 token 发出去」的防御。错误消息只含 Server 名与变量名,不含值——MCP Server <name> 的 env <key> 引用未定义变量 ${VAR},不把 token 值泄到日志里。连接进度回调同理:只写 github: ok / linear: 超时,不写 env/header 值。密钥安全纳入现有 tool-system spec 要求,扩展覆盖 MCP env/headers。
六、写在最后
回头看这次接入的形状:MCP 凿开的不是工具清单,是扩展模型。
接入前:闭合 6 工具、改源码加内置、能力边界 = 源码硬编码。
接入后:内置 + 外部 MCP Server 注册、配一个 Server 就加能力、Agent 调用无感一致。
每条改动都对应到一个具体的后端决策:协议落 McpClient 三步握手 + JSON-RPC id 配对 + 双传输;权限靠适配器实现 category() 接入「问回调查分类」的权限链,权限服务一行没改;故障靠并发 + 各自超时 + 失败回 Result(is_error=True) 复用现有契约,不造新重启机制;配置两层合并按字段形状分语义 + ${VAR} fail-fast。
三句话留下来:
一、不透明 → 诚实缺省,而非探测。 远端工具叫
search背后可能任意副作用,从名字判断不了。缺省execute诚实对应「不受本地沙箱保护」,把信任决策交给用户显式标注,比假装能探测副作用可靠。
二、扩展点设计对了,新能力不踩旧代码。 权限服务用「问回调」查分类而不是查硬编码表,这个选择在 MCP 出现之前就做对了——它没假定工具来源是闭合的。所以 MCP 这个新工具源接进来时,权限服务不需要回头改自己。反过来说,扩展一个能力要动核心层,就是设计有问题的信号。
三、复用现有契约,不造新机制。 子进程崩了回
Result(is_error=True)复用「工具失败回灌、循环不中断」契约;hard_constraints自动注入复用「工具级规则单一事实来源」机制。新能力接在旧契约上,比另起一套重启状态机、另造一套提示机制可靠得多。
至于 v1 没做的——resources/prompts/sampling、tools/list_changed 热重载、子进程自动重启、image/resource 内容块——都列在 proposal 的「v1 显式不做」里。凿开第一道口子够用,剩下的是后续的事。
参考资料
- 本仓库变更提案:
openspec/changes/archive/2026-07-03-add-mcp-client/proposal.md(「闭合 6 工具」「扩展模型从改源码到配 Server」原话出处,v1 显式不做 scope 边界) - 本仓库设计文档:
openspec/changes/archive/2026-07-03-add-mcp-client/design.md(D1 MCP 包单独成层不塞进工具基类、D2mcp__<server>__<tool>命名空间、D3 缺省 execute 诚实、D6 并发连接+各自超时、D7 子进程崩了不重启、D8 配置合并按字段形状、D9${VAR}fail-fast、D11 v1 只 tools) - MCP 客户端核心:
src/furflycode/mcp/client.py(PROTOCOL_VERSION = "2025-03-26":23;三步握手connect():337-361;Transport抽象 + id 配对_dispatch:60-156;stdio_read_loop:158-238;Streamable HTTP + SSE_send/_consume_sse:241-318;call_tool失败包装:384-407) - MCP 远端工具适配器:
src/furflycode/mcp/tool.py(_CATEGORY_MAP+category():17-54;命名空间name():44-45;hard_constraints()外部提示:56-60) - MCP loader 编排:
src/furflycode/mcp/loader.py(_connect_one失败转回调不抛:35-63;load_and_register并发 gather:66-90) - 权限注入式分类:
src/furflycode/permission/policy.py(category_of注入:181;未知工具视作 EXECUTE 保守:312;查询:315) - 工具分类抽象:
src/furflycode/tool/__init__.py(ToolCategory:20;category()abstract:126;is_concurrency_safe:135) - MCP 用户文档:
docs/mcp-client.md(配置格式、两种传输、category 语义、两层合并表、已知限制 v1) - MCP 官方生命周期规范:
https://modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle(initialize → notifications/initialized → 操作 → shutdown,三步握手时序图,§二 协议事实基线) - MCP 官方传输规范:
https://modelcontextprotocol.io/specification/2025-03-26/basic/transports(stdio 行分隔 JSON、Streamable HTTP 双 content-type 协商application/json, text/event-stream、SSE 流响应,§二 双传输事实基线) - 同系列博客:《都准备给 AgentLoop 加权限系统了,CLAUDE.md 里还写着「单轮上限」》
docs/knowledge/都准备给AgentLoop加权限系统了,CLAUDE.md里还写着「单轮上限」.md(category_of注入式权限链的设计由来,本篇 §3.2 接的就是这条链) - 同系列博客:《每轮请求都在全量重算 7K token?我读了 Anthropic 缓存机制才意识到自己多花了一个零》
docs/knowledge/每轮请求都在全量重算7K token?我读了Anthropic缓存机制才意识到自己多花了一个零.md(hard_constraints单一事实来源机制的设计由来,本篇 §3.3 用同一条拼装路径)
