告别孤岛:拥抱 MCP 协议,为大模型打造标准“USB 接口” - 大模型实战 08完结篇
前言
咱们的大模型实战的博客系列已经快到尾声,咱们一路以来,各位友人对大模型的实战方向应该或多或少都有了一些概念,我们一步步将大模型的那些“唬人”的名头慢慢拉下神坛。咱们对大模型的本地部署,云端微调,RAG知识库,agent开发都有一定的概念。这些内容可能不够深,还停留在一个入门的介绍,但是咱们已经告别大模型小白的阶段了,这个博客系列的最初目标,就是想尽可能让各位友人对大模型的实战方向,概念有一个整体的了解。大模型不是“王谢堂前燕”,是可以掌握的。 当然咱们目前只是对各个方向有了大概的了解,剩下的就需要咱们根据自己感兴趣的,深入去做,在实践中去碰壁,去选择更适合的框架,更适合的技术栈,去了解工程上的指标,工程上的最佳实践。
好了,感慨完了,这里就是咱们本系列的最后一篇博客,我们来了解一下MCP。
1. MCP的概念
首先,一言以概之,MCP只是一个Agent工具的USB接口。在USB接口出现前,手机数据传输并不是不能用,它只是将各种各样的接口统一成了一个,方便大家用同样的接口,而不是每家用一个其对应的接口。
好,MCP是干啥的? MCP全称Model Context Protocol, 模型上下文协议,这个名字可能有点难以理解。它实际上就是一个告诉模型,有什么工具可以怎么用,有哪些资源可以读,有哪些预设提示词可以调, 这三样就是Model Context。 MCP本身就是定义了,我们如何让模型知道这些信息的通信协议。

其核心架构有三部分
- MCP Host(客户端): 内置了大模型的调用方,发起调用/查询请求的一方,我们一般用OpenCode,ClaudeCode,Claude Desktop,Cursor或者vscode。正是因为协议统一,所以各方都可以通过这个接口去调同一个服务。
- MCP Server(服务端): 提供服务的工具/信息提供方,一般来说我们会开发的就是这部分,将我们的工具,资源,提示词封装起来,提供服务。
- Transport(传输层):客户端和服务端之间的通信通道,就目前而言有两种STDIO和Streamable HTTP(以前还有SSE,但是由于双端管理问题,安全认证问题等等问题废弃了)。一般STDIO用于本地,Streamable HTTP用于云端服务。
这里简单整理一下,其时序图如下

2. 上手实现MCP Server
2.1 用uv管理项目
我们在前面Kaggle上已经用过很多次uv了,我们这次不跑notebook,我们这次在本地运行。
- 通过命令
pip install uv,安装uv依赖 - 如果是克隆了项目地址,可以直接
uv sync同步项目依赖,跳过uv add和uv init
- 如果是克隆了项目地址,可以直接
- 在新建的项目目录下运行
uv init来初始化项目 - 通过
uv add mcp requests feedparser python-dotenv来安装依赖, 我们就不用原生的pip了
2.2 实现mcp服务
我们将之前写的获取最新博客和发送通知的tool,封装mcp的tool即可。
写Agent的时候,我们是定义工具,转成agent可用的tool,然后给agent。 这里因为和客户端(大模型方)通过mcp沟通,我们只用按照mcp库来将工具暴露出去就好
开始之前,咱们先介绍一下python-dotenv这个新的库。在Kaggle上,我们使用Secrets去存我们的密钥,在本地环境,一般我们是用环境变量来存这些密钥,而dotenv可以用一个.env文件来写这些密钥,去覆盖环境变量,就像Kaggle的Secrets一样。 因为里面有密钥信息,所以我们一定要注意:不要上传我们的.env文件
- 配置密钥:在项目目录下新建.env文件,然后配置我们的老朋友们
1 | TARGET_EMAIL="xxxxx" |
- 编写mcp服务
这里我先把整体的代码贴出,然后再来讲解
1 | import os |
2.3 解读mcp服务
1 | from mcp.server.fastmcp import FastMCP |
我们这里用的是FastMCP,是一个快捷部署的模块,它会自动帮我们处理初始化,请求路由,不用我们手动监听请求。如果有需要的话,我们可以使用mcp.server.Server来做更精细化的控制
1 | import os |
然后这一部分代码,友人们应该很熟悉,不同的就是我们在函数上加了个@mcp.tool()装饰器。是的,mcp的tool定义就这么简单。
但是有几点要注意
- 函数的参数类型和返回值类型要标明:FastMCP会将参数类型转为一个json schema发给client,告知调用的方式。
- docString要写详细:就是函数下用三引号括起来的这部分,最好写明函数使用场景,示例,参数说明。因为这里我们的例子比较简单,就没写那么复杂。这部分内容也是会发给大模型进行读取理解的。
- 不要往STDIO输出:因为我们是通过STDIO跟大模型通信,如果我们用print之类的打印,输出信息到STDIO,可能会将跟大模型的通信内容破坏。
1 | if __name__ == "__main__": |
最后一部分内容,就是运行的主函数了,然后我们运行这个脚本uv run 脚本名.py
这样这个mcp服务就运行起来了
2.4 运行客户端
我先自己写了一个客户端,来验证通信是否通路。代码如下
1 | import asyncio |
可以看到整个client的逻辑也是完全符合之前的通信时序图的,先注册,建立连接,获取工具/资源/prompt列表,然后对工具进行调用。
只是这里我没有去真的调一个大模型,而是人工模拟了大模型的输出。
2.5 在OpenCode客户端使用
- 安装OpenCode
OpenCode是一个基于命令行的Ai编码工具,我们可以通过多种方式来下载
如果安装了NodeJs的话可以使用npm i -g opencode-ai,也比较推荐使用nodeJs安装,因为很多mcp是基于npx使用的。
如果没有NodeJs的话,可以用更通用的curl -fsSL https://opencode.ai/install | bash来进行安装

