MCP协议实战:大模型标准接口告别孤岛
前言
咱们这个大模型实战的博客系列已经快到尾声了。一路走来,各位应该对大模型的实战方向有了不少概念——从本地部署、云端微调,到RAG知识库、Agent开发,一步步把这些听起来唬人的名词拉下神坛。
当然,这些内容可能还停留在入门阶段,深度有限。但至少有一点是确定的:咱们已经告别大模型小白的阶段了。这个系列从一开始的目标,就是想让各位对大模型的实际应用方向和概念有一个整体认知。大模型不是"王谢堂前燕",普通人一样可以掌握。
往后的路,就得靠各位自己感兴趣的方向去深入探索了——在实践中碰壁,选择更合适的框架和技术栈,了解工程上的指标和最佳实践。
好了,感慨到此为止。这是本系列的最后一篇博客,我们来聊聊MCP。
1. MCP的概念
先一句话概括:MCP就是一个Agent工具的USB接口。
回忆一下,在USB接口普及之前,手机数据传输虽然也能用,但每个品牌甚至每款手机的接口都不一样,充电线换来换去,麻烦得很。USB标准一出来,所有设备都统一了——同样的接口,通用的协议,省心省力。
MCP干的也是同样的事。全称Model Context Protocol(模型上下文协议),这个名字听起来有点抽象。通俗点说,它就是告诉大模型三件事:
- 有什么工具可以调用
- 有哪些资源可以读取
- 有哪些预置提示词可以用
这三样合起来,就是"Model Context"(模型上下文)。而MCP本身,就是定义如何让模型知道这些信息的通信协议。
它的核心架构分为三层:
- MCP Host(客户端):内置大模型的调用方,发起请求的一方。常见的包括OpenCode、ClaudeCode、Claude Desktop、Cursor或VS Code。正因为协议统一,这些客户端都可以通过同一个接口去调用同一个服务。
- MCP Server(服务端):提供工具和信息的服务方。一般来说,我们开发的就是这一层——将我们的工具、资源、提示词封装起来提供服务。
- Transport(传输层):客户端和服务端之间的通信通道。目前主要有两种: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服务
我们将之前写的获取最新博客和发送通知的工具,封装成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发给客户端,告知调用方式。
- 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())
可以看到,整个客户端的逻辑完全符合之前的通信时序图:先注册、建立连接、获取工具/资源/Prompt列表,然后对工具进行调用。这里没有真的去调一个大模型,而是人工模拟了大模型的输出。
2.5 在OpenCode客户端使用
- 安装OpenCode
OpenCode是一个基于命令行的AI编码工具,可以通过多种方式下载。
如果安装了Node.js,可以使用 npm i -g opencode-ai,也比较推荐这种方式,因为很多MCP是基于npx使用的。如果没有Node.js,可以用更通用的 curl -fsSL https://opencode.ai/install | bash 安装。
- 运行OpenCode
选一个项目文件夹,然后运行 opencode,就可以把OpenCode运行起来。它内置了一些免费模型供使用。
- 安装MCP
在所有MCP客户端中,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),因为服务端代码里已经通过 .env 配置了。
对于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客户端的配置可能稍有不同,但都相差不大。
配置好之后,重新用 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。





