构建 Thought Forest 本地知识库 MCP Server

从知识索引、受限读取、提案暂存到精确合并,构建可供远程 Agent 安全维护 Thought Forest 的本地 MCP Server。

#type / howto #status / growing #tech / ai #discipline / obsidian #resource / mcp #resource / obsidian #interface / cli

[!abstract] 成功状态 在仓库根目录运行 `npm run kb:mcp:test`,全部工具、路径边界、提案完整性、并发冲突、归档、审计和索引测试通过;服务只向 Agent 暴露语义工具,不暴露任意文件写入或 shell。

[!info] related notes

构建 Thought Forest 本地知识库 MCP Server

目标

构建一个位于 `scripts/kb-mcp/` 的 stdio MCP Server,使 Agent 可以:

  • 了解知识库规模和索引新鲜度;
  • 按标题、别名、描述、标签和正文搜索;
  • 读取知识笔记与规范;
  • 检查链接图和近期笔记;
  • 生成新建或修改提案;
  • 在独立确认后精确合并一条提案;
  • 自动归档、审计并重建索引。

Server 不提供任意路径、任意文件写入、任意 shell 或“把整段 prompt 当命令执行”的能力。

前置条件

  • 仓库根目录为 `D:\home\thought-forest`;
  • Node.js 与 npm 可用;
  • 依赖已经安装;
  • `z/` 是正式知识笔记目录;
  • `docs/` 是仓库规范来源;
  • `inbox/` 用于待审与已归档提案;
  • `npm run kb:index` 可以重建知识索引。

先确认:

Set-Location D:\home\thought-forest
npm run kb:index
npm run kb:mcp:test

步骤 1:把能力拆成闭合工具

工具应回答稳定业务问题,而不是映射底层文件 API:

工具核心问题
`get_vault_overview`当前知识库有多大,索引是否新鲜
`search_knowledge`哪些笔记在标题、别名、描述或标签上匹配
`search_note_content`正文中是否存在这个概念
`read_note`这篇已知笔记或规范实际写了什么
`list_standards`当前有哪些正式规范
`list_tags`哪些标签已注册或稳定使用
`get_related_notes`出链、反链、断链和 MOC 入口是什么
`list_recent_notes`最近创建或更新了哪些笔记
`propose_new_note`把已批准的新建方案固化成什么提案
`propose_note_patch`把已批准的修改固化成什么全文与 diff
`list_proposals`当前有哪些可审提案及其精确 ID
`apply_proposal`如何安全消费一条已二次确认的提案

不要增加 `write_file(path, content)`。它会把安全问题从有限知识库动作扩大到任意文件写入。

步骤 2:建立读取白名单

读取目标应先解析到 Vault 根目录内的绝对路径,再执行:

  1. 输入必须是仓库相对路径;
  2. 拒绝绝对路径和 `..`;
  3. 顶层目录必须在白名单;
  4. 拒绝 `.git`、`node_modules`、密钥目录和环境文件;
  5. 解析真实路径后再次确认没有 symlink 逃逸;
  6. 限制可读取文件类型和响应大小。

白名单用于回答“允许读什么”,硬拒绝列表用于回答“即使误配也绝不能读什么”。两者不能只保留一个。

步骤 3:把写入分成提案与应用

新建提案:

inbox/new/<slug>.md
inbox/new/<slug>.proposal.json

修改提案:

inbox/patch/<slug>.new.md
inbox/patch/<slug>.diff
inbox/patch/<slug>.proposal.json

提案元数据至少记录:

  • 服务端生成的 proposal ID;
  • 目标相对路径;
  • 提案正文 hash;
  • patch 生成时的目标 base hash;
  • 创建时间与理由;
  • 提案类型。

`propose_*` 只写 `inbox/`,不修改 `z/`。

步骤 4:限制最终目标

`apply_proposal` 不接受目标路径参数,只接受 proposal ID。服务端通过该 ID 查回元数据并验证:

  • proposal ID 精确存在;
  • proposal 文件仍在预期目录;
  • 当前 proposal hash 与记录一致;
  • 新建目标尚不存在;
  • patch 目标存在且 base hash 未变化;
  • 目标经过 `resolveApprovedNoteTarget` 后位于 `z/*.md`;
  • 真实父路径没有 symlink 逃逸。

任何检查失败都应保持提案可审,不部分覆盖目标。

步骤 5:定义成功后的事务顺序

建议顺序:

  1. 完成全部只读校验;
  2. 写入目标;
  3. 把 proposal 文件移动到 `inbox/applied//`;
  4. 追加 `inbox/_applied.jsonl`;
  5. 运行知识索引生成;
  6. 返回目标、归档、审计和索引结果。

索引失败发生在目标写入之后时,返回“写入成功但索引失败”的部分成功状态。不要自动重试 `apply_proposal`,因为提案已经被消费;只重跑 `npm run kb:index`。

步骤 6:正确标注 MCP 工具

只读工具声明 read-only;提案和应用工具声明 write。最终合并还应声明 destructive,因为它会改变正式知识库。

这些标注帮助 ChatGPT 展示权限与审批,但不能替代服务端校验。

步骤 7:实现可恢复的 CLI

网页端不可用时保留同一实现的 CLI 包装:

npm run kb:inbox:list
npm run kb:inbox:apply
npm run kb:inbox:apply -- --yes --only <slug>

CLI 与 MCP 必须调用同一个 apply library,避免两条路径的校验规则漂移。

验证

类型检查

npx tsc --noEmit --pretty false

MCP 烟雾测试

npm run kb:mcp:test

测试至少覆盖:

  • 12 个工具均可发现;
  • 只读/写入/破坏性标注正确;
  • 被篡改的提案拒绝;
  • stale target 拒绝;
  • 路径穿越拒绝;
  • 成功应用写入 `z/`;
  • 提案被归档;
  • 审计日志追加;
  • 索引自动重建;
  • 测试临时文件被清理。

手工只读启动

npm run kb:mcp

stdio Server 启动后等待 JSON-RPC 是正常现象。不要向 stdout 打印普通日志;调试信息写 stderr。

回滚与恢复

  • 代码改动通过 Git diff 审查和恢复;
  • 未合并提案直接保留或移动到人工废弃区,不删除正式笔记;
  • 已合并内容通过 Git 历史恢复;
  • 索引损坏时运行 `npm run kb:index`;
  • 审计日志只追加,不用它覆盖知识文件。

常见问题

  • 工具存在但 ChatGPT 看不到:检查 App 工具快照,而不是重复改 Server。
  • 测试能过但 Tunnel 失败:进入 故障排查 的传输层。
  • Agent 跳过查重:这是 Skill/Agent 流程问题,服务端不能凭空判断所有知识语义。
  • 想写 Vault 外文件:应设计新的受限工具和独立审批,不扩大现有路径白名单。
创建于 2026/8/8 更新于 2026/8/8