核心摘要 (TL;DR)
- 概念澄清:MCP (Model Context Protocol) 不是新模型,而是连接大模型与外部工具/数据的“标准 USB 接口”,彻底解决 N 个大模型对接 M 个数据源的 N×M 灾难。
- 核心架构:拆解 MCP 的三层架构(Client 客户端、Server 服务端、Transport 传输层),理解其基于 STDIO 的安全本地通信机制。
- 实战目标:使用
uv管理项目,基于FastMCP框架,将上节课的“博客监控与通知”工具封装成标准的 MCP 服务,并在 OpenCode 客户端中成功调用。
前言
咱们的大模型实战的博客系列已经快到尾声,咱们一路以来,各位友人对大模型的实战方向应该或多或少都有了一些概念,我们一步步将大模型的那些“唬人”的名头慢慢拉下神坛。咱们对大模型的本地部署,云端微调,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文件,然后配置我们的老朋友们
TARGET_EMAIL="xxxxx"
EMAIL_API_KEY="xxxxxxx"
WECHAT_API_KEY="xxxxxxx"
- 编写mcp服务 这里我先把整体的代码贴出,然后再来讲解
import os
import requests
import feedparser
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
# 初始化 MCP 服务器
load_dotenv()
mcp = FastMCP("Blog_Monitor_Notifier")
@mcp.tool()
def get_latest_blog_post(rss_url: str) -> str:
"""
请求并解析目标博客的 RSS feed,获取最新的一篇博客文章的标题和链接。
当咱们需要检查博客是否有更新时,调用此工具。
"""
try:
feed = feedparser.parse(rss_url)
if feed.entries:
latest_entry = feed.entries[0]
return f"Title: {latest_entry.title}\nLink: {latest_entry.link}"
return f"在 RSS 源 {rss_url} 中未找到任何文章。"
except Exception as e:
return f"获取博客失败: {str(e)}"
@mcp.tool()
def send_email_notification(post_title: str, post_link: str) -> str:
"""
当发现博客有更新时,调用此工具发送邮件通知。
必须提供新博客的标题 (post_title) 和链接 (post_link)。
"""
target_email = os.environ.get("TARGET_EMAIL")
email_api_key = os.environ.get("EMAIL_API_KEY")
if not target_email or not email_api_key:
return "邮件发送失败:未配置 TARGET_EMAIL 或 EMAIL_API_KEY 环境变量。"
headers = {
"Authorization": f"Bearer {email_api_key}",
"Content-Type": "application/json"
}
payload = {
"from" : "onboarding@resend.dev",
"to": target_email,
"subject": f"阿尔的代码屋更新咯:{post_title}",
"text": f"检测到 阿尔的代码屋 更新了一篇新博客 \n\n标题:{post_title}\n链接: {post_link}"
}
try:
response = requests.post("https://api.resend.com/emails", headers=headers, json=payload)
if response.status_code == 200:
return "邮件通知发送成功"
return f"邮件发送失败: {response.text}"
except Exception as e:
return f"发送邮件时发生异常: {str(e)}"
@mcp.tool()
def send_wechat_notification(post_title: str, post_link: str) -> str:
"""
当发现博客有更新时,调用此工具发送微信通知。
必须提供新博客的标题 (post_title) 和链接 (post_link)。
"""
wechat_api_key = os.environ.get("WECHAT_API_KEY")
if not wechat_api_key:
return "微信通知发送失败:未配置 WECHAT_API_KEY 环境变量。"
url = f"https://sctapi.ftqq.com/{wechat_api_key}.send"
data = {
"title": f"阿尔的代码屋更新咯:{post_title}",
"desp": f"检测到 阿尔的代码屋 更新了一篇新博客 \n\n标题:{post_title}\n链接: {post_link}"
}
try:
response = requests.post(url, data=data)
if response.status_code != 200:
return f"微信消息发送失败: {response.text}"
result = response.json()
if result.get("code") != 0:
return f"API 拒绝请求: {result.get('message')}"
return "微信通知发送成功"
except Exception as e:
return f"发送微信通知时发生异常: {str(e)}"
if __name__ == "__main__":
# 启动 MCP 服务器,默认监听 stdio
mcp.run()
2.3 解读mcp服务
from mcp.server.fastmcp import FastMCP
我们这里用的是FastMCP,是一个快捷部署的模块,它会自动帮我们处理初始化,请求路由,不用我们手动监听请求。如果有需要的话,我们可以使用mcp.server.Server来做更精细化的控制
import os
import requests
import feedparser
import logging
import sys
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
# 初始化 MCP 服务器
current_dir = os.path.dirname(os.path.abspath(__file__)) # 这里锁死脚本所在路径,避免到时候在外层通过client运行mcp的时候,读不到.env
env_path = os.path.join(current_dir, '.env')
load_dotenv(env_path, override=True) # 强行使用.env中的密钥配置
mcp = FastMCP("Blog_Monitor_Notifier")
# 配置日志
log_file_path = os.path.join(current_dir, 'mcp_server.log')
# 1. 获取专属的 logger 实例并设置捕获级别
logger = logging.getLogger("blog_monitor")
logger.setLevel(logging.INFO)
# 2. 核心避坑:清空可能被框架提前注入的默认 handler
if logger.hasHandlers():
logger.handlers.clear()
# 3. 创建格式化器
formatter = logging.Formatter('%(asctime)s - %(levelname)s - [%(funcName)s] - %(message)s')
# 4. 配置文件 Handler
file_handler = logging.FileHandler(log_file_path, encoding='utf-8')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# 5. 配置标准错误流 Handler (给 OpenCode 这种客户端看的)
stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.setFormatter(formatter)
logger.addHandler(stderr_handler)
# 6. 核心避坑:切断向 root logger 的传播
# 防止我们的日志被 FastMCP 底层拦截或吞噬
logger.propagate = False
# 测试一下日志是否正常工作
logger.info("=== 日志系统初始化成功,MCP Server 启动中 ===")
@mcp.tool()
def get_latest_blog_post(rss_url: str) -> str:
"""
请求并解析目标博客的 RSS feed,获取最新的一篇博客文章的标题和链接。
当咱们需要检查博客是否有更新时,调用此工具。
"""
logger.info(f"开始检查 RSS 源: {rss_url}")
try:
feed = feedparser.parse(rss_url)
if feed.entries:
latest_entry = feed.entries[0]
logger.info(f"成功获取到最新文章: {latest_entry.title}")
return f"Title: {latest_entry.title}\nLink: {latest_entry.link}"
logger.warning(f"RSS 源 {rss_url} 解析成功,但没有找到文章条目。")
return f"在 RSS 源 {rss_url} 中未找到任何文章。"
except Exception as e:
logger.error(f"解析 RSS 失败: {str(e)}", exc_info=True)
return f"获取博客失败: {str(e)}"
@mcp.tool()
def send_email_notification(post_title: str, post_link: str) -> str:
"""
当发现博客有更新时,调用此工具发送邮件通知。
必须提供新博客的标题 (post_title) 和链接 (post_link)。
"""
logger.info(f"准备发送邮件通知,目标文章: {post_title}")
target_email = os.environ.get("TARGET_EMAIL")
email_api_key = os.environ.get("EMAIL_API_KEY")
# 打印脱敏后的鉴权信息,用于排查环境注入问题
masked_email = target_email if target_email else "未配置"
masked_key = f"{email_api_key[:5]}...{email_api_key[-3:]}" if email_api_key else "未配置"
logger.info(f"读取到的配置 -> 目标邮箱: {masked_email}, API_KEY: {masked_key}")
if not target_email or not email_api_key:
logger.error("邮件发送终止:核心环境变量缺失。")
return "邮件发送失败:未配置 TARGET_EMAIL 或 EMAIL_API_KEY 环境变量。"
headers = {
"Authorization": f"Bearer {email_api_key}",
"Content-Type": "application/json"
}
payload = {
"from" : "onboarding@resend.dev",
"to": target_email,
"subject": f"阿尔的代码屋更新咯:{post_title}",
"text": f"检测到 阿尔的代码屋 更新了一篇新博客 \n\n标题:{post_title}\n链接: {post_link}"
}
try:
logger.info("正在向 Resend API 发起 POST 请求...")
response = requests.post("https://api.resend.com/emails", headers=headers, json=payload)
if response.status_code == 200:
logger.info("邮件 API 调用成功,邮件已发送。")
return "邮件通知发送成功"
logger.error(f"邮件 API 返回错误状态码: {response.status_code}, 详情: {response.text}")
return f"邮件发送失败,API 返回: {response.text}"
except Exception as e:
logger.error(f"请求 Resend API 时发生异常: {str(e)}", exc_info=True)
return f"发送邮件时发生网络异常: {str(e)}"
@mcp.tool()
def send_wechat_notification(post_title: str, post_link: str) -> str:
"""
当发现博客有更新时,调用此工具发送微信通知。
必须提供新博客的标题 (post_title) 和链接 (post_link)。
"""
wechat_api_key = os.environ.get("WECHAT_API_KEY")
if not wechat_api_key:
return "微信通知发送失败:未配置 WECHAT_API_KEY 环境变量。"
url = f"https://sctapi.ftqq.com/{wechat_api_key}.send"
data = {
"title": f"阿尔的代码屋更新咯:{post_title}",
"desp": f"检测到 阿尔的代码屋 更新了一篇新博客 \n\n标题:{post_title}\n链接: {post_link}"
}
try:
response = requests.post(url, data=data)
if response.status_code != 200:
return f"微信消息发送失败: {response.text}"
result = response.json()
if result.get("code") != 0:
return f"API 拒绝请求: {result.get('message')}"
return "微信通知发送成功"
except Exception as e:
return f"发送微信通知时发生异常: {str(e)}"
if __name__ == "__main__":
# 启动 MCP 服务器,默认监听 stdio
mcp.run()
然后这一部分代码,友人们应该很熟悉,不同的就是我们在函数上加了个@mcp.tool()装饰器。是的,mcp的tool定义就这么简单。
但是有几点要注意
- 函数的参数类型和返回值类型要标明:FastMCP会将参数类型转为一个json schema发给client,告知调用的方式。
- docString要写详细:就是函数下用三引号括起来的这部分,最好写明函数使用场景,示例,参数说明。因为这里我们的例子比较简单,就没写那么复杂。这部分内容也是会发给大模型进行读取理解的。
- 不要往STDIO输出:因为我们是通过STDIO跟大模型通信,如果我们用print之类的打印,输出信息到STDIO,可能会将跟大模型的通信内容破坏。
if __name__ == "__main__":
# 启动 MCP 服务器,默认监听 stdio
mcp.run()
最后一部分内容,就是运行的主函数了,然后我们运行这个脚本uv run 脚本名.py
这样这个mcp服务就运行起来了
2.4 运行客户端
我先自己写了一个客户端,来验证通信是否通路。代码如下
import asyncio
from mcp.client.session import ClientSession
from mcp.client.stdio import stdio_client, StdioServerParameters
async def main():
server_params = StdioServerParameters(
command="python",
args=["./llm08-mcp-intro/mcp_server.py"] # 需要结合自己的项目路径,项目工作目录,mcp服务脚本名称 去进行填写
)
async with stdio_client(server=server_params) as (read_stream, write_stream):
async with ClientSession(read_stream=read_stream, write_stream=write_stream) as session:
await session.initialize()
tool_response = await session.list_tools()
for tool in tool_response.tools:
print(tool)
prompt_response = await session.list_prompts()
for prompt in prompt_response.prompts:
print(prompt)
resource_response = await session.list_resources()
for resource in resource_response.resources:
print(resource)
### ============模拟大模型选择调用的函数和填入参数====== ###
# mock llm
rss_target = "https://blog.algieba12.cn/atom.xml"
target_tool_name = tool_response.tools[0].name
target_tool_arguments = {"rss_url":rss_target}
### ================================================= ###
call_result = await session.call_tool(
name=target_tool_name,
arguments=target_tool_arguments
)
for content in call_result.content:
if content.type == "text":
print(content.text)
if "__main__" == __name__:
asyncio.run(main())
可以看到整个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都是可以通过一个配置文件去配置,大体上都是长这样
{
"mcpServers": {
"BlogMonitor": {
"command": "python",
"args": ["绝对路径/llm08-mcp-intro/mcp_server.py"],
"env": {
"TARGET_EMAIL": "algieba.king@gmail.com",
"EMAIL_API_KEY": "咱们的_resend_api_key",
"WECHAT_API_KEY": "咱们的_wechat_api_key"
}
}
}
}
其本身,就是将运行咱们服务的命令配置上,这里也可以通过env这个键去配置一些密钥,但是我们可以不配置(opencode也不支持env),因为在咱们的server代码中,我们已经通过.env配置了。
okay, 那对于OpenCode,我们的配置文件名称是opencode.json,可以配置在~/.config/opencode/opencode.json目录作为全局,也可以在项目文件夹下创建项目级别的配置。
我们这里在项目文件夹下创建opencode.json,然后填入
{
"mcp": {
"blog_monitor": {
"type": "local",
"command": ["uv", "run", "llm08-mcp-intro/mcp_server.py"],
"enabled": true
}
}
}
开发小贴士:相对路径的陷阱 这里的命令使用了相对路径
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。
本文作者: Algieba 本文链接: blog.algieba12.cn/llm08-mcp-i… 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!