让 Codex 和 Cursor 读写你的知识库:用 FastMCP 搓一个本地 MCP Server

Posted on 二 13 10月 2026 in Tech

Abstract 让 Codex 和 Cursor 读写你的知识库:用 FastMCP 搓一个本地 MCP Server
Authors Walter Fan
Category Tech
Version v1.0
Updated 2026-10-13
License CC-BY-NC-ND 4.0

大纲

展开看看
  • 痛点:agent 要么看不见知识库,要么拿着全量权限乱 grep 乱写,全量和零权限一样糟
  • 为什么选 MCP 不选 CLI:三个客户端、写护栏要收进 server,不在 prompt 里
  • 柜台背后:一个普通的 Sphinx + MyST + Mermaid 知识库,Markdown 是唯一事实源
  • 柜台设计:五个工具,少而正交,返回必须带路径
  • 代码:kb_mcp_server.py 全文,FastMCP 4.1.0 上逐工具验证
  • 接线:Codex、Cursor、Claude Code 三家的配置片段
  • 写护栏:白名单、校验、原子写、审计、不提供删除;防呆不防贼
  • 代码图上柜台:graphify 常挂当免费地图,Understand-Anything 按需当贵导游
  • 边界与升级:stdio 之外、grep 之外、单仓库之外

你在 Codex 里问一句:“我们当年的重试策略,为什么上限定在 3 次?”它沉默两秒,开始 grep 当前仓库。因为它根本不知道你的知识库在哪儿——那是一套用 Sphinx + MyST 建的 Markdown,在另一个目录里躺了好几年,make html 一跑就是一个能看的站点,但 agent 连门都没摸到。

把知识库挪进工作目录?更糟。agent 一头扎进去 grep,三十个文件读五个,token 烧掉几十万;写起来更是吓人——它拿着全量文件权限直接改你的 .md,顺手把 Mermaid 图改出一朵语法错误的云,你不 build 一遍都不知道。

我的观点是一句话:别把仓库钥匙整个交给 agent,给它开个柜台。 搜索、读、写,都到柜台上办——路径有白名单,写入有校验,每笔操作留流水。MCP(Model Context Protocol)就是干这个的协议,而用 Python 的 FastMCP,这个柜台一百多行代码就能开张:本地 stdio 直连,不部署任何服务,Codex、Cursor、Claude Code 一家配一份配置就都能用。

这篇给的就是这套最简方案。知识库那一侧是最普通的 Sphinx + MyST + Mermaid/PlantUML;柜台这一侧是五个工具;最后再把 graphify、Understand-Anything 这类代码分析工具产出的知识图谱也挂上柜台,让 agent 查图,别 grep。文末的代码可以整段抄走。

一、全量权限和零权限,一样糟

agent 访问知识库,常见的两种境遇都不对:

境遇 表面 实际
看不见(知识库不在工作目录) 安全 知识等于不存在,agent 只能靠参数里的旧知识瞎编
全看见(知识库在工作目录,文件工具全开) 方便 grep 海底捞针烧 token;写入无校验、无审计、无底线

第一种好理解。第二种值得多说一句:agent 自带的文件读写工具是“通用”的,它不理解你的 frontmatter 规范,不知道哪些目录能动哪些不能,更不会在写完之后告诉你它改了什么。权限给了,约束没了。

我之前写过一篇对比 MCP 和 CLI 两条路,结论是对内单机 CLI 也够用。这次我还是选了 MCP,理由有三:

  1. 客户端是三家,不是一家。 Codex、Cursor、Claude Code 各有各的 shell 习惯,给每家写一版 CLI 用法说明,成本比一个 MCP server 高。
  2. 工具 schema 是自描述的。 @mcp.tool 装饰器下的函数签名和 docstring 会变成协议里的工具描述,agent 不用猜参数怎么传;CLI 的 --help 得靠它自己去读。
  3. 写护栏要收在 server 里,不能散在 prompt 里。 路径校验、frontmatter 检查、审计日志,写进代码就是硬约束;写在 prompt 里就是贴在墙上的标语。

二、柜台背后:一个再普通不过的 Sphinx 知识库

柜台背后不需要任何特殊设施,一套标准 Sphinx 就行:

kb/
├── docs/                  # KB_ROOT:唯一事实源,全是 .md
│   ├── conf.py            # Sphinx 配置
│   ├── index.md
│   ├── decision/          # 决策与取舍(为什么这么定)
│   ├── runbook/           # 操作手册(怎么干)
│   ├── domain/            # 业务与领域知识(只有我们知道的事)
│   └── reading/           # 读书与论文笔记
├── _build/                # make html 的产物,可重建,不进 git
├── Makefile               # make html / make clean
└── AGENTS.md              # 告诉 agent:查库请走 kb_* 工具

