news 2026/10/1 7:41:01

MCP实战:用 Python + FastMCP 从零写一个可调试的 MCP 服务(附源码)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MCP实战:用 Python + FastMCP 从零写一个可调试的 MCP 服务(附源码)

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 | sh

Windows 用 PowerShell:

powershell -c "irm https://astral.sh/uv/install.ps1 | iex"

装完后uv --version能打印版本就说明成功。接下来初始化项目目录:

uv init obsidian-mcp-python cd obsidian-mcp-python

uv 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.lock

uv.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写错路径,也不会污染你的正式笔记。调试完再切回去,稳。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 7:41:01

Codex CLI 实战指南:Node.js 版本、tmux 代理与调用链深度解析

1. OpenRig 是什么:一个被误读的开源项目名与真实技术定位OpenRig 这个词在当前中文技术社区里,正经历一场典型的“语义漂移”——它既不是官方发布的知名开源项目,也不是某个主流框架的代号,而更像是一组高频共现关键词在搜索引擎…

作者头像 李华
网站建设 2026/10/1 7:40:17

2026年大模型本地部署指南:硬件选型、推理框架与工具实操深度评测

1. 为什么要在 2026 年重新聊本地部署聊大模型本地部署,其实不需要再讲“隐私有多重要”“数据不能出内网”这类大道理了——2026 年还纠结这些问题的人,大概率已经被企业内部的知识库项目、代码助手私有化、或者个人折腾 AI 写作折腾到头皮发麻&#xf…

作者头像 李华
网站建设 2026/10/1 7:39:24

GEO优化服务商成功案例多不多

深夜的厂房里灯还亮着,一位经营了十几年零部件工厂的企业主,头一回认真地向AI提问自己公司的名字。屏幕上给出的答案让他沉默了——产能数据不对,主营产品被写得似是而非,连承接的业务范围都出现了张冠李戴的描述。他换了好几家大…

作者头像 李华
网站建设 2026/10/1 7:38:27

从BeautifulSoup4到Scrapy:解析库与爬虫框架的选型实战

聊到Python网络爬虫,绕不开Scrapy和BeautifulSoup4这两个名字。我最早接触爬虫时也纠结过:到底学哪个?后来真做了几年爬虫项目,才明白这俩压根不在一个维度——BeautifulSoup4是一把趁手的解析工具,Scrapy是一整套能自…

作者头像 李华