开发技巧

Cursor:用官方 Python MCP SDK 写一个项目知识库 MCP 服务器

这篇用官方 Python MCP SDK 写一个项目知识库 MCP 服务器,通过 .cursor/mcp.json 接入 Cursor,让 Agent 可以检索和读取团队笔记(部署流程、接口规范等)。服务器提供:

BINBHO · 图文教程

这篇用官方 Python MCP SDK 写一个项目知识库 MCP 服务器,通过 .cursor/mcp.json 接入 Cursor,让 Agent 可以检索和读取团队笔记(部署流程、接口规范等)。服务器提供:

  • search_notes:多关键词检索,返回文件名、行号和该行内容;
  • read_note:读取笔记全文,只能读 notes/ 下的 .md 文件,防止越界;
  • 资源 notes://index:全部笔记的标题列表。

最后用真实安装的 Cursor CLI 加载这个服务器,并列出它的工具。

先看清成品(box 上真实运行)

  • 用 SDK 客户端通过 stdio 调用:检索“回滚”命中 deploy.md 第 4 行;越界读取被拒绝,错误提示原样返回给客户端。
  • Cursor CLI(2026.10.19-6bbf2c4)运行 agent mcp list-tools project-notes,列出了 read_note (name) 和 search_notes (query, limit)。
  • pytest 3 项通过。

合规提醒:Cursor 会把代码上下文发送给模型服务商处理。涉及公司代码时,先确认团队是否允许使用,并按需要开启 Privacy Mode。规则、hooks 和 MCP 配置会提交进仓库,里面不要写任何密钥,密钥用环境变量或 ${env:NAME} 引用。用 AI 生成的代码,版权和许可证合规由你负责,合并前要人工审查。本文不列 Cursor 的价格,额度和可用模型以官网为准。

运行说明:Cursor CLI 是真实安装的,mcp list、mcp enable 和 mcp list-tools 也都真实运行过,这几个命令不需要登录。但 box 上没有 Cursor 账号,所以没有让 Agent 在对话中真正调用这些工具(agent -p 会提示 Authentication required)。

做完之后你会得到什么?

成果 说明
notes_mcp.py MCP 服务器(stdio),基于 mcp 2.x 的 MCPServer
.cursor/mcp.json Cursor 的项目级 MCP 配置
notes/ 两篇示例笔记
demo_client.py 用 SDK 客户端模拟 Cursor 的调用过程
test_mcp.py 测试工具列表、检索与读取、越界读取被拦截

需要准备哪些环境?

python3 -m venv .venv && .venv/bin/pip install mcp pytest     # box 上装的是 mcp 2.3.0
curl https://cursor.com/install -fsS | bash                     # Cursor CLI(可选,用来验证配置)

MCP 配置要点(来自官方文档)

  • 配置文件:项目级 .cursor/mcp.json,全局 ~/.cursor/mcp.json。
  • 传输方式:stdio(Cursor 在本地启动进程,填 command、args、env)、SSE、Streamable HTTP(填 url)。
  • 配置里可以用插值:${env:NAME}、${userHome}、${workspaceFolder} 等,密钥用 ${env:...} 引用,不要直接写进文件。
  • CLI 子命令:agent mcp list、list-tools <名称>、enable <名称>、disable <名称>、login <名称>。

文件结构

54-cursor-C-src/
├── .cursor/mcp.json
├── notes_mcp.py
├── notes/
│   ├── deploy.md
│   └── api-style.md
├── demo_client.py
└── test_mcp.py

第 1 步:服务器

"""项目知识库 MCP 服务器(stdio):让 Cursor Agent 检索、读取 docs 目录里的团队笔记。
只读;路径限制在 NOTES_DIR 之内,防止读到仓库外的文件。基于官方 Python SDK(mcp 2.x 的 MCPServer)。"""
import os, pathlib, re
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

ROOT = pathlib.Path(os.environ.get("NOTES_DIR", pathlib.Path(__file__).with_name("notes"))).resolve()
server = MCPServer("project-notes")


def _safe(name: str) -> pathlib.Path:
    p = (ROOT / name).resolve()
    if not p.is_relative_to(ROOT) or p.suffix != ".md":
        # 2.x 里普通异常会被替换成“Error executing tool”,只有 ToolError 的提示会原样传给 Agent
        raise ToolError(f"只能读取 {ROOT.name}/ 下的 .md 文件:{name}")
    return p


@server.tool()
def search_notes(query: str, limit: int = 5) -> list[dict]:
    """按关键词(空格分隔,全部命中)搜索团队笔记,返回文件名、命中行号和该行内容。"""
    terms = [t.lower() for t in query.split() if t]
    hits = []
    for f in sorted(ROOT.rglob("*.md")):
        for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
            if terms and all(t in line.lower() for t in terms):
                hits.append({"file": str(f.relative_to(ROOT)), "line": i, "text": line.strip()[:200]})
    return hits[:limit]


@server.tool()
def read_note(name: str) -> str:
    """读取一篇笔记全文,name 为 search_notes 返回的 file。"""
    return _safe(name).read_text(encoding="utf-8")


@server.resource("notes://index")
def index() -> str:
    """全部笔记的标题列表。"""
    out = []
    for f in sorted(ROOT.rglob("*.md")):
        first = next((l for l in f.read_text(encoding="utf-8").splitlines() if l.startswith("#")), f.stem)
        out.append(f"- {f.relative_to(ROOT)}:{first.lstrip('# ')}")
    return "\n".join(out)


if __name__ == "__main__":
    server.run()   # 默认 stdio

第 2 步:Cursor 配置

{
  "mcpServers": {
    "project-notes": {
      "command": ".venv/bin/python",
      "args": ["notes_mcp.py"],
      "env": { "NOTES_DIR": "notes" }
    }
  }
}

第 3 步:测试与演示

import asyncio, json, sys, pathlib
from mcp import Client, StdioServerParameters

HERE = pathlib.Path(__file__).parent


def run(coro):
    return asyncio.run(coro)


async def session(fn):
    params = StdioServerParameters(command=sys.executable, args=[str(HERE / "notes_mcp.py")])
    async with Client(params) as c:
        return await fn(c)


def text(r):
    return "".join(getattr(b, "text", "") for b in r.content)


def test_tools_listed():
    names = run(session(lambda c: c.list_tools()))
    assert {t.name for t in names.tools} == {"search_notes", "read_note"}


def test_search_and_read():
    async def go(c):
        r = await c.call_tool("search_notes", {"query": "page_size"})
        hits = r.structured_content["result"] if r.structured_content else json.loads(text(r))
        body = await c.call_tool("read_note", {"name": hits[0]["file"]})
        return hits, text(body)
    hits, body = run(session(go))
    assert hits[0]["file"] == "api-style.md" and "ISO 8601" in body


def test_path_traversal_blocked():
    r = run(session(lambda c: c.call_tool("read_note", {"name": "../notes_mcp.py"})))
    assert r.is_error and "只能读取" in text(r)
"""用官方 SDK 的客户端,以 stdio 方式启动 notes_mcp.py,模拟 Cursor 调用工具的过程。"""
import asyncio, sys, pathlib
from mcp import Client, StdioServerParameters


async def main():
    p = StdioServerParameters(command=sys.executable, args=[str(pathlib.Path(__file__).with_name("notes_mcp.py"))])
    async with Client(p) as c:
        tools = await c.list_tools()
        print("工具:", [t.name for t in tools.tools])
        r = await c.call_tool("search_notes", {"query": "回滚"})
        print("search_notes('回滚') →", r.structured_content)
        r = await c.read_resource("notes://index")
        print("notes://index →\n" + r.contents[0].text)
        r = await c.call_tool("read_note", {"name": "../.cursor/mcp.json"})
        print("越界读取 →", r.is_error, r.content[0].text)

asyncio.run(main())

第 4 步:运行

$ python demo_client.py
工具: ['search_notes', 'read_note']
search_notes('回滚') → {'result': [{'file': 'deploy.md', 'line': 4, 'text': '- 回滚命令:make rollback VERSION=<上一个 tag>'}]}
notes://index →
- api-style.md:接口规范
- deploy.md:部署流程
越界读取 → True Error executing tool read_note: 只能读取 notes/ 下的 .md 文件:../.cursor/mcp.json

$ agent mcp list
project-notes: not loaded (needs approval)
$ agent mcp enable project-notes
✓ Enabled and approved MCP server: project-notes
$ agent mcp list-tools project-notes
Tools for project-notes (2):
- read_note (name)
- search_notes (query, limit)
$ agent mcp list
project-notes: ready

$ python -m pytest -q
3 passed in 2.06s

调试示例

在这一篇里接连遇到了四个问题,按出现顺序记录如下。

1. 越界读取的错误提示不见了。 第一版用 raise ValueError(...) 拒绝越界读取,客户端收到的却只有 Error executing tool read_note。原因是 mcp 2.x 会把普通异常当作“程序崩溃”,只把原文写进服务器日志,不发给客户端。改用 mcp.server.mcpserver.exceptions.ToolError 之后,提示才原样传给了 Agent。

2. ${workspaceFolder} 没有被展开。 一开始我照着文档在 args 里写了 ${workspaceFolder}/notes_mcp.py,结果 agent mcp list-tools 报错:

python: can't open file '/tmp/p/54C/${workspaceFolder}/notes_mcp.py': [Errno 2] No such file or directory

也就是说,这个版本的 CLI(2026.10.19-6bbf2c4)没有展开这个变量。文档写的是支持插值,所以这可能是 CLI 和编辑器之间的差异,本文没有在编辑器里验证。改成相对路径 notes_mcp.py 后,在项目根目录运行就没问题了。

3. 改了配置,审批就失效了。 改完 mcp.json 后再运行 list-tools,提示 has not been approved。可见审批是跟配置内容绑定的,配置一变就要重新运行 agent mcp enable。这是一个合理的安全设计:别人改了你的 MCP 启动命令,不会被自动信任。

4. 找不到 mcp 模块。 "command": "python3" 找到的是另一个虚拟环境里的 Python,于是报 ModuleNotFoundError: No module named 'mcp'。改成项目自己的 .venv/bin/python 之后,状态变成了 ready。

常见问题

现象 原因 处理
not loaded (needs approval) 还没有审批,或者配置被改动过 agent mcp enable <名称>,或者在编辑器里批准
Connection closed 服务器启动时报错 看错误输出里的 stderr,先在终端里手动运行一次
工具报错,但看不到原因 抛出的是普通异常 预期内的错误一律抛 ToolError
读到了不该读的文件 没有限制路径范围 先 resolve(),再用 is_relative_to(ROOT) 检查

模板规则

  • MCP 服务器默认只读,并且限定可访问的目录。
  • 配置里用项目自己的解释器,不写死机器上的路径,密钥用 ${env:...} 引用。
  • 预期内的错误用 ToolError 返回,让 Agent 看得懂。

项目资源

用途 地址
Cursor 文档索引(llms.txt) https://cursor.com/llms.txt
Cursor MCP 文档 https://cursor.com/docs/mcp
Cursor CLI 参数 https://cursor.com/docs/cli/reference/parameters
MCP Python SDK https://github.com/modelcontextprotocol/python-sdk
Model Context Protocol 官网 https://modelcontextprotocol.io/

团队笔记接成 MCP 之后,Agent 回答“怎么部署”“接口格式是什么”这类问题时,会去查你们自己的文档,而不是凭空猜测。