conf.py 里关键的就这么一行:

extensions = ["myst_parser", "sphinxcontrib.mermaid", "sphinxcontrib.plantuml"]

每篇文档是一张带 frontmatter 的卡片:

---
title: 为什么我们的重试上限定在 3 次
type: decision
status: current
updated: 2026-10-13
---
# 为什么我们的重试上限定在 3 次

下游 P99 已经 800ms,3 次重试叠加退避,最坏情况把调用方拖到超时线之外……

```mermaid
flowchart LR
    A[调用失败] --> B{第几次?}
    B -- "≤3" --> C[退避后重试]
    B -- ">3" --> D[熔断]
两个要点,都是老规矩:**Markdown 源文件是唯一事实源,`_build` 删了能一条命令长回来**——这套分层我在[知识库工作流](./2026-08-31-knowledge-base-workflow.html)那篇里详细写过;**`make html` 就是知识库的编译器**,Mermaid 代码块写坏了、内链断了,build 的时候全会暴露。这个性质后面有用。

## 三、柜台设计:五个工具,少而正交

| 工具 | 输入 | 输出 | 护栏 |
|---|---|---|---|
| `kb_catalog` | 无 | 全部文档的路径 + frontmatter 摘要 | 只读 |
| `kb_search` | 正则、条数上限 | `文件:行号: 摘要` | 只读,上限 50 条 |
| `kb_read` | 相对路径 | 整篇原文 | 路径白名单,只 `.md` |
| `kb_write` | 路径、内容、事由 | 成功或明确的拒绝理由 | 校验 frontmatter,原子写,留审计,不删 |
| `code_graph_query` | 自然语言问题 | 代码知识图谱的查询结果 | 只调本地 CLI,30 秒超时 |

设计上就两条原则:

**工具要少而正交。** 每加一个工具,agent 的选择空间就大一分,选错的概率也大一分。五个工具对应五个动作:看目录、找、读、写、查代码图,没有第七个动作的位置。

**返回必须带路径。** 搜索结果的每一条都落在具体文件的具体行上,读出来的内容自带出处。**不给路径的答案不算答案**——这一条能把大部分幻觉挡在门口,因为 agent 接下来要引用时,必须先去读原文。

![知识库 MCP 柜台架构](../images/tech_20261013_mcp_kb_architecture.png)

## 四、开张:一百多行代码

先装 FastMCP(Python 3.10+):

```console
$ pip install fastmcp

然后是柜台本身,完整代码:

# kb_mcp_server.py —— 知识库柜台:catalog / search / read / write / 代码图查询
import json
import os
import re
import subprocess
from datetime import datetime, timezone
from pathlib import Path

from fastmcp import FastMCP

KB_ROOT = Path(os.environ.get("KB_ROOT", "~/kb/docs")).expanduser().resolve()
CODE_ROOT = Path(os.environ.get("CODE_ROOT", KB_ROOT)).expanduser().resolve()
AUDIT_LOG = KB_ROOT.parent / ".kb_audit.jsonl"
ALLOWED_SUFFIX = ".md"
ALLOWED_GLOB = "*.md"
MAX_WRITE_BYTES = 200_000
VALID_STATUS = ("current", "draft", "superseded")
REQUIRED_META = ("title", "type", "status", "updated")

FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL)

mcp = FastMCP("kb")


def safe_path(rel: str) -> Path:
    """把 agent 给的路径关进 KB_ROOT,出界或非 .md 一律拒绝。"""
    p = (KB_ROOT / rel).resolve()
    if not p.is_relative_to(KB_ROOT):
        raise ValueError(f"path escapes KB_ROOT: {rel}")
    if p.suffix != ALLOWED_SUFFIX:
        raise ValueError(f"only .md is served, got: {rel}")
    return p


def parse_frontmatter(text: str) -> dict:
    meta = {}
    m = FRONTMATTER_RE.match(text)
    if m:
        for line in m.group(1).splitlines():
            k, sep, v = line.partition(":")
            if sep:
                meta[k.strip()] = v.strip()
    return meta


def audit(action: str, path: str, extra: dict | None = None) -> None:
    entry = {"ts": datetime.now(timezone.utc).isoformat(), "action": action,
             "path": path, **(extra or {})}
    with open(AUDIT_LOG, "a", encoding="utf-8") as f:
        f.write(json.dumps(entry, ensure_ascii=False) + "\n")


@mcp.tool
def kb_catalog() -> str:
    """列出知识库全部文档的路径和 frontmatter 摘要。先看这个,再决定读哪篇。"""
    items = []
    for p in sorted(KB_ROOT.rglob(ALLOWED_GLOB)):
        rel = p.relative_to(KB_ROOT)
        if any(part.startswith(".") for part in rel.parts):
            continue
        meta = parse_frontmatter(p.read_text(encoding="utf-8"))
        items.append({
            "path": str(rel),
            "title": meta.get("title", ""),
            "type": meta.get("type", ""),
            "status": meta.get("status", ""),
            "updated": meta.get("updated", ""),
        })
    return json.dumps(items, ensure_ascii=False, indent=1)


@mcp.tool
def kb_search(query: str, limit: int = 10) -> str:
    """按正则搜索全部 .md 文档,返回 "文件:行号: 摘要"。"""
    limit = max(1, min(limit, 50))
    rx = re.compile(query, re.IGNORECASE)
    hits = []
    for p in sorted(KB_ROOT.rglob(ALLOWED_GLOB)):
        for n, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
            if rx.search(line):
                hits.append(f"{p.relative_to(KB_ROOT)}:{n}: {line.strip()[:120]}")
                if len(hits) >= limit:
                    return "\n".join(hits)
    return "\n".join(hits) if hits else "no hits"


@mcp.tool
def kb_read(path: str) -> str:
    """读一篇文档的完整原文。"""
    return safe_path(path).read_text(encoding="utf-8")


@mcp.tool
def kb_write(path: str, content: str, reason: str) -> str:
    """新增或更新一篇文档。content 必须带 frontmatter
    (title/type/status/updated),status 只能是 current/draft/superseded。
    不提供删除——删东西走 git,让人来决定。"""
    p = safe_path(path)
    if len(content.encode("utf-8")) > MAX_WRITE_BYTES:
        return f"rejected: content over {MAX_WRITE_BYTES} bytes"
    meta = parse_frontmatter(content)
    missing = [k for k in REQUIRED_META if not meta.get(k)]
    if missing:
        return f"rejected: frontmatter missing {missing}"
    if meta["status"] not in VALID_STATUS:
        return f"rejected: status must be one of {VALID_STATUS}"
    p.parent.mkdir(parents=True, exist_ok=True)
    tmp = p.with_name(p.name + ".tmp")
    tmp.write_text(content, encoding="utf-8")
    os.replace(tmp, p)
    audit("write", path, {"reason": reason, "bytes": len(content)})
    return f"ok: {path} ({len(content)} chars, reason recorded)"


@mcp.tool
def code_graph_query(question: str) -> str:
    """查代码知识图谱(graphify-out/graph.json),回答"谁调用谁、改哪会影响哪"。
    前提:先在 CODE_ROOT 跑过 graphify。"""
    graph = CODE_ROOT / "graphify-out" / "graph.json"
    if not graph.exists():
        return "no graph found: run `graphify update` in CODE_ROOT first"
    try:
        out = subprocess.run(
            ["graphify", "query", question],
            cwd=CODE_ROOT, capture_output=True, text=True, timeout=30,
        )
    except FileNotFoundError:
        return "graphify CLI not installed: uv tool install graphifyy"
    return (out.stdout or out.stderr)[:8000]


if __name__ == "__main__":
    mcp.run()   # stdio:本地 agent 够用,零部署

顺手交代一个调试时踩的坑:Path.rglob(".md") 匹配的是文件名恰好叫 .md 的文件,不是所有 Markdown——要写 rglob("*.md")。这个坑的 symptom 很有迷惑性:目录和搜索永远返回空,读单篇却正常,因为读不走 rglob。

这份代码的每个工具都用 FastMCP 的 Client 逐个调过一遍(FastMCP 4.1.0,Python 3.11),关键输出长这样:

SEARCH: decision/retry.md:2: title: 为什么重试上限定 3 次
WRITE no-frontmatter: rejected: frontmatter missing ['title', 'type', 'status', 'updated']
TRAVERSAL blocked: Error calling tool 'kb_read': path escapes KB_ROOT: ../../etc/passwd.md
WRITE ok: ok: runbook/deploy.md (85 chars, reason recorded)

审计流水也真的落了盘,kb/.kb_audit.jsonl 里一行一条:

{"ts": "2026-10-13T06:30:51.964687+00:00", "action": "write",
 "path": "runbook/deploy.md", "reason": "test", "bytes": 85}

五、接线:Codex、Cursor、Claude Code

三个客户端,三份配置,都是 stdio 直连。

Codex(~/.codex/config.toml):

[mcp_servers.kb]
command = "python3"
args = ["/Users/walter/kb/kb_mcp_server.py"]
env = { KB_ROOT = "/Users/walter/kb/docs", CODE_ROOT = "/Users/walter/src/myproject" }

Cursor(~/.cursor/mcp.json):

{
  "mcpServers": {
    "kb": {
      "command": "python3",
      "args": ["/Users/walter/kb/kb_mcp_server.py"],
      "env": {
        "KB_ROOT": "/Users/walter/kb/docs",
        "CODE_ROOT": "/Users/walter/src/myproject"
      }
    }
  }
}

Claude Code(一条命令):

$ claude mcp add kb --env KB_ROOT=/Users/walter/kb/docs \
    -- python3 /Users/walter/kb/kb_mcp_server.py

再在知识库的 AGENTS.md 里写一句软约束,把用法钉在 agent 的常驻上下文里:

## 知识库怎么用
- 查知识库一律走 kb_* 工具:先 kb_catalog 看目录,再 kb_search / kb_read。
- 引用结论必须给出 kb 里的文件路径。
- 写入用 kb_write,必须带 reason;删除不归你管,提给人类。

六、写护栏:防呆,不防贼

写工具是整套东西里最危险的一个,护栏也最多:

护栏 代码位置 挡住什么
路径白名单 safe_path:resolve 后 is_relative_to(KB_ROOT) ../../etc/passwd.md 这类越界访问
后缀限制 同上,只放行 .md 顺走配置文件、脚本
frontmatter 校验 kb_write 开头 无元数据的裸文档进库,污染目录层
大小上限 200 KB agent 把整段日志或生成物灌进知识库
原子替换 写 .tmp 再 os.replace 写一半断电留下半截文件
审计流水 每笔写一行 JSONL 事后说不清“这篇是谁改的、为什么改”
不提供删除 压根没有这个工具 删除是不可逆动作,走 git、让人拍板

得诚实地说清楚这套护栏的边界:它防呆,不防贼。 agent 理论上仍然可以用自带的文件工具绕过柜台直接改文件——MCP server 管不了协议之外的动作。真要硬约束,得靠客户端的沙箱(Codex 的 sandbox、Cursor 的权限设置),把直连文件系统的路封死,只留柜台这一扇门。柜台的价值不在绝对安全,在于两点:正门足够好用,agent 没动机走野路子;账本足够清楚,出了事能对上账。

还有一个升级选项:把 make html 挂进写入流程。Sphinx build 就是知识库的编译器,写入后自动 build 一次,Mermaid 写坏了当场报错——相当于给文档加了 CI。个人用没必要常开,定期跑一遍就够。

七、把代码分析工具的图也挂上柜台

知识库里除了人写的文档,还有一类“从代码里长出来的知识”——代码知识图谱。agent 在陌生代码库里 grep 的惨状我写过:问“重定向逻辑在哪处理的”,它 grep 出三十个文件读五个,发现读错了再 grep 一轮。治这个病的思路是同一个:别让它翻文件,让它查图。

柜台上的 code_graph_query 已经接了 graphify。它在柜台里的角色是常挂的免费地图:tree-sitter 纯本地解析,建图零 token,graphify-out/graph.json 常驻磁盘,agent 问“谁调用谁”,一个工具调用就回来,比 grep 又快又准。

另一个是 Understand-Anything,角色是按需的贵导游。它用多 agent 流水线把代码库解析成 .ua/knowledge-graph.json,LLM 给节点写人话摘要、生成导览、映射业务领域——解释力比纯 AST 强一档,但首次全库分析要烧不少 token。它的产物也可以挂上柜台,几行就够:

@mcp.tool
def ua_graph_summary() -> str:
    """读 Understand-Anything 图谱的摘要:节点数、边数、核心节点。"""
    g = CODE_ROOT / ".ua" / "knowledge-graph.json"
    if not g.exists():
        return "no graph: run /understand first"
    data = json.loads(g.read_text(encoding="utf-8"))
    return json.dumps({
        "nodes": len(data.get("nodes", [])),
        "edges": len(data.get("edges", [])),
        "top": [n.get("label", n.get("id")) for n in data.get("nodes", [])[:20]],
    }, ensure_ascii=False)
graphify Understand-Anything
解析方式 tree-sitter,纯本地 tree-sitter + LLM 多 agent
建图成本 零 token,秒级 首次全库分析烧 token
强项 结构关系:谁调用谁、改哪影响哪 人话解释:这个模块是干什么的
挂柜台方式 code_graph_query(本文已含) 读 .ua/knowledge-graph.json 的工具
适合 常挂 按需,接手新库或大改动前跑一次

老规矩,边界要讲清楚:图上的解释是阅读线索,不是签字证据。 LLM 写的节点摘要可以很通顺又漏掉真正决定行为的那一行,两个工具给的答案最后都要回源码核对——这个判断我在上一篇里展开过。另外 graphify 的中文查询我实测是失效的,问它请用英文。

八、边界与升级路径

这套最简方案的适用边界,和往哪儿升级:

现状 什么时候不够 升级方向
stdio 本地直连 想跨机器共享、给团队用 mcp.run(transport="http") 起服务,加鉴权;无状态部署的坑我写过
Python 正则搜索 文档涨到几万篇,扫描变慢 SQLite FTS5 → pgvector,三级检索递进见知识库工作流
单仓库知识库 业务拆成七八个仓库,知识落进缝里 联邦式领域知识库,见上一篇
五个工具 agent 开始组合出危险动作 加确认门:写 status: current 的文档先返回 diff 等人类点头

验收别用“能不能连上”这种弱标准,用 golden question:问一个只有你的知识库知道答案的问题(“我们重试上限为什么是 3 次”),agent 应该先 kb_catalog、再 kb_search、然后 kb_read 原文、引用时带路径——这条链走通了,柜台才算开张。

总结:柜台要小,账本要清

回到开头那句:别把仓库钥匙整个交给 agent,给它开个柜台。这套方案里最值钱的不是那五个工具,而是三条约束——路径进白名单,写入过校验,每笔留流水。工具可以加减,这三条别动。

全套加起来:一个 Sphinx 知识库(你多半已经有了),一百多行 Python,三份客户端配置。没有部署、没有数据库、没有向量库,grep 和目录在这一档规模下就是最便宜也最准的检索。

今天就能做的四件事

  1. 挑你最常被问的 10 篇文档,补上 frontmatter(title/type/status/updated 四个字段)。
  2. 把 kb_mcp_server.py 存下来,pip install fastmcp,KB_ROOT 指到你的 docs 目录,跑通 kb_catalog。
  3. 给你最常用的那个 agent 配上这份 MCP server,问一个 golden question 验收。
  4. 在 AGENTS.md 里写下“查库走 kb_*、引用带路径、删除提给人类”这三句,下周看看审计流水里有没有第一笔账。

全文思维导图

@startmindmap
<style>
mindmapDiagram {
  node {
    BackgroundColor #F8F9FA
    LineColor #64748B
    FontColor #172033
    RoundCorner 10
    Padding 10
    FontSize 13
  }
  :depth(0) {
    BackgroundColor #1E3A5F
    LineColor #1E3A5F
    FontColor white
    FontSize 18
    FontStyle bold
  }
  :depth(1) {
    FontSize 15
    FontStyle bold
  }
  :depth(2) {
    FontSize 13
  }
}
</style>

* 知识库 MCP 柜台
** 痛点
*** 看不见:库不在工作目录
*** 全看见:grep 烧 token、乱写无护栏
*** 解法:开柜台,不给钥匙
** 为什么是 MCP
*** 三家客户端共用一份
*** 工具 schema 自描述
*** 护栏收在 server,不散在 prompt
** 知识库一侧
*** Sphinx + MyST + Mermaid
*** Markdown 是唯一事实源
*** make html 就是编译器
** 柜台五个工具
*** kb_catalog 目录
*** kb_search 正则搜路径:行号
*** kb_read 整篇原文
*** kb_write 校验+原子写+审计
*** code_graph_query 查代码图
** 写护栏
*** 路径白名单 is_relative_to
*** frontmatter 四字段必填
*** 200KB 上限、原子替换
*** 审计 JSONL、不提供删除
*** 防呆不防贼,硬约束靠沙箱
** 代码图上柜台
*** graphify:免费地图,零 token 常挂
*** Understand-Anything:贵导游,按需
*** 图是指路不是作证,回源码核对
** 升级路径
*** stdio → http 加鉴权
*** grep → FTS5 → pgvector
*** 单库 → 领域联邦
*** 验收用 golden question
@endmindmap

知识库 MCP 柜台 - 思维导图


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。