核心摘要 (TL;DR)
- 概念澄清:MCP (Model Context Protocol) 不是新模型,而是连接大模型与外部工具/数据的“标准 USB 接口”,彻底解决 N 个大模型对接 M 个数据源的 N×M 灾难。
- 核心架构:拆解 MCP 的三层架构(Client 客户端、Server 服务端、Transport 传输层),理解其基于 STDIO 的安全本地通信机制。
- 实战目标:使用
uv 管理项目,基于 FastMCP 框架,将上节课的“博客监控与通知”工具封装成标准的 MCP 服务,并在 OpenCode 客户端中成功调用。
前言
咱们的大模型实战的博客系列已经快到尾声,咱们一路以来,各位友人对大模型的实战方向应该或多或少都有了一些概念,我们一步步将大模型的那些“唬人”的名头慢慢拉下神坛。咱们对大模型的本地部署,云端微调,RAG知识库,agent开发都有一定的概念。这些内容可能不够深,还停留在一个入门的介绍,但是咱们已经告别大模型小白的阶段了,这个博客系列的最初目标,就是想尽可能让各位友人对大模型的实战方向,概念有一个整体的了解。大模型不是“王谢堂前燕”,是可以掌握的。 当然咱们目前只是对各个方向有了大概的了解,剩下的就需要咱们根据自己感兴趣的,深入去做,在实践中去碰壁,去选择更适合的框架,更适合的技术栈,去了解工程上的指标,工程上的最佳实践。
好了,感慨完了,这里就是咱们本系列的最后一篇博客,我们来了解一下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 2 3
| TARGET_EMAIL="xxxxx" EMAIL_API_KEY="xxxxxxx" WECHAT_API_KEY="xxxxxxx"
|
- 编写mcp服务
这里我先把整体的代码贴出,然后再来讲解
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88
| import os import requests import feedparser from dotenv import load_dotenv from mcp.server.fastmcp import FastMCP
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" : "[email protected]", "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.run()
|
2.3 解读mcp服务
1
| from mcp.server.fastmcp import FastMCP
|
我们这里用的是FastMCP,是一个快捷部署的模块,它会自动帮我们处理初始化,请求路由,不用我们手动监听请求。如果有需要的话,我们可以使用mcp.server.Server来做更精细化的控制
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142
| import os import requests import feedparser import logging import sys from dotenv import load_dotenv from mcp.server.fastmcp import FastMCP
current_dir = os.path.dirname(os.path.abspath(__file__)) env_path = os.path.join(current_dir, '.env') load_dotenv(env_path, override=True) mcp = FastMCP("Blog_Monitor_Notifier")
log_file_path = os.path.join(current_dir, 'mcp_server.log')
logger = logging.getLogger("blog_monitor") logger.setLevel(logging.INFO)
if logger.hasHandlers(): logger.handlers.clear()
formatter = logging.Formatter('%(asctime)s - %(levelname)s - [%(funcName)s] - %(message)s')
file_handler = logging.FileHandler(log_file_path, encoding='utf-8') file_handler.setFormatter(formatter) logger.addHandler(file_handler)
stderr_handler = logging.StreamHandler(sys.stderr) stderr_handler.setFormatter(formatter) logger.addHandler(stderr_handler)
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" : "[email protected]", "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.run()
|
然后这一部分代码,友人们应该很熟悉,不同的就是我们在函数上加了个@mcp.tool()装饰器。是的,mcp的tool定义就这么简单。
但是有几点要注意
- 函数的参数类型和返回值类型要标明:FastMCP会将参数类型转为一个json schema发给client,告知调用的方式。
- docString要写详细:就是函数下用三引号括起来的这部分,最好写明函数使用场景,示例,参数说明。因为这里我们的例子比较简单,就没写那么复杂。这部分内容也是会发给大模型进行读取理解的。
- 不要往STDIO输出:因为我们是通过STDIO跟大模型通信,如果我们用print之类的打印,输出信息到STDIO,可能会将跟大模型的通信内容破坏。
1 2 3
| if __name__ == "__main__": mcp.run()
|
最后一部分内容,就是运行的主函数了,然后我们运行这个脚本uv run 脚本名.py
这样这个mcp服务就运行起来了
2.4 运行客户端
我先自己写了一个客户端,来验证通信是否通路。代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| 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"] )
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)
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是一个基于命令行的Ai编码工具,我们可以通过多种方式来下载
如果安装了NodeJs的话可以使用npm i -g opencode-ai,也比较推荐使用nodeJs安装,因为很多mcp是基于npx使用的。
如果没有NodeJs的话,可以用更通用的curl -fsSL https://opencode.ai/install | bash来进行安装

1 2 3 4 5 6 7 8 9 10 11 12 13
| { "mcpServers": { "BlogMonitor": { "command": "python", "args": ["绝对路径/llm08-mcp-intro/mcp_server.py"], "env": { "TARGET_EMAIL": "[email protected]", "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,然后填入
1 2 3 4 5 6 7 8 9
| { "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 子进程。当咱们关闭编辑器时,子进程会被操作系统自动回收,不会留下僵尸进程。
- 天生安全:数据只在父子进程间的标准输入输出流中传递,不需要进行复杂的网络鉴权,也不用担心被同网段的其他机器恶意调用。
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。