MCP协议实战:用Python从0到1开发AI智能体工具
上个月我在搭一个内部 AI 助手模型本身很聪明能拆解问题、能写代码可一旦涉及“帮我把工单状态改掉”“把这条记录写进数据库”“发个定时提醒”它马上就歇菜。真正上手做过后你才会意识到大模型只有“脑子”没有“手”所有外部动作都得靠外围工具替它完成。MCPModel Context Protocol模型上下文协议解决的就是这件事——它把“AI 智能体如何发现工具、如何调用工具、工具返回什么格式”全部标准化。这篇文章是我从 0 到 1 用 Python 开发 MCP 工具的完整记录包括协议原理、完整代码、本地验证、接入智能体的三种方式以及我实际踩过的五个坑适合所有想给 AI 智能体扩展能力的开发者。我尽量把话说得直接不绕弯子。你不需要先成为 AI 专家只要能写 Python 函数就能跟着做出一只可以挂进智能体的“手”。1. 先别急着写代码MCP 到底解决了什么问题1.1 智能体“有脑没手”的尴尬局面在接触 MCP 之前我做智能体扩展用过 Function Calling也就是常说的“工具调用”。这种方式本身没问题问题出在“每家都有自己的格式”。OpenAI 的函数参数声明是一套 JSON Schema 写法Google 的 Gemini 又略有不同换一个模型工具注册代码往往就得跟着改一遍。更要命的是当你要接的工具越来越多参数校验、错误处理、鉴权、日志、工具列表下发……每个应用都得自己造一套轮子。做过的人都知道这不是“写一个函数”的事而是一整套生命周期管理。MCP 的思路是把“模型和工具之间的对话规则”制度化。它不去管你的工具内部用什么语言、什么框架实现只约定好双方怎么打招呼、怎么报能力、怎么传参数、怎么返回结果。智能体应用只需要装一个支持 MCP 的客户端就能拿到服务器上注册过的所有工具并像调用本地函数一样去调用它们。1.2 “协议”两个字才是关键我见过有人在讨论 MCP 时问这属于软件协议还是硬件协议这里统一说清楚MCP 是软件协议而且是应用层的接口规范。它不像 API 网关那样负责流量转发也不像消息队列那样承担可靠传输。MCP 更像是一套“能力插座标准”——工具提供方把自己做成一个 MCP Server智能体应用作为 MCP Client两边按同一套协议说话相当于你家的电器只需要一个统一标准的插头不需要为每个品牌的插座单独做一套接口。它和“插件”也不是一回事。插件通常是平台私有的某个 AI 产品的插件只能在它自己的环境里跑绑定很深。MCP 是开放标准只要客户端支持这个协议就能接入你的服务。所以一次开发可以同时服务多个不同的智能体应用这是它和传统插件模式的本质差别。1.3 一次调用背后的四步握手MCP 沿用 JSON-RPC 2.0 的消息格式连接建立后消息顺序大致如下客户端发送initialize请求声明自己支持的协议版本和客户端能力。服务端返回capabilities告诉客户端自己支持哪些原语比如有没有 Tools、Resources、Prompts。客户端确认初始化完成发送notifications/initialized通知。客户端调用tools/list拉取工具列表。用户触发某个任务时客户端调用tools/call并传入参数。我把这些流程写出来是想让你在调试时有个底如果你在tools/list这一步就看不到工具后面所有调用都不可能成功。很多“接不上”的问题最后都能归因到协议握手这一步。2. 开发前的认知准备三种原语、传输层与 SDK 选型2.1 三种原语的分工Tools 干活、Resources 喂数据、Prompts 定话术MCP 有三类核心能力官方叫“原语”。刚开始学容易混我习惯这样记原语谁主动典型场景一句话类比Tools模型按需调用写文件、查天气、改数据库状态给智能体的“手”Resources客户端 / 用户读取提供只读数据比如文件内容、查询结果给智能体的“资料库”Prompts用户或工作流触发预置的提示词模板快速开始某类任务给智能体的“标准话术”简单说Tools 是“执行动作”Resources 是“提供上下文”Prompts 是“预设开场的提示词”。很多人第一次写 MCP 只知道 Tools把一些只读数据也包成 Tool 来调用。如果你的场景只是把数据拿给模型看、不产生副作用用 Resource 更合适。网上经常有人搜“mcp resource 实战”就是想搞明白这一点——Resource 和 Tool 的边界在于“读”和“写”的区别。2.2 stdio 与 Streamable HTTP本地和远程的两种“接法”MCP 支持两种主流传输方式选型直接决定部署形态。传输方式适合场景优点短板stdio本地开发、个人工具零部署客户端直接拉起子进程只能在同一台机器上用Streamable HTTP团队服务、低代码平台常驻服务可远程访问、可鉴权需要部署服务器要考虑安全和可用性我的建议是如果你只是自己电脑上用直接上 stdio如果要做成团队共用服务或者想接 Dify、Coze 这类平台让 MCP 工具跑成 HTTP 地址会更方便。另外提一句早期版本里还有 SSE 传输方式现在官方在逐步收敛到 Streamable HTTP 方案新项目直接按新版文档走即可不用纠结老接口。2.3 FastMCP 还是官方 SDK我的选择过程Python 生态里开发 MCP Server主要有两条路直接用官方mcp库或者用社区封装fastmcp。官方mcp库提供底层控制力但你得自己处理不少样板代码比如初始化握手、事件分发、请求处理。典型写法大概是from mcp.server import Server from mcp.server.stdio import stdio_server server Server(todo) # 需要自己注册 initialize、tools/list、tools/call 等处理器而 FastMCP 把这一切包了起来几行代码就能注册一个工具from fastmcp import FastMCP mcp FastMCP(todo) mcp.tool() def hello(name: str) - str: 向用户打招呼 return fhello {name}FastMCP 并不是另起炉灶的协议底层还是在用官方mcp库。我的选择是从 0 到 1 的教程项目、快速原型、内部小工具用 FastMCP 最省心如果要在公司框架里做深度定制、要自定义传输行为那再回到官方 SDK 做底层开发。3. 从 0 到 1写一个可运行的待办事项 MCP Server我挑“待办事项管理”做示例是因为它麻雀虽小五脏俱全有新增、有查询、有状态变更正好能把 Tools、Resources、Prompts 三种原语都覆盖到。这个项目做出来后你可以直接拿它当骨架把里面的函数换成自己的业务逻辑。3.1 环境准备先建项目目录和虚拟环境这一步别省后面装依赖、打日志、部署的时候你会感谢它的隔离性。mkdir mcp-todo cd mcp-todo python -m venv .venv source .venv/bin/activate # Windows 系统用.venv\Scripts\activate pip install fastmcp[cli] python -c import fastmcp; print(fastmcp.__version__)Python 版本建议 3.10 以上推荐 3.11 或 3.12。一方面是因为新版类型注解写起来更舒服另一方面是异步生态在 3.11 上更稳定。装好后能打印出版本号说明环境没问题。3.2 核心代码三个 Tool 把待办管理串起来在server.py里写如下代码from typing import Literal from fastmcp import FastMCP mcp FastMCP(todo) _todos [] _id 0 Priority Literal[low, medium, high] mcp.tool() def add_todo(content: str, priority: Priority medium) - dict: 添加一条新的待办事项。 当用户说“记一下”“帮我加个任务”“安排一件事”时调用。 返回创建后的待办条目。 Args: content: 待办的具体内容。 priority: 优先级可选值为 low、medium、high。 global _id _id 1 item { id: _id, content: content, priority: priority, done: False, } _todos.append(item) return item mcp.tool() def list_todos(status: str | None None) - list[dict]: 列出待办事项列表。 Args: status: 过滤条件pending 表示未完成done 表示已完成不传则返回全部。 if status pending: return [t for t in _todos if not t[done]] if status done: return [t for t in _todos if t[done]] return _todos mcp.tool() def complete_todo(todo_id: int) - dict: 将一条待办标记为已完成。 Args: todo_id: 待办条目 ID。 for t in _todos: if t[id] todo_id: t[done] True return t raise ValueError(f未找到 ID 为 {todo_id} 的待办) if __name__ __main__: mcp.run()启动python server.py程序会通过 stdio 监听协议消息。此时什么都没输出因为协议消息走的是 stdout正常情况不该有可见的打印。这段代码里有几个细节值得展开说。第一函数名和变量名我都写得很明确配合 docstring模型在决策“该调用哪个工具”时靠的就是这些信息。第二priority: Priority medium用了Literal在生成工具 schema 时会被转成一个枚举数组。这样模型不会给你传一个“very high”之类的非法值参数校验在协议层就完成了。第三当工具执行时报出ValueErrorMCP 框架会把它转成调用失败的返回客户端能看到明确的错误信息模型之后还有机会自我纠正。比闷声返回一个“{ok: false}”再让模型自己猜原因要强得多。这里再声明一下数据存在内存列表里进程一重启就没了。作为示例完全没问题它的价值是让你关注 MCP 本身的机制。真要生产用把_todos换成 SQLite 存储后面坑的部分我会专门讲并发问题。3.3 Resource 与 Prompt从“只能做事”到“能读资料、能套模板”继续在同一个文件里加 Resource 和 Promptmcp.resource(todo://current) def current_todos() - str: 返回当前所有待办的文本摘要适合作为上下文喂给模型。 lines [ f[{t[priority]}] {t[content]} - {完成 if t[done] else 待办} for t in _todos ] return \n.join(lines) if lines else 当前没有待办事项 mcp.prompt() def weekly_review() - str: 生成一次周度待办回顾的提示词模板。 return ( 请阅读用户的待办清单按优先级重新排序 圈出本周必须完成的 3 件事并给出推进建议。 )todo://current定义了一个自定义 scheme 的 Resource。客户端可以通过标准接口读取它把结果作为上下文注入对话这就是“给模型喂数据”的正规方式。weekly_review则是一个 Prompt 模板用户触发后直接变成一轮高质量对话的开场。如果你装的 FastMCP 版本较新Resource 的返回值类型和注册方式在细节上可能略有差异以你当前版本官方示例为准。核心思想不变模型需要的上下文走 Resource需要执行的动作走 Tool需要标准流程开场走 Prompt。3.4 用 Inspector 做协议级验证工具写没写对不要直接丢给智能体试错先上官方 Inspector 校验协议。npx modelcontextprotocol/inspector python server.py浏览器会自动打开 Inspector 界面。进去之后重点看三件事连接是否正常建立。能正确列出 Tools、Resources、Prompts说明握手阶段没有出错。选择add_todo手动填参数并调用。观察返回的 JSON 是否符合预期。切换列表里的工具把每个函数都点一遍看协议层有没有报错。Inspector 非常好用的一点是它会展示完整的 JSON-RPC 报文你甚至能回放调用记录。之前排查“客户端为什么找不到工具”“返回格式为什么解析失败”全靠它定位问题。这比直接在智能体应用里黑盒测试高效得多。4. 把工具装进智能体三种接入与验证方式4.1 标准客户端配置mcpServers 一行接入现在大多数桌面 AI 客户端都沿用了同一套mcpServers配置结构。你需要做的就是在客户端的配置文件里新增一个服务条目{ mcpServers: { todo: { command: python, args: [/绝对路径/server.py], cwd: /绝对路径 } } }配置完成后重启客户端然后在对话里输入“帮我加一条待办周五前提交周报优先级高”。如果配置正常模型会主动发现你注册的add_todo工具并完成调用。这里有两个很常见的翻车点。一是路径写成相对路径客户端工作的目录和你预期的不一致启动直接失败二是改完配置忘了重启工具列表一直不刷新。遇到“连接上了但工具不出现”的情况先检查这两条。4.2 远程 HTTP 部署接入 Dify、Coze 等平台的思路如果你希望 MCP 工具跑在服务器上或者接入 Dify、Coze 这类支持 MCP 的平台通常要把服务切到 Streamable HTTP 传输方式。FastMCP 依赖安装完整后可以这样启动python server.py --transport streamable-http或者你在代码里通过mcp.run(transportstreamable-http)指定传输方式具体参数以当前版本文档为准。启动后服务会监听一个 HTTP 地址路径上暴露/mcp之类的端点。接下来把它部署到一台有公网访问能力的服务器上并在服务前面加鉴权策略比如请求头里校验 API Token防止未授权访问。然后在平台的 MCP 工具配置里填入服务地址做一次连通性测试工具列表拉取成功后就完成了接入。真正常见的坑不在启动而在鉴权。很多平台发起的是带自定义 Header 的 POST 请求如果你的服务端只校验了路径没校验 Header或者跨域配置没放开客户端会得到一个看似“找不到工具”的结果。遇到这种情况先抓包看请求是否真正到达了服务端。4.3 最小客户端自测不信配置只看代码不管什么平台最可靠的接入验证还是直接写一个最小客户端调官方 SDK 连一次。下面是基于官方mcpSDK 的客户端示例import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main(): params StdioServerParameters( commandpython, args[server.py], ) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools await session.list_tools() print(发现工具:, [t.name for t in tools]) result await session.call_tool( add_todo, {content: 用 Python 完成 MCP 课程, priority: high}, ) print(result) if __name__ __main__: asyncio.run(main())运行后你会在终端看到发现工具: [add_todo, list_todos, complete_todo]然后是对应工具调用的返回结果。这个最小客户端的意义在于它剔除了所有平台干扰直接证明“我的 MCP Server 本身是通的”。如果这里都不通问题基本出在服务端。5. 实测中躲不开的五个坑从协议到数据的完整排错记录这一部分是我真正想分享的内容。MCP 的官方文档很干净但真实跑起来坑都在细节里。5.1 stdio 模式下的 print 是协议杀手第一次用 stdio 跑 MCP Server我习惯性地在工具函数里加了一个print()用来打印日志结果客户端直接失联。原因很简单stdio 传输的实现里stdout 被当成协议消息的专用通道你打一句“我进来了”客户端那边收到的就是一个无法解析的半截 JSON-RPC 报文。解决办法也很简单日志走sys.stderr或者统一用logging模块把 handler 指向sys.stderr。这样调试信息正常显示却不会污染 stdout 上的协议数据。import logging import sys logging.basicConfig(streamsys.stderr, levellogging.DEBUG)5.2 类型注解决定工具 schema模型拿到的是什么货工具函数的类型注解直接决定暴露给模型的 JSON Schema。你写mcp.tool() def add_task(content): # 缺少类型注解 ...生成出来的 schema 里参数没有明确的type模型只能靠猜。猜对了是运气猜错了就是数据校验失败。正确的做法是给每个参数都写完整类型能用Literal限制枚举值就用Literal能用str | None表达可选参数就别写“不传就默认 None”这种模糊描述。模型是拿你的 schema 在推理的你给它的材料越精确它输出的调用就越准。另外还有个冷门坑bool类型的参数pydantic 会把字符串no转成 True把0转成 False。如果你的业务参数语义不严格模型的“否”可能变成“是”。敏感业务场景能不用bool做参数就不用改传字符串枚举更稳。5.3 长耗时任务模型等不起我把一个耗时的文件处理逻辑直接写进了工具函数智能体调用后整整等了半分钟没响应。原因不在于 MCP 慢而在于模型侧普遍有响应超时限制。一个工具调用如果长时间不返回客户端会判断超时用户看到的就是“卡死”。更好的设计是“先受理、再回查”。工具一被调用立刻返回一个任务 ID比如mcp.tool() def start_sync() - dict: 启动一次异步同步立刻返回任务ID。 task_id create_task() return {task_id: task_id, status: running} mcp.tool() def sync_status(task_id: int) - dict: 查询同步任务的执行状态供模型多次查阅。 status get_task_status(task_id) return {task_id: task_id, status: status}模型先拿到一个“任务已受理”的即时响应再通过第二个工具轮询状态。整个过程没有长阻塞体验会好很多。5.4 共享状态在并发下的“互踩”本地演示时内存里的_todos数据没问题。可一旦通过 HTTP 暴露给多个客户端多个会话同时调用add_todo和complete_todo内存列表就会开始出现各种灵异事件ID 重复、数据丢失、读到半条写入的内容。这个问题在上线前不一定能暴露因为单测和 Inspector 都是串行调用。我的建议是生产级 MCP Server 的数据层不要用内存态直接落 SQLite 或正式数据库并做好事务控制。如果你只是想演示协议本身用内存列表没问题但要在文档和代码注释里写清楚——这是个有边界的玩具实现不是生产方案。5.5 工具描述模糊模型就“自由发挥”这一点最容易忽略影响却最大。模型不会读你的源码它决策用哪个工具靠的是函数名、docstring 和参数 schema。对比一下mcp.tool() def f(data: dict) - dict: 处理待办 ...和mcp.tool() def complete_todo(todo_id: int) - dict: 将一条待办标记为已完成。 当用户说“做完”“完成”“搞定某项任务”时调用。 Args: todo_id: 待办条目 ID。 ...后者让“该不该调用”变得非常清晰。我给内部服务写 MCP 工具时会专门要求每个 docstring 都写清楚三件事这个工具是干什么的、什么场景下调用、每个参数到底是什么意思。模型不是神你给它一份模糊的说明书它就只能自由发挥。待办事项这个项目做下来骨架虽小但把 MCP 的完整链路都串起来了。我现在的习惯是给任何系统加 MCP 能力之前先花半小时把工具边界和数据模型画清楚再动手写代码。每加一个工具都跑一遍 Inspector验证通过后再交给智能体用。这个“先服务端自测、再客户端接入”的顺序能帮你省下大量联调时间。你也可以把公司内部现有的 HTTP API 包一层 MCP 适配器让智能体通过统一接口调用老系统能力剩下的就是在一个个具体业务场景里慢慢把工具磨顺手了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →