构建 Thought Forest 本地知识库 MCP Server
从知识索引、受限读取、提案暂存到精确合并,构建可供远程 Agent 安全维护 Thought Forest 的本地 MCP Server。
[!abstract] 成功状态 在仓库根目录运行 `npm run kb:mcp:test`,全部工具、路径边界、提案完整性、并发冲突、归档、审计和索引测试通过;服务只向 Agent 暴露语义工具,不暴露任意文件写入或 shell。
[!info] related notes
- 前置概念: 本地 MCP Server, MCP
- 架构: ChatGPT 网页端维护本地 Obsidian 的架构
- 安全模型: 二阶段提案合并的安全边界
- 下一步: 通过 Secure MCP Tunnel 连接本地 MCP 与 ChatGPT
构建 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 根目录内的绝对路径,再执行:
- 输入必须是仓库相对路径;
- 拒绝绝对路径和 `..`;
- 顶层目录必须在白名单;
- 拒绝 `.git`、`node_modules`、密钥目录和环境文件;
- 解析真实路径后再次确认没有 symlink 逃逸;
- 限制可读取文件类型和响应大小。
白名单用于回答“允许读什么”,硬拒绝列表用于回答“即使误配也绝不能读什么”。两者不能只保留一个。
步骤 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:定义成功后的事务顺序
建议顺序:
- 完成全部只读校验;
- 写入目标;
- 把 proposal 文件移动到 `inbox/applied/
/`; - 追加 `inbox/_applied.jsonl`;
- 运行知识索引生成;
- 返回目标、归档、审计和索引结果。
索引失败发生在目标写入之后时,返回“写入成功但索引失败”的部分成功状态。不要自动重试 `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 外文件:应设计新的受限工具和独立审批,不扩大现有路径白名单。