首页 / 资讯中心 / 文章详情

MCP协议实战:从零开发AI工具与FastMCP应用

MCP协议实战:从零开发AI工具与FastMCP应用 ★ FEATURED ARTICLE
先把话说在前面MCP 协议Model Context Protocol模型上下文协议最近在 AI 应用开发圈子里的热度几乎可以用“刷屏”来形容。凡是做 Agent、做 AI 插件、做企业内部 Copilot 的人都绕不开这个词。简单说它是一套标准化协议负责让 AI 模型“接上”外部工具和数据源解决的是大模型没法直接读文件、查数据库、调 API 的尴尬。再加上工具开发这个落地点基本上就是把“会说话的模型”变成“会干活的助手”的关键一步。这篇文章我打算直接以实操为主围绕 MCP 协议的核心设计、工具开发流程、联调技巧和常见坑位展开。无论你是刚接触协议的小白还是已经写过一个简单 Server 但被参数校验折磨过的开发者都能从中找到可以直接照搬的步骤。我会用 Python 生态作为主力演示环境附带协议层面的原理解读争取让你看完之后能独立写出一个干净、好用、敢上线的 MCP 工具。1. 先搞懂 MCP 协议的整体设计1.1 MCP 到底解决什么问题在 MCP 出现之前想让 AI 调用一个工具整体路径是相当痛苦的。模型厂商和工具提供方各自定义各自的接口A 平台用 REST 自定义鉴权B 平台用 WebSocket 私有消息格式C 平台干脆只支持内置函数调用。开发者每接入一个平台都要从头实现一轮“适配层”把工具的入参、出参、错误码、认证方式全部翻译成目标平台能懂的语言。这个场景其实很像早年间的硬件外设打印机有打印机的驱动扫描仪有扫描仪的驱动键盘鼠标各搞一套。直到 USB 接口统一了外设连接标准大家才从“每买一个设备就要装一套驱动”的噩梦中解放出来。MCP 就是在 AI 工具集成领域扮演这个 USB 的角色它把“AI 应用怎么发现工具”“怎么描述工具参数”“怎么发起调用”“怎么返回结果”这几个环节全部标准化。只要你的工具实现了 MCP 协议任何支持 MCP 的 AI 客户端都能直接使用不需要再为不同的客户端分别写适配代码。MCP 的核心是一个客户端-服务端模型。AI 应用本身就是 Host负责承载整个会话和模型交互应用内部再启动一个 MCP Client用于连接外部的 MCP Server。真正干活的工具则运行在 Server 侧通过标准协议暴露给客户端。如果你的工具只在本机使用可以用 stdio 管道通信如果希望远程让团队共享也有 HTTP SSE 的传输方案。整个协议基于 JSON-RPC 2.0 的消息格式请求、响应、通知三类消息构成了全部的通信语义。1.2 四类核心角色与三类原语MCP 协议里有几个概念需要在一开始就建立正确印象否则后面写工具时会混乱。第一是 Host。Host 是用户直接面对的应用程序比如某个 AI 桌面客户端、某款支持 MCP 的 IDE 插件它负责创建会话、管理多个 Client 连接并决定哪些工具可以被模型看到。第二是 Client。Client 是 Host 内部与 Server 建立一对一定向连接的组件负责协议握手和消息转发。第三是 Server。Server 是工具提供方它暴露能力给 Client本身不关心最终用户是谁。第四是 Tool 本身。Tool 是真正可执行的函数单元有名字、有描述、有参数声明、有返回值。在 Server 侧除了 Tool 之外还有两个容易混淆的原语Resource 和 Prompt。Tool 是“可以执行的动作”比如发一封邮件、查询订单状态、写一条数据库记录Resource 是“可以读取的数据”比如一个 CSV 文件、一份配置、一张图片Prompt 是“可复用的会话模板”它可以封装一套精心设计的提示词让客户端在特定场景下直接套用。三者的定位差异用一句话概括Tool 负责“操作”Resource 负责“供给”Prompt 负责“引导”。我在实际项目中踩过的一个典型误区就是把所有能力都塞进 Tool。比如想知道某个文件是否存在本来应该用 Resource 暴露文件系统结果有人写了一个 check_file_exists 工具。乍一看没毛病但模型在面对开放场景时会很别扭你没法在对话中直接“引用”一个还没有被加载的资源模型只能靠猜。正确做法是能被检索和引用的静态数据用 Resource需要参数化执行的动作才用 Tool。这个区分越清晰模型的行为就越稳定。1.3 一次完整调用的生命周期理解了角色之后有必要把一次标准工具调用的完整流程走一遍。整个过程看起来简单但每一步都有协议约束。第一步Client 与 Server 建立传输通道。本地场景通常是启动子进程并打开 stdin/stdout 管道远程场景则是建立 HTTP 连接。第二步Client 发送 initialize 请求携带协议版本、客户端能力说明和 Client 信息Server 返回协议版本、服务端能力说明和 Server 信息。这里最关键的字段是 capabilities它表明双方各自支持哪些特性后续所有消息都必须在这个能力范围内活动。第三步双方发送 initialized 通知表示初始化完成。第四步Client 发送 notifications/initialized 之后开始发送 tools/list 请求Server 返回当前可用的工具列表每个工具都附带一份 JSON Schema 格式的参数声明。第五步用户或模型决定调用某个工具Client 发送 tools/call 请求Server 执行工具并返回结构化结果。整个调用链路与我平时调试 REST 接口最大的不同在于MCP 是双通道的。Client 和 Server 在初始化时可以协商出多种能力比如采样能力、根目录能力、日志能力。这些能力的加入使得 Server 不再只是一个“被调用方”它甚至可以在特定条件下向 Client 发起请求比如请求模型帮忙补全一段文本。这种双向交互设计是传统 API 协议里少见的也是 MCP 能支持复杂 Agent 场景的重要原因。2. 开发前的准备协议细节与工程选型2.1 官方 SDK 怎么选不同类型的团队、不同语言栈选择 SDK 的策略完全不一样。目前官方维护的 SDK 覆盖 Python、TypeScript、Java、Kotlin、C#、Go 等主流语言。如果团队本身就是做 AI 应用大概率已经有了 Python 或 TypeScript 的基础设施选官方 SDK 是最稳妥的路径。Python 生态里我默认推荐一个上层封装FastMCP。它是基于官方低层 MCP SDK 构建的高层框架把协议细节封装成了装饰器风格。你用 FastMCP 定义一个工具本质上就是写一个普通函数加一个 mcp.tool() 装饰器再配上类型注解和 docstring参数的 JSON Schema 会自动生成。对于大部分业务工具来说这种开发体验几乎是最快的也很适合快速验证想法。TypeScript 生态也有对应的 FastMCP 移植版用法与 Python 版本保持了一致的装饰器风格。如果你的团队前端技术栈更成熟选 TypeScript 版本完全没问题。而对于那些需要深度定制协议行为的场景比如自定义传输层、实现复杂鉴权、处理流式输出建议直接用低层 SDK不要用高层封装。FastMCP 做 90% 的标准场景都很舒服但遇到极端定制需求时它替你封装掉的那部分反而会变成你不容易绕开的墙。2.2 工具声明的关键点inputSchema 决定一切写过一段时间 MCP 工具的人都会认同一个结论工具能不能被模型正确使用七成功劳取决于 inputSchema 写得好不好。inputSchema 就是工具的 JSON Schema 参数声明模型会阅读这个声明来决定调用哪个工具、传入什么参数、参数取什么值。这里有一个初学者最常见的失误把参数描述写得过于简单。比如一个发送通知的工具content 参数的描述只写“内容”两个字。模型面对这个参数时不知道应该传明文还是 Markdown、要不要带标题、有没有长度限制。它只能根据猜测生成参数结果必然不稳定。正确做法是给每个参数写清楚取值范围、默认行为、单位或格式、和其他参数的关系。我见过最有效的描述方式是“像给一个人解释怎么操作一样写参数说明”。模型不是人但它阅读说明的能力非常强说明越精确行为越可靠。除此之外必填与可选字段的声明也值得专门检查。FastMCP 会根据函数签名推断没有默认值的参数视为必填有默认值的视为可选。这个推断在大部分场景下是对的但如果你用 dict 或对象类型作为参数就必须在描述里进一步说明内部结构。否则模型看到的只是一个类型为 object 的参数内部字段完全靠猜几乎必然出错。第一版工具写完之后强烈建议把生成的 inputSchema 实际打印出来看一遍。很多开发者在 FastMCP 里定义完函数就直接联调结果模型乱传参数他们完全不知道问题出在参数描述上。只要打开 schema 看一眼很多问题当场就能定位。2.3 服务端生命周期与安全边界MCP Server 的生命周期可以拆成三个阶段启动、工作、退出。启动阶段负责初始化资源和连接工作阶段持续处理 tools/list、tools/call 等请求退出阶段负责清理资源、保存状态、关闭连接。FastMCP 提供了生命周期钩子比如 lifespan 上下文管理器可以用来加载模型、初始化数据库连接池、创建全局缓存。我建议从一开始就规划好这些钩子不要等 Server 跑起来了再往函数里硬塞全局变量。安全边界是工具开发中最容易被忽略的一环。必须清醒地认识到一个 MCP 工具对 AI 客户端而言就是一段“可被模型任意调用的代码”。模型会基于用户的输入决定是否调用你的工具工具内部如果存在可以删除文件、执行命令、修改数据的操作就必须加防护。我的个人习惯是三层防护。第一层参数校验一定要严格所有外部输入都按“不可信数据”对待类型不对直接拒绝取值范围超限直接抛错。第二层危险操作必须二次确认比如删除操作宁可让工具返回“确认删除请再调一次”也不要让模型一步到位。第三层本地 Server 不要随意监听公网地址。stdio 模式天然安全因为只有启动它的父进程能访问管道但 HTTP 传输模式一旦监听在 0.0.0.0 上就等于把工具能力暴露给了整个网络这是绝对不能接受的。凡是远程部署的 MCP Server都必须在传输层加鉴权至少做到 token 校验和来源 IP 白名单。3. 实操从零写一个备忘录 MCP Server3.1 环境准备与工程初始化为了让整个分享更有“可抄作业”的价值我把这次要实现的 Demo 定义成一个备忘录管理服务。它提供四个工具新增备忘录、列出备忘录、按关键词搜索、删除指定备忘录。功能不复杂但覆盖了写 MCP 工具会遇到的大部分典型问题参数校验、读写本地文件、结构化返回、错误处理。第一步是准备环境。推荐直接用 uv 管理项目依赖这一步能省很多时间。新建一个 notes_server 目录初始化虚拟环境然后安装 mcp 库。命令如下mkdir notes_server cd notes_server uv init --python 3.11 uv add mcp[cli]安装完 mcp 之后FastMCP 已经内置在里面了。验证一下 Python 能正常导入uv run python -c from mcp.server.fastmcp import FastMCP; print(FastMCP)如果能打印出类信息说明环境没问题。接下来我们直接写代码。3.2 动手实现四个核心工具在 notes_server.py 中完成全部实现。代码本身不长但我把注释写得密一点方便你理解每个字段的用途。import json import uuid from datetime import datetime from pathlib import Path from typing import Optional from mcp.server.fastmcp import FastMCP # 创建 FastMCP 实例。name 是服务标识instructions 会注入给模型参考。 mcp FastMCP( notes-server, instructions这是一个备忘录管理服务。支持新增、列出、搜索、删除备忘录。备忘录存储在本地 JSON 文件。, ) DATA_FILE Path(__file__).parent / notes.json def _load_notes() - list[dict]: if not DATA_FILE.exists(): return [] try: return json.loads(DATA_FILE.read_text(encodingutf-8)) except json.JSONDecodeError: return [] def _save_notes(notes: list[dict]) - None: DATA_FILE.write_text( json.dumps(notes, ensure_asciiFalse, indent2), encodingutf-8, ) mcp.tool() def add_note( title: str, content: str , tags: Optional[list[str]] None, ) - str: 新增一条备忘录。 参数说明 - title备忘录标题必填建议控制在 50 字以内。 - content备忘录正文可空最长不要超过 5000 字。 - tags标签列表可空每个标签建议不超过 10 个字。 if not title.strip(): raise ValueError(title 不能为空) notes _load_notes() note { id: str(uuid.uuid4()), title: title.strip(), content: content.strip(), tags: [t.strip() for t in (tags or []) if t.strip()], created_at: datetime.now().isoformat(timespecseconds), } notes.append(note) _save_notes(notes) return json.dumps({ok: True, id: note[id]}, ensure_asciiFalse) mcp.tool() def list_notes(keyword: Optional[str] None) - list[dict]: 列出备忘录。 keyword 可选。为空时返回全部不为空时按标题和正文做包含匹配。 notes _load_notes() if keyword: kw keyword.strip().lower() notes [ n for n in notes if kw in n[title].lower() or kw in n[content].lower() ] # 按创建时间倒序返回 notes.sort(keylambda n: n[created_at], reverseTrue) return notes mcp.tool() def search_notes(keyword: str) - list[dict]: 搜索备忘录返回标题或正文包含关键词的所有条目。 与 list_notes 的区别本工具明确要求提供 keyword用于语义上更聚焦的搜索场景。 if not keyword.strip(): raise ValueError(keyword 不能为空) return list_notes(keyword) mcp.tool() def delete_note(note_id: str) - str: 按 ID 删除一条备忘录。删除操作不可恢复请谨慎调用。 notes _load_notes() before len(notes) notes [n for n in notes if n[id] ! note_id] if len(notes) before: raise ValueError(fnote_id{note_id} 不存在) _save_notes(notes) return json.dumps({ok: True, deleted_id: note_id}, ensure_asciiFalse) if __name__ __main__: mcp.run()有几个设计细节值得展开说明一下。第一所有返回结果都做了结构化处理单值操作用 JSON 字符串返回查询操作用数组返回。这样模型拿到结果后可以顺利解析而不是面对一段来源不明的文本。第二删除操作在 docstring 里明确标注了“不可恢复”和“谨慎调用”就是在给模型一个行为上的提醒让它更谨慎地执行高影响操作。第三参数校验放在了工具函数的最前面一旦触发非法入参就抛出 ValueErrorFastMCP 会把异常转成协议层面的错误码返回给客户端模型就能感知到调用失败的原因。3.3 用 MCP Inspector 做本地联调代码写完不代表能用第一步验证应该用官方联调工具。先启动服务uv run python notes_server.py正常情况下进程会保持运行不会打印任何内容等待客户端通过 stdio 管道连接。然后另开一个终端使用 MCP Inspector 连接uv run npx modelcontextprotocol/inspector python notes_server.py这里有一个值得强调的细节Inspector 是个可视化的调试面板它允许你查看 Server 的工具列表、逐个调用工具、查看返回结果。我习惯先看 Tools 列表页确认四个工具都在。然后手动调用 add_note传一个标题和一个标签看返回结果是不是包含 ok 和 id。接着调 list_notes看是否能查回到刚才新增的数据。最后调 delete_note把刚才那条数据删掉。整个过程走一遍才敢说工具本身没有逻辑问题。如果在 Inspector 里发现工具列表为空优先检查函数装饰器是否写成了 mcp.tools()而不是 mcp.tool()。这类问题很隐蔽因为语法不报错只是函数没有被注册。3.4 用最小客户端脚本验证协议级调用Inspector 适合人工联调但如果你要在 CI 环境里做自动化验证就得写一个最小客户端脚本。这个脚本不涉及任何 AI 客户端它直接扮演一个 MCP Client发起协议请求并打印结果。import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main(): server_params StdioServerParameters( commandpython, args[notes_server.py], ) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools await session.list_tools() print(Tools:, [t.name for t in tools.tools]) result await session.call_tool(add_note, { title: 协议测试, content: 通过最小客户端写入, tags: [test], }) print(Add result:, result.content[0].text) search_res await session.call_tool(list_notes, {keyword: 协议测试}) print(Search result:, search_res.content[0].text) if __name__ __main__: asyncio.run(main())这个脚本把协议层的关键步骤都覆盖了initialize 握手、list_tools、call_tool。跑一次之后你对 MCP 协议的理解会从“看过文档”变成“亲手用过”。到这里一个完整的本地 MCP Server 已经落地。接下来可以把它接入任何支持 MCP 的 AI 客户端。不同客户端的配置方式略有区别但本质都是在一份 JSON 配置里注册 Server 名、启动命令和参数例如{ mcpServers: { notes-server: { command: python, args: [/绝对路径/notes_server.py], env: {} } } }配置完成后重启客户端在对话中要求模型“帮我记一条明天的待办”或者“查一下和协议测试相关的备忘录”模型就会自动选择并调用对应的 MCP 工具。4. 实战中的坑问题清单与排查方法4.1 连接与启动类问题这一类问题通常在 Server 还没正式开始处理业务之前就爆炸了。最常见的现象是客户端提示 MCP Server 连接失败或者工具列表一直加载不出来。第一类原因路径错误。客户端配置里填的 command 或 args 如果带了相对路径工作目录一变就找不到文件了。我的习惯是在配置里使用绝对路径。另一个高频问题是用 uv 虚拟环境创建的 Python 依赖直接用系统 python 命令启动时找不到 mcp 模块。这种情况下command 应该直接指向虚拟环境里的 python 可执行文件或者用 uv run python 作为启动命令而不是裸写 python。前者把环境绑定死后者依赖 uv 能够在目标目录找到项目配置两者都可行但二选一不要混。第二类原因stdout 被污染。stdio 传输模式下Server 的所有标准输出都会被当成协议数据帧任何 print 语句都会破坏数据流。很多人在调试时喜欢在 Server 代码里随手 print 一行日志结果客户端直接卡死或报解析错误。正确做法是协议运行期间不要向 stdout 输出任何非协议内容。日志一律走 stderrFastMCP 也支持配置日志级别或者把日志直接写入文件。第三类原因依赖版本不匹配。mcp 库版本迭代很快不同版本之间的 API 变动不小。如果你照着某篇文章的代码写但装的 SDK 版本不同很容易出现属性不存在或者函数签名不一致的问题。排查时先锁定版本uv.lock 或 requirements.txt 里固定住 mcp 的版本再按照对应版本文档调试。4.2 工具识别与参数类问题服务连上了工具列表也能看到但模型调用时频繁报参数错误或者干脆不调用你的工具。这种问题的根源基本集中在 inputSchema 的质量上。最容易出现的是参数类型推断错误。比如函数签名里写了 tags: list[str] NoneFastMCP 可能按顺序推断 tags 为必需参数虽然你本意是可选。解决方法是把可选参数写成 Optional[list[str]] None让类型注解和默认值完全一致。再比如参数用了自定义类型或 dataclass生成的 Schema 可能只是一个普通 object内部结构模型完全看不到。遇到这种情况要么把复杂类型拆成多个基础类型参数要么在 docstring 里对结构做极为具体的描述。还有一类经常被忽略的问题参数描述里的字段名和实际函数签名不一致。如果 docstring 里描述了某个字段但签名里没有这个参数模型可能会试图传一个不存在的字段结果被参数校验拒绝。写工具时docstring 必须和签名保持严格同步不要出现“文档里有、签名里没有”的情况。4.3 返回值与模型表现类问题工具调用成功了但模型给用户的回答仍然不准确甚至出现幻读这类问题是最难排查的因为错误不在协议层而在“工具返回的结果是否被模型正确理解”。一个很常见的表现是工具返回了一堆 JSON模型却只摘取其中一部分或者直接告诉用户“查询失败”。原因多半是返回结果太随意。比如直接返回一个字典对象而没有清晰的字段说明或者返回纯文本时没有显式标注字段含义。协议要求工具调用结果必须是一个 content 数组里面每个元素是一个结构化 block。FastMCP 会自动帮你包装但你在定义返回值时仍然要想清楚“模型能从这个结果里读出什么”。我自己的一个改进方法是让所有工具返回结果都带上冗余信息。比如新增备忘录返回的 JSON 里除了 ok 和 id再带上标题和创建时间。模型在向用户汇报时可以直接引用标题和创建时间而不需要再猜测。类似地删除操作返回 deleted_id 的同时可以把被删条目的标题也带回来。多传这几个字段模型的表现会明显稳定。还有一类问题是内容过长。如果工具返回的列表有几百条记录模型处理大量文本时容易丢失前面的信息。此时应该做分页或限制返回条数。工具不是数据库接口没有必要把全量数据一股脑返回给模型。加一个 limit 参数默认返回前 20 条多出来的情况返回总量提示让模型决定是否要扩大范围是更聪明的设计。4.4 一个顺手的问题速查表现象可能原因排查方法解决方案连接失败路径错误、虚拟环境不对检查客户端配置里的 command 和 args使用绝对路径指向虚拟环境 python工具列表为空装饰器写错、函数未注册在 Inspector 中查看服务端工具日志检查 mcp.tool() 装饰器与函数签名stdout 被污染Server 里误用 print观察终端是否有非协议输出日志统一走 stderr 或文件参数缺失可选参数被推断为必填打印生成的 inputSchema用 Optional[...] 默认值声明参数模型不按说明调用参数描述不精确阅读生成后的 Schema重写 docstring描述取值范围与格式返回结果不完整返回结构太简单查看模型实际接收到的 content增加冗余字段提供可直接引用的信息4.5 安全加固与远程部署的注意点本地 Demo 做完之后很多人会顺理成章地想把它做成一个远程服务给团队使用。这一步非常容易踩坑因为远程 MCP Server 和本地 stdio Server 的安全模型完全不一样。本地 stdio 模式默认只允许启动自己的父进程通信外部网络无法访问威胁面很小。但一旦通过 HTTP 传输暴露到网络任何能访问到这个端口的人都可能调用你的工具。举一个最简单的例子如果把 notes_server 包装成 HTTP 模式并监听 0.0.0.0:8000那么攻击者只需要发送一个格式化好的 JSON-RPC 请求就能删除所有备忘录——如果你的工具里有 delete 这类危险操作后果可想而知。所以我给出的远程部署建议是第一不要直接复用本地工具的代码来监听公网必须加一层网关做鉴权可以使用简单的预共享 token也可以做 OAuth但绝不能裸奔第二把高危操作设计成“可停用”的模式远程环境下默认禁用 delete、update 这类写操作第三所有远程工具的操作日志必须留痕记录每次调用的来源、时间、参数和结果方便事后审计。工具开发本身不难难的是让它在一个不安全的环境里保持安全。最后再分享一点个人体会备忘录这个 Demo 是我反复用来讲解 MCP 的“最小完整案例”因为它真实覆盖了工具注册、参数校验、数据读写、错误处理、客户端验证这条完整链路。做完这一遍之后你对协议的掌握程度会远远超过只看文档的效果。我个人在实际操作中最大的体会是工具开发真正花时间的不是写函数逻辑而是“让模型理解你的工具”。很多人的工具逻辑很简单但参数描述写得敷衍结果模型要么不调用要么调用得乱七八糟。从第一次写工具开始就有意识地训练自己把每个参数的语义、边界、格式写清楚。这种习惯比掌握任何框架技巧都更能提升最终交付质量。如果你打算继续深入我建议下一步可以试着把同一个 Server 改造成远程 HTTP 模式并加一层简单的 token 鉴权去感受一下传输方式切换对客户端配置和协议行为带来的差异。也可以尝试给服务加上 Resource把某个数据文件暴露给模型读取体会 Tool 和 Resource 分工的不同。走完这两步MCP 工具开发对你来说就不再是“照着模板抄”而是真的能按业务需求自由设计了。
阅读完成 · 觉得有帮助?
咨询建站