运行OpenCode
选一个项目文件夹,然后运行opencode,就可以将opencode运行起来,其内置了一些免费模型供咱们使用
安装mcp
在所有的mcp client中,mcp都是可以通过一个配置文件去配置,大体上都是长这样
1 | { |
其本身,就是将运行咱们服务的命令配置上,这里也可以通过env这个键去配置一些密钥,但是我们可以不配置(opencode也不支持env),因为在咱们的server代码中,我们已经通过.env配置了。
okay, 那对于OpenCode,我们的配置文件名称是opencode.json,可以配置在~/.config/opencode/opencode.json目录作为全局,也可以在项目文件夹下创建项目级别的配置。
我们这里在项目文件夹下创建opencode.json,然后填入
1 | { |
开发小贴士:相对路径的陷阱
这里的命令使用了相对路径llm08-mcp-intro/mcp_server.py。这要求咱们在 OpenCode 中打开的正好是包含该项目的根目录,否则uv可能会找不到虚拟环境或报错。如果运行失败,建议直接替换为绝对路径以确保万无一失。
这里的命令本质就是将咱们的服务跑起来,直接用python而不用uv也行,取决于咱们怎么跑起来server,各个mcp client的配置可能稍有不同,但是都相差不大,这样配好之后。我们再重新使用opencode命令在项目目录打开项目,然后shift+p输入mcp。

选择Toggle MCPs,然后就能看见我们的这个mcp服务在运行,已经链接上了。

然后就可以输入prompt来让它查询并通知咱们。
3. 常见问题 (Q&A)
Q1: 为什么突然冒出来一个 MCP 协议?以前咱们写 Agent 不也是直接调各种 Tool 吗?
A: 为了解决“N 对 M”的重复造轮子问题。
- 现象:在 MCP 出现之前,如果咱们写了一个很棒的“查询本地数据库”的工具,咱们想让 ChatGPT 用,咱们需要对接一遍 OpenAI 的 Function Calling API;想让 Claude 用,又要对接一遍 Anthropic 的 API;想放在 Cursor 里,还得去写 Cursor 的插件。这就形成了 N 个大模型对接 M 个数据源的 N×M 复杂网络。
- 结论:MCP 就是那个“USB 接口”。它定义了一套标准的通信规范。咱们只需要按照 MCP 的标准把工具写一次(变成 MCP Server),任何支持 MCP 的客户端(无论是 Claude Desktop、Cursor 还是咱们自己写的脚本)都可以无缝接入。复杂网络瞬间变成了 N+M 的星型拓扑结构。
Q2: MCP 协议里的 “Host”、“Client” 和 “Server” 到底是怎么交互的?
A: 记住“菜单”和“点菜”的比喻。
- 现象:很多初学者容易搞混谁在调用谁。
- 结论:
- MCP Host/Client(客户端):像 Claude Desktop 或 OpenCode,它们是大模型的“宿主”,负责发起连接。
- MCP Server(服务端):也就是咱们写的 Python 脚本,负责提供具体的工具和数据。
- 交互流程:客户端连接后,第一件事是要求看“菜单”(
list_tools等)。服务端把写好注释的函数列表(JSON Schema)发过去。用户提问时,大模型看着这份菜单决定要用哪个工具,然后让客户端向服务端发送“点菜”指令(call_tool)。服务端执行完 Python 代码,把结果“上菜”给客户端。
Q3: 为什么本地 MCP 服务通常使用 STDIO(标准输入输出)而不是 HTTP/REST API 进行通信?
A: 为了极致的便捷性、安全性和生命周期管理。
- 现象:大家习惯了写微服务用 HTTP 暴露端口,但 MCP 本地开发却偏爱
stdio模式。 - 结论:
- 零端口冲突:不需要像传统 Web 服务那样去抢占
8080或3000端口。 - 同生共死:客户端(如 OpenCode)在后台通过命令行拉起 Python 子进程。当咱们关闭编辑器时,子进程会被操作系统自动回收,不会留下僵尸进程。
- 天生安全:数据只在父子进程间的标准输入输出流中传递,不需要进行复杂的网络鉴权,也不用担心被同网段的其他机器恶意调用。
- 零端口冲突:不需要像传统 Web 服务那样去抢占
Q4: 既然 FastMCP 这么好用,为什么官方还要保留底层的 Low-level Server API?
A: 为了极致的动态能力和底层控制权。
- 现象:FastMCP 就像自动挡汽车,它强依赖 Python 的类型提示(Type Hints),在服务启动时就“静态”定死了工具的说明书(JSON Schema)。
- 结论:如果咱们的业务场景极其复杂,比如需要根据数据库中存在的表,实时动态生成或注销可用的工具;或者咱们需要让另一个大模型来实时决定当前有哪些工具可用,咱们就必须切回“手动挡”的底层 API(
mcp.server.Server),手动监听list_tools并动态拼装返回类型。
Q5: MCP 里的 Tools(工具)、Resources(资源)和 Prompts(提示词)到底有什么本质区别?
A: 核心区别在于“调用方”以及“是否有副作用”。
这是 MCP 协议设计最优雅的三板斧:
- Tools(工具):赋予大模型“行动力”。需要大模型主动思考并传入参数来执行,通常包含副作用(比如发邮件、写数据库、调外部 API)。
- Resources(资源):赋予大模型“感知力”。它是只读的数据源(如本地报错日志、配置文件)。它就像一个“挂载的网盘”,大模型或用户可以直接读取里面的文本作为对话上下文,没有任何副作用。
- Prompts(提示词):标准化的“工作流”。它通常由用户在客户端主动触发(带变量参数),快速生成一长串复杂的、包含角色设定的系统指令。
Q6: 在暴露资源时,咱们自己捏造的 URI(比如 postgres:// 或 system://logs),大模型是怎么发起网络请求去读它的?
A: 大模型和客户端根本不发真正的网络请求!(划重点)
- 现象:很多有 Web 开发经验的朋友会疑惑,计算机网络里根本没有
memo://这种协议,客户端是怎么解析的? - 结论:在 MCP 的世界里,URI 只是一个路由“暗号”。当咱们在代码里写下
@mcp.resource("memo://today")时,只是向客户端注册了这个字符串。当大模型想要这个资源时,客户端只会把memo://today这串纯文本通过 STDIO 发给咱们的 Python 后端,由咱们的 Python 函数负责去本地硬盘或数据库捞数据并返回。所以,前缀怎么写完全由咱们自由发挥,只要符合业务语义即可。
Q7: 如果咱们有一整个文件夹(比如 100 篇本地 Markdown 笔记)想作为资源给大模型读,难道要写 100 个 @mcp.resource 吗?
A: 完全不需要,使用“资源模板 (Resource Templates)”即可。
- 现象:静态绑定(Direct Resources)只适合全局唯一的固定资源。
- 解决方案:MCP 允许在 URI 中使用大括号
{}定义动态参数。例如定义@mcp.resource("file:///local_notes/{filename}"),大模型在分析问题时,会自动将{filename}替换为它想查阅的笔记名传给咱们的函数。咱们的 Python 代码只需接收这个变量,拼凑出真实的文件路径读取即可(注意:实际开发中务必做好路径安全校验,防止目录穿越漏洞)。
Q8: 为什么在终端里单独运行脚本没问题,一挂载到 OpenCode 或 Claude Desktop 就报“未配置 API Key”或鉴权失败?
A: 这是因为子进程的“当前工作目录 (CWD)”发生了错位。(划重点)
这也是本次实战中最容易踩的坑!
- 现象:在终端运行脚本时,当前目录就是项目目录,
load_dotenv()能顺利找到同级的.env文件。但当宿主客户端(如 OpenCode)拉起 Python 子进程时,它的工作目录往往是编辑器的根目录甚至系统的临时目录,导致.env寻址失败被静默跳过,API Key 自然读取为空。 - 解决方案:放弃默认的相对路径。在代码最顶端使用
current_dir = os.path.dirname(os.path.abspath(__file__))动态锁定脚本所在的绝对路径,并拼接出.env的绝对路径传给load_dotenv()。
Q9: 既然环境变量容易丢,那咱们在 opencode.json 里直接加个 env 字段配置密钥行不行?
A: 有些客户端可以(如 Claude Desktop),但在 OpenCode 中会直接报错失效。
- 现象:如果在 OpenCode 的配置里加上
env对象,会直接提示Configuration is invalid... Invalid input,导致服务无法注册。 - 结论:这是因为不同客户端对 MCP 配置的 JSON Schema 校验严格程度不同。OpenCode 目前针对
type: "local"的配置并没有开放env字段的支持。因此,采用 Q8 中的代码级绝对路径锁定,才是无视客户端环境差异的终极最佳实践。
Q10: 为了排查 API 报错咱们加了 logging.basicConfig(),但程序运行后日志文件里依然空空如也,怎么回事?
A: 咱们的日志配置被底层框架给“截胡”了。
- 现象:Python 的
basicConfig有个非常隐蔽的特性:如果在此之前根节点(root logger)已经被其他模块(比如import FastMCP及其底层的异步机制)初始化过了,basicConfig就会静默失效,什么都不会写入。且在 MCP 的 STDIO 模式下,普通的print()会污染通信流导致解析崩溃。 - 解决方案:放弃全局配置。手动创建一个专属的 Logger(如
logger = logging.getLogger("mcp_server")),通过logger.propagate = False切断它向根节点的传播,并手动为其添加FileHandler和StreamHandler(sys.stderr)。
Q11: 咱们明明已经在 .env 里填入了真实的 API Key,怎么日志里打印出来的还是旧的占位符(或者报 401 错误)?
A: 可能是旧环境变量的残留,或者子进程并未真正重启。
- 现象:操作系统终端里可能残留了之前跑测试时的环境变量,或者咱们修改了
.env文件但宿主客户端还在用旧的进程通信。 - 解决方案:
- 在代码中开启强制覆盖:
load_dotenv(env_path, override=True),这能确保.env里的值无视系统残留,绝对生效。 - 修改密钥后不需要重启整个 OpenCode,只需快捷键调出 MCP 菜单,将对应的 Server Toggle Off 然后再 Toggle On。这会杀掉旧进程并重新拉起,瞬间加载最新配置。
- 在代码中开启强制覆盖:
Q12: 启动 MCP 服务的命令是写 uv run 还是 uvx?这两者有什么区别?
A: 必须写 uv run。两者的作用域完全不同!
- 现象:许多刚接触 uv 的开发者容易混淆这两个极为相似的命令。
- 结论:
uv run专门用来跑当前项目的脚本,它会自动寻找项目下的.venv虚拟环境,因此能正确加载咱们刚安装的mcp和feedparser等依赖。而uvx(等同于uv tool run)是用来在临时、隔离的环境里运行全局第三方工具(如代码格式化工具ruff)。跑咱们自己写的 MCP 本地服务,永远只用uv run。
