1. 为什么我要用 Python + FastMCP 手写一个 MCP 服务
MCP 这个词最近半年在开发者圈子里出现频率极高,但真正动手写过一个能跑起来的 MCP 服务的人其实不多。大部分文章停留在概念科普:什么是 Host、Client、Server,什么是 tools、resources、prompts,传输层又分 stdio 和 SSE。看完之后你大概知道 MCP 是"让大模型调用外部能力的协议",但真要自己写一个,还是会卡在第一步——从哪开始?
MCP(Model Context Protocol)本质上是一套约定好的通信协议,让 LLM 客户端(比如 Cursor、Claude Desktop、Cline)能够发现并调用你本地或远程定义的工具函数。它解决的问题很实际:模型本身只会生成文本,但通过 MCP,它可以真正去读你的文件、查你的数据库、调你的内部 API。适合谁?适合所有想让 AI 助手接入自己业务数据的后端、全栈、工具链开发者。
我选择 Python + FastMCP 而不是 JS/TS 路线,原因有三个。第一,Python 生态里处理文件、数据、脚本任务天然顺手,很多内部工具本来就是 Python 写的,直接包一层 MCP 就能复用。第二,FastMCP 是官方维护的 Python 框架,用装饰器风格注册工具,代码量极少,一个@mcp.tool()就是一个可被模型调用的能力。第三,调试成本低,stdio 模式下直接在终端就能验证请求响应,不需要额外起 HTTP 服务。
这篇我会带你从零走完一遍:用 uv 初始化项目、写一个操作本地 Markdown 库的 MCP 服务、注册三个工具函数、用 stdio 启动、再用客户端配置调用验证。源码全部可复制,配置片段路径和原文一致,你跟着敲一遍就能跑通。过程中我会把踩过的坑和常见报错一起讲清楚,避免你卡在 401 或者 local proxy failed 上浪费时间。
2. 环境准备:uv 初始化项目与 FastMCP 依赖安装
先说工具选择。Python 项目管理我一直推荐 uv,它类似 JS 生态里的 npm/pnpm,但速度更快,还能顺带管理 Python 版本和虚拟环境。对前端转过来的同学,理解虚拟环境可以类比成"每个项目独立的 node_modules + 指定 node 版本",不同项目之间依赖互不干扰。
第一步,安装 uv。macOS/Linux 下一条命令:
curl -LsSf https://astral.sh/uv/install.sh | shWindows 用 PowerShell:
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"装完后uv --version能打印版本就说明成功。接下来初始化项目目录:
uv init obsidian-mcp-python cd obsidian-mcp-pythonuv init会生成一个基础的pyproject.toml和main.py。然后创建虚拟环境并激活:
uv venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate激活后命令行前面会出现(.venv)前缀。接着安装 MCP 官方包,它内置了 FastMCP:
uv add "mcp[cli]"这条命令会把依赖写进pyproject.toml并锁定版本。装完后你的pyproject.toml大概长这样,注意requires-python和依赖段:
[project] name = "obsidian-mcp-python" version = "0.1.0" description = "A debug-friendly MCP server for Obsidian markdown vault" readme = "README.md" requires-python = ">=3.10" dependencies = [ "mcp[cli]>=1.2.0", ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build"这里有个容易忽略的点:FastMCP 依赖 Python 3.10 及以上,如果你系统默认是 3.9,uv venv时可以显式指定版本,比如uv venv --python 3.11。我试过在 3.9 上跑,导入mcp.server.fastmcp会直接报语法错误,排查半天才发现是版本问题。
依赖装好后,项目骨架就齐了。目录结构大致是:
obsidian-mcp-python/ ├── .venv/ ├── main.py ├── pyproject.toml └── uv.lockuv.lock是锁文件,保证团队协作时依赖版本一致,类似package-lock.json。到这里前置工作结束,下一节开始写真正的服务代码。
3. 可复制配置:FastMCP 服务源码与工具函数注册
现在写核心的main.py。这个 MCP 服务的功能是操作本地 Obsidian 库里的 Markdown 文件,提供三个工具:统计文件数量、读取全部内容、新建文件。完整源码如下,你可以直接复制:
import os import glob from mcp.server.fastmcp import FastMCP mcp = FastMCP("obsidian-mcp-python") # 从环境变量读取 Obsidian 根目录,默认当前目录 OBSIDIAN_ROOT = os.environ.get("OBSIDIAN_PATH", ".") @mcp.tool() def count_markdown_files() -> int: """获取 Obsidian 库中所有 Markdown 文件的数量""" md_files = glob.glob(os.path.join(OBSIDIAN_ROOT, "**/*.md"), recursive=True) return len(md_files) @mcp.tool() def get_all_markdown_contents() -> list: """获取 Obsidian 库中所有 Markdown 文件的内容""" md_files = glob.glob(os.path.join(OBSIDIAN_ROOT, "**/*.md"), recursive=True) result = [] for file_path in md_files: try: with open(file_path, "r", encoding="utf-8") as f: content = f.read() relative_path = os.path.relpath(file_path, OBSIDIAN_ROOT) result.append({"file": relative_path, "content": content}) except Exception as e: result.append({ "file": os.path.relpath(file_path, OBSIDIAN_ROOT), "error": str(e), }) return result @mcp.tool() def create_markdown_file(filename: str, content: str = "") -> dict: """在 Obsidian 库中创建一个新的 Markdown 文件 Args: filename: Markdown 文件名(不需要 .md 后缀,会自动添加) content: 文件的初始内容(可选) """ if not filename.endswith(".md"): filename += ".md" file_path = os.path.join(OBSIDIAN_ROOT, filename) if os.path.exists(file_path): return {"success": False, "error": f"文件 {filename} 已存在"} try: os.makedirs(os.path.dirname(file_path), exist_ok=True) with open(file_path, "w", encoding="utf-8") as f: f.write(content) return {"success": True, "path": os.path.relpath(file_path, OBSIDIAN_ROOT)} except Exception as e: return {"success": False, "error": str(e)} if __name__ == "__main__": print(f"Obsidian MCP 服务已启动,使用路径: {OBSIDIAN_ROOT}") mcp.run(transport="stdio")几个关键点解释一下。FastMCP("obsidian-mcp-python")里的字符串是服务名,客户端配置里会用到。@mcp.tool()装饰器把普通函数注册成模型可调用的工具,函数的 docstring 非常重要——它就是模型判断"什么时候该调这个工具"的依据,所以描述要写清楚。参数类型注解(filename: str)也会被 FastMCP 解析成工具的输入 schema。
OBSIDIAN_ROOT从环境变量读取,这样同一份代码可以指向不同的库,不用改代码。mcp.run(transport="stdio")表示用标准输入输出通信,这是本地调试最省事的方式,客户端通过子进程启动这个脚本,用 stdin/stdout 交换 JSON-RPC 消息。
接下来是客户端配置。以 Cursor 为例,在 MCP 配置里加上这段 JSON,路径改成你自己的:
{ "mcpServers": { "obsidian-mcp-python": { "command": "uv", "args": [ "--directory", "/Users/ran/Code/Github/zhangran/obsidian-mcp-python", "run", "main.py" ], "env": { "OBSIDIAN_PATH": "/Users/ran/Documents/ObsidianNotes" } } } }这里三件套要记牢:Base URL 概念在 stdio 模式下对应的是command+args(即启动命令),Key 对应的是环境变量里的路径配置,Model ID 对应的是服务名obsidian-mcp-python。stdio 模式没有网络地址,所以"Base URL"就是那条启动命令。如果你用的是 Cline 或者 Claude Code,配置结构类似,只是字段名可能叫mcpServers或servers,把这三件套对应填进去即可。
4. 验证请求:stdio 启动与一次完整的请求响应流程
配置写好后,先别急着在客户端里点,我们直接在终端验证服务能不能起来。进入项目目录,激活虚拟环境,手动跑一次:
uv run main.py如果看到Obsidian MCP 服务已启动,使用路径: ...这行输出,说明服务进程正常启动并阻塞在 stdio 上等待消息。这时候它不会退出,因为它在等客户端发 JSON-RPC 请求。按 Ctrl+C 结束。
更规范的验证方式是用 MCP 官方的 CLI 工具,它内置了 inspector:
uv run mcp dev main.py这条命令会启动一个本地调试界面,自动列出你注册的所有工具,还能手动填参数调用。打开后你能看到count_markdown_files、get_all_markdown_contents、create_markdown_file三个工具,点进去填参数就能看到返回结果。这是排查工具逻辑最快的方式,不用绕客户端。
如果你想纯命令行验证,可以手动构造一个 JSON-RPC 请求通过管道喂给服务:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | uv run main.py正常会返回类似这样的响应,列出所有工具及其 schema:
{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [ {"name": "count_markdown_files", "description": "获取 Obsidian 库中所有 Markdown 文件的数量", "inputSchema": {"type": "object", "properties": {}}}, {"name": "get_all_markdown_contents", "description": "获取 Obsidian 库中所有 Markdown 文件的内容", "inputSchema": {"type": "object", "properties": {}}}, {"name": "create_markdown_file", "description": "在 Obsidian 库中创建一个新的 Markdown 文件", "inputSchema": {"type": "object", "properties": {"filename": {"type": "string"}, "content": {"type": "string"}}}} ] } }看到这个响应,说明服务注册、协议握手、工具暴露全部正常。然后在 Cursor 的 AI 会话里,你直接说"帮我统计一下 Obsidian 里有多少篇笔记",模型会自动发现可用的 MCP 工具,判断该调count_markdown_files,执行后把结果返回给你。再让它"新建一篇叫 test 的笔记,内容是 hello",它会调create_markdown_file,成功后你打开 Obsidian 就能看到文件已经写进去了。
整个链路是:客户端启动子进程 → 发送tools/list发现工具 → 模型决策 → 发送tools/call执行 → 返回结果。stdio 模式下这些消息都走标准输入输出,所以调试时你能在终端看到完整的请求响应日志。
5. 本篇常见报错排查:401、local proxy failed 与 reading choices
实际跑的时候,报错基本集中在这几类,我按遇到频率排一下。
第一类,401 Unauthorized或invalid api key。如果你用的是远程 MCP 服务或者接了某个模型网关,401 通常意味着 Key 没配对。检查环境变量里 Key 是否拼写正确、有没有多余空格、是否过期。stdio 本地服务一般不会 401,出现这个多半是你在服务内部又去调了外部 API 而 Key 失效。解决方式是把 Key 放到环境变量里,别硬编码,然后重启客户端让配置生效。
第二类,local proxy failed或connection refused。这个报错说明客户端尝试连接 MCP 服务但连不上。stdio 模式下常见原因是command路径不对,比如uv不在系统 PATH 里,客户端找不到可执行文件。解决办法是用绝对路径,比如把"command": "uv"改成"command": "/Users/你的用户名/.local/bin/uv"。另一个原因是--directory指向的目录不存在,或者main.py文件名写错。逐项核对配置里的路径即可。
第三类,Error reading choices或unexpected token。这类是 JSON 解析错误,通常发生在服务往 stdout 打印了非协议内容。比如你在main.py里用了print()输出调试信息,而 stdio 模式下 stdout 是协议通道,任何多余输出都会污染消息流,导致客户端解析失败。记住:调试信息一律用sys.stderr.write()或logging输出到 stderr,stdout 只留给 MCP 协议。我踩过这个坑,加了一行 print 之后客户端直接报 reading choices 错误,删掉就好了。
第四类,OAuth相关报错。如果你接的是需要 OAuth 的远程服务,会看到 token 过期或 scope 不足的提示。本地 stdio 服务不涉及 OAuth,遇到这个说明你配置的是远程 MCP。处理方式是重新走一遍授权流程,或者检查 token 是否被撤销。
第五类,工具调用返回空或报KeyError。这通常是环境变量OBSIDIAN_PATH没传进去,导致OBSIDIAN_ROOT用了默认的.,扫不到文件。在配置的env段里确认路径存在且有读权限。另外create_markdown_file如果目录层级很深,os.makedirs的exist_ok=True能保证不报错,但路径拼接时注意别用绝对路径覆盖根目录。
排查顺序建议:先看服务能不能手动uv run main.py起来,再看tools/list有没有正常返回,最后才去客户端里点。分层定位能省很多时间。
6. 从本地调试到长期编码:把 MCP 服务接进你的工作流
服务跑通只是第一步。真正让它产生价值,是把它接进你日常的编码和知识管理流程里。比如我现在的用法是:Obsidian 库作为长期知识沉淀,MCP 服务作为桥梁,让 AI 助手能直接读写这些笔记。写代码时遇到需要查自己之前记录的方案,直接问助手,它会调get_all_markdown_contents去检索,比手动翻文件快得多。
如果你要长期跑这类编码和 Agent 任务,建议把模型调用统一走一个稳定的入口,避免每个工具各自配 Key 导致管理混乱。TaoToken 提供了兼容的 API 入口,模型对话可以在 https://taotoken.net/api 直接调,接入文档在 https://taotoken.net/doc 有完整说明。需要生成和管理 Key 的话去 https://taotoken.net/api-keys,长期编码或 Agent 场景可以看 Coding Plan,想先验证模型效果就去 模型对话 试一下。
回到 MCP 本身,下一步你可以扩展的方向很多:把工具从文件操作扩展到数据库查询、内部 API 调用、日志检索;把 stdio 换成 SSE 让远程客户端也能连;给工具加上更精细的参数校验和错误处理。FastMCP 的装饰器模式让这些扩展都很轻量,加一个@mcp.tool()就是一个新能力。
最后留一个实用技巧:把main.py里的OBSIDIAN_ROOT默认值改成一个测试目录,先在测试目录里跑通所有工具,确认逻辑没问题再指向真实库。这样即使create_markdown_file写错路径,也不会污染你的正式笔记。调试完再切回去,稳。