Spindriftの车厢
首页归档项目音乐杂谈照片墙友链关于
封面

工具集是闭合的 6 个内置,直到 MCP 把这堵墙凿开

写作时间:2026-07-04 01:46:01
# AI Coding
# Agent
# MCP

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 版规范,客户端生命周期是三步:

  1. initialize 请求——客户端发协议版本、自己支持的可选能力(capabilities,比如是否提供文件根目录、是否支持采样,Furfly 这里给空表示不声明任何可选能力)、客户端实现信息(clientInfo,名字+版本号,供服务器识别);服务器回它自己的能力和实现信息。这一步 MUST 是第一个交互,发完 initialize 之前什么正经请求都不能发。
  2. notifications/initialized 通知——客户端收到 initialize 响应后,回一个无 id 的通知,表示「就绪,可以开始正常操作」。
  3. tools/list → tools/call——进入操作阶段,列工具清单、按需调用。

规范把这三步画成一张时序图,原文:

The client MUST initiate this phase by sending an initialize request containing: Protocol version supported, Client capabilities, Client implementation information. ... After successful initialization, the client MUST send an initialized notification 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 包单独成层不塞进工具基类、D2 mcp__<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 用同一条拼装路径)

‍

avatar

Spindrift

ZZULI 软件工程大三学生。擅长后端开发与Agent应用

RECOMMENDED

Python类型Protocol vs ABC 到底怎么选

2026-06-27 21:24:57

实践出真知:为什么要采用openspec?

2026-06-30 10:20:06

准备给AgentLoop加权限 ,发现CLAUDE.md 里还写着「单轮上限」

2026-07-01 23:58:39

Table of Contents