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 个激进不同的接口设计(最小化/灵活性/作者体验/端口适配)并行竞标 → 用户选定杂交方案。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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