这篇用官方 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 回答“怎么部署”“接口格式是什么”这类问题时,会去查你们自己的文档,而不是凭空猜测。