前一阵子在本地折腾 AI 工作流,发现一个特别头疼的事:每次让模型去读项目文件、查数据库、调第三方服务,都要按不同厂商的 function calling 格式写一遍适配代码,换个 AI 客户端就得重来。后来我把这套能力用 MCP(Model Context Protocol,模型上下文协议)重写了一遍,才发现工具接入这件事原本可以这么清爽。这篇文章我会从协议设计思路讲到代码实战,带你在本地从零写一个 MCP Server,并把它接到支持 MCP 的 AI 客户端里真正跑起来。适合第一次接触 MCP 的开发者,也适合用了 MCP 但一直没亲手写过 Server 的朋友。
1. 先想清楚:MCP 解决的是「工具接入」,不是「模型本身」
1.1 没有 MCP 之前,工具接入为什么这么「碎」
我做本地项目里的文档问答功能时,最初的方案是让模型能读取指定目录的 Markdown 文件。听起来很简单,实际落地要经历这么几步:先选定底层模型,查它支持的 function calling 格式;接着给文件读取写一段工具描述 JSON,把参数名、类型、用途都规定清楚;然后处理模型的调用请求,执行完再把结果拼回对话上下文;最后如果你发现这个方案明天要换一个 AI 客户端,上述所有代码基本作废。
这种“碎”不只是麻烦,而是每个工具、每个数据源都要重复一遍同样性质的胶水工作。一个典型项目里,本地文件、SQLite 数据库、任务清单、内部 Web 服务,每一个都要单独写接入逻辑。时间久了你会发现,真正业务逻辑没写多少,代码库反而长满了各类“接线器”。更难受的是,这些接线器彼此格式还不一样,今天接 A 模型写一套,明天接 B 应用又得写一套。
我去翻了很多团队的开源项目,发现大家痛苦高度一致:工具接入是非标准化的,每换一层就重写一遍。MCP 就是在这样的背景下出现的——它想解决的问题,不是让某个模型变聪明,而是把“工具怎么暴露、怎么被发现、怎么被调用”这几件事变标准。
1.2 MCP 的思路:把能力暴露协议化
MCP 给出的方案很朴素:所有提供能力的服务,都用同一个协议暴露自己的工具和数据;所有消费能力的 AI 应用,都用同一个协议去发现和调用。换句话说,它把“能力接入”这件事从每对关系的私聊,变成了公共标准下的群聊。
我习惯把它类比成 USB-C 接口。USB-C 能取代一堆杂线,不是因为它做了多惊天动地的事,而是把供电、数据传输、协议协商统一到了同一个接口上。MCP 也一样,它统一的是“模型怎么发现工具、怎么发起调用、怎么拿回结果”这段完整的交互流程。你写好的一个 MCP Server,可以在任何支持 MCP 的客户端里复用,不用为每个客户端改代码。
这里要强调一个边界:MCP 改变不了模型本身的智商,它不负责让回答更流畅,也不参与模型训练或微调。它管的是连接层。换句话说,你把一个文件系统封装成 MCP Server,AI 客户端就能用标准方式读取文件,但能不能从文件里总结出好观点,那是模型能力的事。理解这个边界很重要,能避免你对 MCP 抱有不切实际的预期。
1.3 和 function calling、RAG 的区别,一句话就能说清
我在社区里看到不少新手把 MCP、function calling、RAG 混在一起,其实它们的定位完全不同。
function calling 是模型服务内部的一种函数调用机制,解决的是“怎么把参数结构化和结果返回给模型”这件事,但它绑定特定厂商实现;RAG 是给模型补充外部知识,它的目标是“让模型回答它原本不知道的内容”;MCP 是连接层标准,它让工具和数据的暴露方式协议化,方便同一套能力在不同应用和模型之间复用。
我对三者的记忆口诀是:RAG 是给厨师递菜谱,function calling 是告诉厨师某台特定烤箱的按钮怎么按,MCP 则是把所有烤箱统一成同一个操作台。实际项目里三者并不冲突,反而经常一起用。例如我可以在 MCP Server 里实现一个“语义搜索”工具,工具内部用向量检索去找到相关文档,再把结果包装成标准工具调用返回给 AI 应用。RAG 负责找,MCP 负责把“找”这件事变成标准能力。
2. 拆开协议骨架:三个角色、三种能力、一条消息链路
2.1 Host / Client / Server:谁在组织、谁在转发、谁在干活
MCP 的拓扑里有三个角色:Host、Client、Server。
Host 是用户直接面对的应用层,比如你用的桌面 AI 客户端、IDE 插件、Agent 框架。它负责承载整个会话,决定“什么时候该调用工具”“调用结果怎么展示”。Client 是 Host 内部的协议组件,专门负责与 Server 建立连接、收发 JSON-RPC 消息、维护会话状态。Server 则是能力提供方,它连接真实的文件系统、数据库或第三方服务,并把它们包装成协议可描述的能力。
一个 Host 内部可以同时跑多个 Client,每个 Client 连接不同的 Server。例如一个 IDE 插件可以同时连接项目文件 MCP Server、数据库 MCP Server 和浏览器调试 MCP Server。Server 本身不感知模型用什么,也不感知 Host 是谁,它只对 Client 负责。这也正是 MCP 能复用的关键:Server 只需要写一遍,换个 Host 照样能接。
2.2 JSON-RPC 2.0:从 initialize 到 tools/call 的完整会话
MCP 的消息层基于 JSON-RPC 2.0,这是一种轻量的远程调用协议。一个典型会话会经历下面几个阶段。
首先是 initialize。Client 向 Server 发送一条 initialize 请求,带上自己支持的协议版本和客户端信息;Server 回应自己的协议版本、能力列表和服务名称。这个阶段相当于两个人在自我介绍,并确认“我们用同一个版本的语言交流”。
接着 Client 发送notifications/initialized通知,表示初始化完成。之后 Client 就可以调用tools/list获取 Server 暴露的工具清单,Server 会返回每个工具的名称、描述、参数 JSON Schema。再往后,当 Host 决定让模型调用某个工具时,Client 会发送tools/call请求,Server 执行对应逻辑并返回结果。
我手动给本地 Server 发过一条简单的 initialize 消息,大致长这样:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"manual-test","version":"1.0"}}}Server 返回的响应里会包含协议版本、capabilities 和 serverInfo。看到这个响应,基本可以确认 Server 进程活着且协议握手成功。整个交互都是双向的,一次请求对应一次响应,超时、错误也都有标准字段可以表达。
2.3 Tools / Resources / Prompts:三种原语的边界
MCP 协议定义了三种核心原语:Tools、Resources、Prompts。
Tools 是最常用的,它表示一个可被模型调用的函数,通常带有参数、会执行操作、可能产生副作用,比如“读取文件”“发送请求”“写入数据库”。模型在推理过程中可以主动发起调用。
Resources 是暴露给模型的数据内容,比如项目里的某个文档、数据库中的某条记录。它更像“文件”,模型不是去调用它,而是在需要的时候引用它作为上下文。实际实现中,Server 可以通过resources/list和resources/read让客户端浏览和读取这些数据。
Prompts 则有点类似模板命令。Server 可以为用户交互提供可复用的提示词模板,例如“总结某个文档”“分析当前目录代码结构”。用户在 Host 界面里触发这些模板,就能快速发起一个预定义的交互过程。
三者的差别可以这样看:Tools 是“动词”,模型发起;Resources 是“名词”,模型引用;Prompts 是“剧本”,用户触发。动手写 MCP Server 时,多数场景先聚焦 Tools 就够了。
2.4 stdio 与 HTTP:传输层应该怎么选
MCP 支持多种传输方式,主流的是 stdio 和 HTTP。
stdio 模式适用于本地开发场景,Server 以子进程方式启动,Client 通过标准输入、标准输出与 Server 通信。它的好处是简单、隔离性好、不涉及网络端口,非常适合个人本地工具和桌面应用。我的本地项目默认都用 stdio。
另一种是通过 HTTP 承载的远程传输,适用于部署在一台服务器上、供多个客户端共享的工具服务。2025 年之后协议主推的 Streamable HTTP 模式支持流式与非流式请求,双向通信能力比早期的 SSE 方案更强。
选型的建议很简单:只在本机用,选 stdio;要部署成团队共享服务,选 HTTP。别一上来就把本地工具做成网络服务,网络化意味着要补认证、限流、防攻击,这些成本大多数本地场景根本不需要付。
3. 从零写一个本地 MCP Server:项目文档助手实战
3.1 实战目标:让 AI 能看懂项目里所有 Markdown 文档
我们现在做一个叫“项目文档助手”的本地 MCP Server。目标场景是:我的项目里有大量.md文档,包括架构说明、开发规范、发布记录。我希望 AI 客户端能自动发现这些文档、按需读取内容、还能根据关键词定位相关内容。
为此,Server 需要暴露三个能力:列出项目里所有 Markdown 文件、读取指定文件内容、在文档里搜索关键词。这三个能力对应三个工具函数,足够覆盖多数实际使用场景,又不会因为工具太多把模型搞得无所适从。
项目结构长这样:
project-doc-mcp/ ├── .venv/ ├── server.py ├── README.md └── docs/ ├── 架构说明.md ├── 开发规范.md └── 发布记录.md3.2 环境准备:虚拟环境与官方 Python SDK
MCP 的官方 Python SDK 包名就是mcp。先建虚拟环境,再安装依赖:
mkdir project-doc-mcp cd project-doc-mcp python -m venv .venv source .venv/bin/activate pip install mcp建议 Python 版本用 3.10 以上,SDK 内部大量使用类型注解和异步语法,版本太低容易出兼容问题。装完后可以确认一下版本号:
python -c "from importlib.metadata import version; print(version('mcp'))"3.3 用 FastMCP 实现 Server:三个工具函数
官方 SDK 提供了一个高级封装叫 FastMCP,它的设计思路是:你只需要写普通函数,加个装饰器,FastMCP 会自动把函数签名转换成工具调用的 JSON Schema,并处理底层的协议交互。
下面是完整的server.py:
from pathlib import Path from mcp.server.fastmcp import FastMCP PROJECT_ROOT = Path(__file__).parent.resolve() mcp = FastMCP( "project-doc-mcp", instructions="你是项目文档助手,可以帮用户列出、读取和搜索项目中的 Markdown 文档。", ) @mcp.tool() def list_markdown_files() -> list[str]: """列出项目目录下所有 Markdown 文件,用于了解项目文档结构。""" return sorted( str(p.relative_to(PROJECT_ROOT)) for p in PROJECT_ROOT.rglob("*.md") ) @mcp.tool() def read_file(relative_path: str) -> str: """读取项目目录下的一个文件,返回其内容。相对路径必须指向项目内存在的文件。""" path = (PROJECT_ROOT / relative_path).resolve() if not path.is_file(): return f"文件不存在:{relative_path}" try: content = path.read_text(encoding="utf-8", errors="replace") except Exception as exc: return f"读取文件失败:{exc}" return content[:8000] @mcp.tool() def search_docs(keyword: str) -> list[str]: """在项目内 Markdown 文件中搜索关键词,返回文件路径、行号和匹配行内容。""" results = [] for p in PROJECT_ROOT.rglob("*.md"): try: for line_no, line in enumerate( p.read_text(encoding="utf-8", errors="ignore").splitlines(), start=1, ): if keyword in line: results.append( f"{p.relative_to(PROJECT_ROOT)}:{line_no}: {line.strip()[:120]}" ) except Exception: continue return results[:50] if __name__ == "__main__": mcp.run()三个函数的定位很清晰:先列目录,让模型知道有哪些文档;再按需读取,拿到完整内容;最后是关键词搜索,用于快速定位某个概念出现在哪些文档里。
代码里有几个细节值得解释一下。PROJECT_ROOT用的是Path(__file__).parent.resolve(),确保无论 Server 被从哪里启动,根目录都固定为脚本所在目录,不会因为当前工作目录变化导致路径错乱。每个工具函数都写了完整的中文描述,这非常关键,因为模型看不到函数内部实现,它在推理时依赖的就是函数名、参数名和描述信息。
read_file返回时截断到 8000 字符,是为了防止单个文件过大导致上下文膨胀。你可以按自己项目的文档规模调整这个值。search_docs里对每一行做了匹配,而不是把整个文件放进内存,这样即使文档很大也不会把 Server 拖垮。
3.4 启动自测:手动发一条 JSON-RPC 消息验证握手
写完 Server 后,先用最简单的方式测一下协议能不能通。在终端里运行:
python server.py这个命令默认以 stdio 模式启动。你可以手动给它喂一条 JSON-RPC 消息验证握手,比如在另一个终端里执行:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"manual-test","version":"1.0"}}}' | timeout 3 python server.py如果一切正常,你会看到 Server 返回包含serverInfo和capabilities的 JSON 响应。这就说明进程启动成功、协议握手没问题。看到响应后进程会因为等待后续输入而挂着,用 Ctrl+C 结束即可。
这个手动测试在什么情况下特别有价值?当你的 AI 客户端连不上 Server、报“无法连接”时,先手动发 initialize 消息,能够快速区分问题是出在 Server 本身,还是出在 Host 配置。这比把全部报错甩给 AI 客户端要高效得多。
4. 接进 AI 客户端:配置、推理与工具调用的完整链路
4.1 客户端配置:把 mcpServers 指向本地进程
要让 AI 客户端用上我们刚写的 Server,需要在客户端的 MCP 配置里声明这个服务器。不同客户端的配置入口略有区别,但本质上都是维护一份mcpServers列表,把 Server 名称、启动命令、参数和当前目录告诉 Host。
以配置文件方式为例,核心配置段长这样:
{ "mcpServers": { "project-doc": { "command": "/path/to/project-doc-mcp/.venv/bin/python", "args": ["server.py"], "cwd": "/path/to/project-doc-mcp" } } }注意command我写的是虚拟环境里的 Python 解释器绝对路径。这一步很容易踩坑:如果你直接写python,Host 子进程可能用的是系统全局 Python,那个环境里未必装了mcp库,结果就是这个 Server 起不来。配置完成后,重启客户端让配置生效。
4.2 从用户提问到工具结果回传:一次完整调用的 6 个节点
平台配置好了,很多开发者却不知道模型到底是怎么一步步用上工具的。我把完整链路拆成 6 个节点,你就明白“MCP 怎么被调用”了。
- 用户在 Host 里提问,比如“README 里写了什么”。
- Host 获取当前会话可用的 MCP 工具列表,这些工具的描述会被拼进发给模型的上下文里。
- 模型在推理时判断需要调用某个工具,输出一个结构化的工具调用意图,比如调用
list_markdown_files。 - Host 里的 MCP Client 收到这个意图,把它翻译成一条
tools/call协议消息,发送给对应的 Server 进程。 - Server 执行函数逻辑,把结果以 JSON 形式返回给 Client。
- Client 把工具结果回传给模型,模型基于结果生成最终回答,Host 展示给用户。
整个过程中,模型没有直接访问文件系统,它只看到“工具描述 + 工具返回结果”,具体执行完全由 Server 完成。这种隔离带来一个好处:你可以在不改变任何工具逻辑的前提下,把同一套能力接入不同模型、不同客户端。
4.3 联调手段:Inspector、stderr 日志与重启注意事项
初次联调时几乎一定会遇到问题。我最推荐的调试工具是 MCP 官方配套的 Inspector,它能可视化地查看工具列表、手动触发工具、回看协议消息。启动方式也很简单:
npx @modelcontextprotocol/inspector python server.pyInspector 启动后会在浏览器里打开一个调试面板,左侧显示工具列表,右侧可以填参数并发送调用。这个工具最大的价值是“绕过模型,直接测试 Server 本身”,帮你确认问题到底在 Server 逻辑还是模型选择。
另一个关键实践是日志输出位置。stdio 模式下,标准输出是协议通道,绝对不能用来打印业务日志;日志必须走标准错误。我通常会在代码里加一行:
import sys print("server started, project root:", PROJECT_ROOT, file=sys.stderr)这样做能保证 Server 的调试信息可见,又不污染协议消息。很多新手在 Server 里用print输出调试信息,接进 Host 后流量全部损坏,连接报错,排查半天才发现是这个原因。
最后是重启问题:MCP Server 以子进程方式运行,修改 Server 代码后必须重启客户端,或者确保客户端能重新拉起子进程。我实测时经常写完代码不重启就一遍遍测试,白白浪费很多时间。现在我会先确认配置中进程不存在,再重新连接。
5. 一个「好」的本地 MCP Server,还要处理这些边界
5.1 工具描述与参数 Schema:AI 用不用的准,先看「自解释」
工具能不能被模型正确调用,很大程度上不取决于代码逻辑,而取决于工具的“自解释”能力。模型在推理时只能看到工具名、参数名、描述和 JSON Schema,它看不到你的实现。所以写工具描述时,要站在“一个不了解内部实现的 AI 模型”的视角来写。
好的工具描述应该包含三部分:这个工具是做什么的、什么场景应该用、什么场景不要用。举个例子,search_docs的描述如果只写“搜索文本”,模型就不知道这个工具是搜项目文档还是搜整个文件系统。像代码里那样写“在项目内 Markdown 文件中搜索关键词,返回文件路径、行号和匹配行内容”,模型就清楚它的边界。
参数设计也要尽量精简。如果一个工具需要 5 个以上参数,模型选择错误参数的概率会明显上升。我建议把工具拆细,一个工具只负责一件事,必要参数控制在 3 个以内,可选参数给默认值。实在复杂的场景,就用多个工具组合完成。
5.2 路径与权限控制:别让 Server 变成任意文件读取接口
本地 MCP Server 看似只给自己用,但依然要做好路径校验。最典型的攻击路径是路径穿越,也就是模型在被诱导后传入类似../../etc/passwd这样的路径。如果 Server 直接拼接路径去读,就可能读到项目目录外的敏感文件。
我的防护方式是先做路径归一化,再用relative_to校验:
path = (PROJECT_ROOT / relative_path).resolve() if PROJECT_ROOT not in path.parents and path != PROJECT_ROOT: return f"不允许访问项目目录之外的文件:{relative_path}"这个写法先把路径解析成绝对路径,再确认它确实位于项目根目录之下。校验失败就返回提示字符串,不做任何拼路径的补救。虽然这个 MCP Server 是以本地可信场景为主,但把防护写在前面,之后如果要把同样代码部署成 HTTP 服务,就不用再补课了。
权限控制还要考虑另一层:本地文件服务应默认只读。像我们提供的工具,只读文件内容、搜索关键词,不动任何写入操作。需要写文件时,我会单独提供一个带明确描述的 write 工具,而不是让 read 工具同时承担写的能力。
5.3 错误返回要「模型可读」,日志要「人可读」
很多开发者在写工具时,遇到异常就直接抛出,让 FastMCP 把异常包装成协议错误。这样做的问题是模型拿到的是一段晦涩的异常信息,它没法判断下一步应该怎么做。
更好的做法是:预期内的错误直接作为字符串结果返回。我在read_file里写的 “文件不存在:xxx” 就是一个典型例子。模型拿到这个字符串,就能理解是路径传错了,会在后续回答里直接告诉用户“这个文件不存在”,而不是抛出一个让人摸不着头脑的调用失败。
而真正需要人工排查的错误,比如调用栈、依赖缺失、启动失败,应该由 Server 在启动阶段用日志输出,通常是写进 stderr 或日志文件。我习惯给每个工具加一个最小化日志:
print(f"[tool] search_docs keyword={keyword}", file=sys.stderr)这样的好处是,当工具行为不符合预期时,能从日志里看到模型实际传入了什么参数,快速判断是模型选错工具还是参数被截断。模型可读的错误面向“自动恢复”,人可读的日志面向“事后定位”,两者职责不同,缺一不可。
6. 复盘:自研 Server 前的判断、取舍与我的体会
6.1 先翻现成生态,再决定要不要自己写
动手写任何 MCP Server 之前,我强烈建议先花半小时翻社区里已经存在的 MCP Server 集合。MCP 生态从 2024 年底开始爆发式增长,到现在已经有非常丰富的现成实现。数据库连接、GitHub 操作、图片处理、设计稿导出、股票行情、交通查询等场景,大概率已经有人做好了,而且多数是开源项目。
我见过不少开发者在没有调研的情况下,从零写了一个功能与现成项目重复的 Server,最后还要花大量时间维护和修 bug。正确路径是:先用现成的,跑通核心流程;真要自定义,也优先在已有项目上改,而不是推翻重来。自己写 Server 前先问一句“这世界上是不是已经有人解决过类似问题”,能帮你省下很多时间。
6.2 什么时候适合自研 MCP Server
现成生态虽多,但自研依然有它的价值,我归纳为三种典型情况。
一种是你有高度私有化的工具或数据源。公司内部平台、特殊格式的本地数据库、自研系统,这类东西没有公开 MCP 实现,只能自己做协议封装。第二种是场景非常轻、不值得引入外部依赖,比如我们要给 AI 暴露一个只有三个工具的文档接口,自己写几十行代码比接一个几百行的通用工具更划算。第三种是你需要精确控制工具的权限边界和错误行为,这时候自己写的逻辑最可控。
反过来说,如果你的需求正好落在某个成熟 Server 的覆盖范围,且对方项目活跃度不错,就别重复造轮子了。我的一个本地项目依赖某个第三方工具,我一开始坚持自研,最后发现对方在细节上比我处理得更完善,果断切换成了现成方案,整体效果反而更好。
6.3 我踩过几次坑之后的选择逻辑
复盘我自己的踩坑经历,有三条经验值得分享。
第一条是:先明确这个 MCP Server 的消费端。如果只是本机用,stdio 就够扎实地支撑全部需求,别提前引入 HTTP、认证等复杂度。我是被“网络化”三个字忽悠过的人,给一个本地小工具加了 HTTP 服务,结果额外维护了一堆东西,价值却没增加。
第二条是:工具越少越好。写 Server 时总想把所有能力都暴露出去,但工具太多会让模型难以选择,经常调用错工具。我现在的做法是先用最小工具集跑通流程,等模型确实经常需要某个能力时再加。
第三条是:协议细节比工具逻辑更容易出错。MCP 是建立在 JSON-RPC 之上的,如果你不了解 initialize、tools/list、tools/call 这个生命周期,可能连配置怎么排查都无从下手。所以我建议所有人动手写 Server 前,至少手动发一次 initialize 消息,把协议的“手感”建立起来。
我现在做本地 AI 工具链,MCP 已经成了标配。无论是把项目文档喂给 IDE 助手,还是让 Agent 操作本地的脚本工具,我都尽量用同一个标准去封装。如果你正准备给自己的项目接入 AI 能力,我建议也从这样一个小小的本地 Server 开始,跑通之后再向更多场景延伸。