Skip to content

Refactor RFC: 深化 MCP 工具边界——ToolKernel 深模块 + 协议适配器 + 边界测试 #19

Description

@mambo-wang

Problem

MCP 工具边界是本系统的公共 API(44+ 工具,被 Qoder / Cursor / Claude Desktop 等 IDE agent 消费),但它当前是全仓库架构摩擦最集中的区域:

1. Schema 与实现分离 2600 行,无静态匹配保证

  • codewiki/mcp/registry.py 共 2622 行,约 85% 是手写 inputSchema JSON 字符串;dispatch(registry.py:2552-2622)靠字符串 handler_path + importlib 动态导入,6 条调用分支(3 mode × takes_store)
  • schema 声明的参数与 handler 实际读取的参数之间没有任何静态保证;工具行为描述在四处重复(registry description / server.py:71-127 instructions / prompts.py 1627 行 / AGENTS.md)

2. 薄壳样板批量重复

  • 53 个工具文件共用 (arguments, store) -> str 薄壳签名,各自重写 json.dumps(ensure_ascii=False)、错误捕获、output_dir 解析
  • _resolve_output_dir 有 4 份独立副本(capture_conversation.py:186 / distill_conversation.py:200 / note_consolidation.py:306 / source_ingest.py);wiki 场景块记载的收敛点 resolve_workspace 尚未落地
  • knowledge_loop.py(2394 行)单文件 51 处函数内局部 import 绕循环依赖

3. 横切关注点硬编码在 dispatcher 里

  • CBM enrichment 在 registry.py:2450-2549 按工具名匹配分支,加一种后处理就要改 dispatch 核心

4. 集成风险集中在无人测试的缝隙

  • 31 个测试文件、437 个测试全部直接 import handler,零测试经过 dispatch
  • 仅有的端到端冒烟 tests/smoke_test_mcp.py 文件名不匹配 pytest 收集;okf_regression_test.py 收集 0 条
  • store 参数对大部分文件型工具是残留噪音:测试每次调用新建空 SessionStore

Proposed Interface

选定方案:D* 杂交设计——以 ports & adapters 骨架(深模块 ToolKernel + 协议适配器 + 内存测试适配器),保留手写 schema(公共 API 即 schema 字节,不做签名派生,避免对冻结契约的回归风险),吸收中间件方案中的"边界契约快照测试"作为迁移护栏。

# codewiki/mcp/protocol.py —— 领域契约,零 mcp import
@dataclasses.dataclass(frozen=True)
class ToolSpec:
    name: str
    description: str
    input_schema: dict[str, Any]      # JSON Schema 手写,随工具文件同址维护
    mode: str = "thread"              # "main_thread" | "thread" | "async"
    takes_store: bool = True

@dataclasses.dataclass(frozen=True)
class ToolResult:
    payload: Any   # 已解码结构——测试断言这个
    text: str      # 线上字节——legacy str 原样透传,逐字节兼容

class ToolSurface(Protocol):          # 入站端口:生产与测试驱动同一表面
    def list_tools(self) -> list[ToolSpec]: ...
    async def call(self, name: str, arguments: dict[str, Any]) -> ToolResult: ...

# codewiki/mcp/kernel.py —— 端口的唯一实现(深模块,约 150 行)
class ToolKernel:  # implements ToolSurface
    def __init__(self, store, *, enrichers: tuple[Enricher, ...] = ()) -> None: ...
    def register(self, spec: ToolSpec, handler) -> None: ...
    def register_legacy(self, schema, handler_path: str, mode: str,
                        takes_store: bool = True) -> None:   # 桥接旧 _register,一行不改即可运行
    async def call(self, name, arguments) -> ToolResult:
        # 查表 → 按 mode 调度(main_thread 直调 / thread 走 asyncio.to_thread / async await)
        # → 结果归一化(str/dict/TextContent/list[TextContent] 四种形状)
        # → enrichers 作用于 dict 型成功结果(CBM 不再二次 json.loads)
        # → 任何异常塌缩为 ToolResult({"error": str(e)}, ...),永不抛出边界

@tool(name=..., description=..., input_schema=..., mode=...)   # 装饰器:工具文件内声明式注册
def handle_xxx(arguments: dict, store) -> dict:                # 返回 dict 即可,封包/兜底在墙内
    ...

# codewiki/mcp/mcp_adapter.py —— 唯一 import mcp.types 的地方(加 server.py)
class McpAdapter:      # ToolSpec→mcp.types.Tool、ToolResult.text→TextContent

# codewiki/mcp/testing.py —— 测试适配器:同一端口,无 mcp、无子进程
class InMemoryClient:
    def __init__(self, *, with_cbm: bool = False) -> None: ...
    def call(self, name: str, arguments: dict) -> Any:   # 返回 payload

使用示例:

# server.py 接线(仍约 10 行)
_adapter = McpAdapter(build_kernel(_store, with_cbm=True))

@server.list_tools()
async def list_tools() -> list[Tool]: return _adapter.list_tools()

@server.call_tool()
async def call_tool(name, arguments) -> list[TextContent]:
    return await _adapter.call_tool(name, arguments)

# 边界测试(与生产同一表面,tmp_path 即文件系统替身)
def test_capture_dedup(tmp_path):
    client = InMemoryClient()
    out = str(tmp_path / "repowiki")
    assert client.call("capture_conversation",
                       {"output_dir": out, "conversation": CONV})["status"] == "captured"
    assert client.call("capture_conversation",
                       {"output_dir": out, "conversation": CONV})["status"] == "duplicate"

隐藏的复杂度: 三种执行模式调度(调用点零分支)、统一异常兜底(50+ 处手写 try/except 收敛为一处)、JSON 编码单点(ensure_ascii=False)、结果形状归一化、CBM 后处理(从按名匹配分支 → 可注册/可关闭的 enricher)、importlib 风险限定在 legacy 桥接(新工具直接引用函数对象,拼写错误 import 期暴露)、output_dir 解析(收敛至 resolve_workspace 单点:纯解析、不 mkdir、抛 ValueError 由边界兜底)。

Dependency Strategy

依赖类别:In-process(核心逻辑纯进程内,可直接合并与真文件系统边界测试),协议边界用 ports & adapters 隔离:

依赖 处置
mcp SDK(Tool/TextContent/Server) mcp_adapter.py + server.py import;ToolSpec/ToolResult 是我方契约,SDK 升级只动适配器
SessionStore 注入的具体依赖,不做端口(进程内线程安全对象,测试随手 SessionStore() 即建——现有 31 个测试已证明)
repowiki/ 文件布局 拥抱不隔离——本来就是 local-substitutable 的持久状态(tmp_path 可替换);真正的债务是 4 份 _resolve_output_dir,合并进 workspace.py::resolve_workspace 单点
CBM Enricher 中间件,build_kernel(with_cbm=...) 控制,测试默认关闭保证确定性

Testing Strategy

  • 新边界测试
    • tests/test_mcp_boundary.py——把 smoke_test_mcp.py 的 19 步改写为 InMemoryClient.call(...) 形式纳入 pytest 收集,成为端到端回归网
    • 边界契约快照测试:遍历注册表对 (schema, 样例调用输出键集) 做快照,迁移期只许审批变更(防 schema/行为漂移)
    • 逐工具迁移时补 InMemoryClient 边界测试(每 PR 一工具一测试)
  • 旧测试处置:437 个 handler 直连测试迁移期共存不动(测试直连不受枢纽改造影响);随工具文件触碰逐步替换为边界测试;断言 json.dumps/try-except 样板的测试在对应工具迁移后删除
  • 测试环境tmp_path + 真 SessionStore,无需 mock;CBM 默认关闭;不引入新基础设施

Implementation Recommendations

模块职责(不绑定当前文件路径):

  • 该拥有:工具注册表、执行模式调度、异常→错误 JSON 封包、结果编码与归一化、横切后处理管线
  • 该隐藏:importlib 桥接细节、结果形状归一化、必填参数校验、enricher 组合机制
  • 该暴露ToolSurface(list/call 两个方法)、@tool 装饰器、InMemoryClient 测试适配器

不变量(迁移全程必须保持):

  • 工具名、参数名、JSON 响应语义逐字不变(公共 API);legacy str 结果经 ToolResult.text 原样透传
  • 异常兜底契约不变:handler 抛异常是安全的,边界统一返回 {"error": str(e)}
  • 三种执行模式语义不变(tree-sitter 线程亲和性)

渐进迁移路径(无 big-bang):

  1. P0 零行为差枢纽:新增 protocol/kernel/mcp_adapter/testing;桥接器收编全部旧 REGISTRY 条目;server.py 改接 McpAdapter;旧 dispatch() 保留为弃用薄壳。437 个现有测试不改、全绿
  2. P1 先建回归网再动工具:CBM 抽为 enrichment.py 中间件;smoke 移植为边界测试;建契约快照
  3. P2 路径收敛:4 份 _resolve_output_dir 合并进 workspace.resolve_workspace(行为逐字一致),各副本随工具迁移逐个删除
  4. P3 主体(5-10 个小 PR):逐工具把 registry.pyTool(...) 块剪贴进对应工具文件成 @tool;每批由 P1 边界测试护驾
  5. P4 收尾:删除 importlib 路径与 registry.py;评估 takes_store 退役(目前仅 3 个工具为 False);smoke_test_mcp.py 删除

约束未来的规则: 新工具只许新式注册(装饰器直引函数对象);横切关注点一律走 enricher,禁止再往 dispatch 核心加按名匹配分支。


本 RFC 由 improve-codebase-architecture 工作流产出:两个并行探索代理走读全仓库识别摩擦 → 6 个深化候选 → 4 个激进不同的接口设计(最小化/灵活性/作者体验/端口适配)并行竞标 → 用户选定杂交方案。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions