Skip to content

项目 5 · 自己的 MCP Server ​

🎯 难度:中级⏱ 预计:1 天# FastMCP# stdio# ClientSession

把个人笔记能力包装成 MCP Server。先用自己的 Python Client 验证协议,不用大模型 Key;之后再连接支持 MCP 的 Host。

准备与目录 ​

bash
uv init notes-mcp --python 3.12
cd notes-mcp
uv add "mcp==1.12.4" "pydantic==2.11.7" "pydantic-settings==2.10.1"
text
notes-mcp/
├── server.py
├── client.py
└── notes/
    └── python.md

这是固定 SDK 1.x 与兼容依赖的课程;旧 MCP SDK 直接搭配最新 Pydantic 可能出现内部接口 ImportError,按上面的版本安装并保存 uv.lock。升级 2.x 时需要迁移 API。创建 notes 和 python.md,写一段不敏感的学习笔记。

服务端:列出与读取笔记 ​

python
from pathlib import Path
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("我的学习笔记")
ROOT = Path(__file__).resolve().parent / "notes"
if ROOT.is_symlink():
    raise ValueError("笔记根目录不允许符号链接")
ROOT.mkdir(exist_ok=True)
ROOT = ROOT.resolve()


def resolve_note(name: str) -> Path:
    if not 1 <= len(name) <= 100 or name.startswith("."):
        raise ValueError("非法笔记名")
    relative = Path(name)
    if relative.is_absolute() or len(relative.parts) != 1 or relative.suffix != ".md":
        raise ValueError("只允许 notes 下单个 Markdown 文件")
    file = ROOT / relative
    if file.is_symlink():
        raise ValueError("不允许符号链接")
    file = file.resolve()
    if not file.is_relative_to(ROOT) or not file.is_file() or file.stat().st_size > 20_000:
        raise ValueError("笔记不存在、越界或超过 20 KB")
    return file


@mcp.tool()
def list_notes() -> list[str]:
    """列出可以读取的学习笔记名称。"""
    result = []
    for path in sorted(ROOT.glob("*.md")):
        try:
            resolve_note(path.name)
        except (ValueError, OSError):
            continue
        result.append(path.name)
        if len(result) >= 100:
            break
    return result


@mcp.tool()
def read_note(name: str) -> str:
    """读取一篇学习笔记,name 必须来自 list_notes 返回的名称。"""
    return resolve_note(name).read_text(encoding="utf-8")


if __name__ == "__main__":
    mcp.run(transport="stdio")

客户端:握手、列工具、调工具 ​

python
import asyncio
import sys
from pathlib import Path
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main():
    server = Path(__file__).resolve().parent / "server.py"
    params = StdioServerParameters(command=sys.executable, args=[str(server)])
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()  # 协议握手
            response = await session.list_tools()
            print("工具:", [tool.name for tool in response.tools])
            notes = await session.call_tool("list_notes", {})
            print("笔记列表:", notes.content)
            result = await session.call_tool("read_note", {"name": "python.md"})
            if result.isError:
                raise RuntimeError("读取笔记失败,请检查 notes/python.md")
            for part in result.content:
                if part.type == "text":
                    print(part.text)

if __name__ == "__main__":
    asyncio.run(main())
bash
uv run client.py

客户端自动启动 server 子进程并关闭。使用 sys.executable 确保 Server 也使用当前项目的虚拟环境,而不是系统 Python。

连接 Host ​

不同产品 MCP 配置字段可能不同,以其设置界面为准。常见 JSON 形式如下,替换绝对路径:

json
{
  "mcpServers": {
    "learning-notes": {
      "command": "uv",
      "args": ["--directory", "/absolute/path/notes-mcp", "run", "server.py"]
    }
  }
}

GUI 启动的应用可能找不到终端的 PATH;必要时把 command 换成 uv 的绝对路径。不要在配置中填模型 Key,这个只读 Server 不需要它。

问 Host:「列出我的学习笔记,然后总结 python.md」。Host 应展示 MCP 工具调用。确认过哪些工具读取了内容,再验证总结。

验收与升级 ​

  • 协议客户端能列出两个工具并读到内容。
  • read_note("../.env") 和绝对路径被拒绝。
  • 不存在文件返回工具错误,而不是让整个 Server 崩溃。
  • Server 不向 stdout 打调试日志,否则会污染协议。

本版只有只读能力。增加写笔记前,先设计明确确认、文件名规则、冲突处理和备份。远程 HTTP 版本需要认证、访问控制与资源限制,不要把本地无认证 Server 直接暴露公网。

用心写给每一个想入门 Agent 开发的前端工程师