核心摘要 (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 osimport requestsimport feedparserfrom dotenv import load_dotenvfrom 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 osimport requestsimport feedparserimport loggingimport sysfrom dotenv import load_dotenvfrom mcp.server.fastmcp import FastMCP# 初始化 MCP 服务器current_dir = os.path.dirname(os.path.abspath(__file__)) # 这里锁死脚本所在路径,避免到时候在外层通过client运行mcp的时候,读不到.envenv_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. 核心避坑:清空可能被框架提前注入的默认 handlerif logger.hasHandlers(): logger.handlers.clear()# 3. 创建格式化器formatter = logging.Formatter('%(asctime)s - %(levelname)s - [%(funcName)s] - %(message)s')# 4. 配置文件 Handlerfile_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 asynciofrom mcp.client.session import ClientSessionfrom mcp.client.stdio import stdio_client, StdioServerParametersasync 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是一个基于命令行的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 子进程。当咱们关闭编辑器时,子进程会被操作系统自动回收,不会留下僵尸进程。
- 天生安全:数据只在父子进程间的标准输入输出流中传递,不需要进行复杂的网络鉴权,也不用担心被同网段的其他机器恶意调用。
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
本文链接: https://blog.algieba12.cn/llm08-mcp-intro/
版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
[code][/code]
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |