
如果你手头有一台带串口指令的 IoT Power 功耗计又天天盯着 AI 写代码的能力流口水这个项目应该正合胃口。我用一个下午给功耗计写了一个 MCPModel Context Protocol服务端把它接进了 AI 对话流——现在我在对话框里问“当前输出功率多少”AI 会自己去读串口、解析报文然后把电压、电流、功率整理好回给我。这种“让 AI 自己看功耗计”的体验一旦跑通就回不去了而且它不挑客户端Claude Desktop、Cursor、Codex 这些支持 MCP 的软件都能直接用。这篇就按我实际踩坑的路线来写从整体设计、MCP 协议怎么和串口设备对接到完整代码、客户端配置和排错经验都一并摊开。适合手头有 IoT Power 或类似串口功耗设备、想深入理解 MCP 服务端写法、或者想给 AI Agent 接真实硬件的开发者。1. 项目全貌与关键设计决策1.1 为什么是 MCP而不是让 AI 自己写串口代码最开始我面临三个方案简单对比一下就能明白为什么最终选 MCP方案优点缺点结论让 AI 现场写一段 Python 串口读取代码零开发量AI 每次生成的代码不一样依赖也难装硬件通信还容易踩权限坑不稳定仅适合演示自己写一个 REST 服务让 AI 通过 HTTP 调用接口清晰可控要额外部署服务、处理鉴权、做进程管理本地场景过于重量可行但没必要写一个 MCP 服务端声明工具给 AI 直接调客户端原生支持一次写好到处复用需要理解 MCP 协议和 SDK选它MCP 解决的核心问题是“模型上下文协议”它定义了一套标准化的消息格式让 AI 应用能发现外部工具、调用外部工具、读取外部资源。协议本身不关心底层设备是串口、蓝牙还是 USB只负责传输“这个工具叫什么、参数是什么、返回什么”。所以我把功耗计封装成几个小工具AI 只要看到工具描述和参数 schema就知道怎么调用、怎么解读返回结果。这个取舍背后还有一个实际原因我经常需要在不同客户端之间切换。给 Claude Desktop 写的服务端如果协议是私有 JSON-RPC那换到 Cursor 又得重写一套。而 MCP 现在已经成为 AI 客户端的公共协议写一次服务端配置 JSON 里改一行地址就能到处接。这个“一次封装、到处复用”的价值在真正维护过三套客户端接入后会觉得特别香。1.2 服务端架构与工具粒度设计项目整体结构一句话说就是AI 客户端通过标准输入输出stdio启动我的 Python 进程进程内部维护一个串口连接收到 AI 发来的工具调用请求后翻译成功耗计的 ASCII 指令读完回复再打包成 MCP 格式返回。我选的工具栈是 Python FastMCP pySerial。FastMCP 是目前封装 MCP 协议最舒服的 Python 库几行装饰器就能把一个普通函数暴露成工具底层 JSON-RPC 握手全部遮掉。pySerial 则是 Python 操作串口的标准库跨平台Windows 下用 COM 口、Linux 下用 /dev/ttyUSB0行为一致。工具粒度是设计里最容易被忽略的地方。一开始我想按电压、电流、功率分成三个工具让 AI 分别调用。后来实测发现AI 问“当前功率”时会先调 voltage 又调 current来回折腾而且多次调用之间设备状态可能有变化读出来的数据三个时间点对不上。最终我收敛成三个核心工具read_snapshot一次读取电压、电流、功率三组实时数据适合大多数问答场景。read_series按照设定次数和间隔连续采样返回一组带时间戳的记录适合让 AI 做均值、波动分析。raw_query透传任意指令给设备适合调试和覆盖我没预设到的功能。工具不是越细越好而是越贴合 AI 的“思考习惯”越好。如果 AI 只需要知道一个整机功耗值你却只让它读某一路电流它还得自己乘电压容易出错。把常用动作封装成完整语义的原子操作AI 调用一次就拿到完整答案是服务端设计里很关键的一点。2. MCP 协议与功耗计协议的对接原理2.1 MCP 服务端在协议栈里的位置MCP 是一个应用层软件协议跟设备侧指令集完全是两码事。功耗计说话用的是串口 ASCII 指令MCP 服务端是夹在 AI 和硬件之间的翻译官。它要做两件事向上用 MCP 协议和 AI 客户端对话向下用设备协议和功耗计对话。MCP 有三个开发者最常用的原语tools、resources、prompts。本项目核心是 tools也就是把功耗计的能力暴露成可执行的函数。resources 适合暴露不需要参数的数据内容比如设备信息prompts 适合预置常用操作模板比如“帮我测一下充电器纹波”。理解 MCP 服务端时不用把这三个原语想得太玄就当成三种和 AI 交互的方式能执行的动作是 tool能读取的状态是 resource能填好的对话模板是 prompt。协议传输层上本地这类工具服务优先用 stdio。MCP 客户端启动时把我的 Python 进程作为子进程运行通过 stdin/stdout 传递 JSON-RPC 2.0 消息。stdio 传输最大的好处是免鉴权、免端口、环境隔离好——服务端不会在网络里裸奔也不会被别的机器扫到端口。这也是 MCP 设计里“本地优先”的体现。2.2 一次完整调用的生命周期刚开始调 MCP 服务端时最困惑的是“AI 怎么知道我的工具存在”。实际上整个流程是这样的客户端启动我的 Python 进程先发一个initialize请求双方确认 MCP 版本和协议能力。客户端发notifications/initialized通知服务端已就绪。客户端发送tools/list我的服务端返回所有用 FastMCP 装饰器注册的工具列表包括每个工具的描述、参数类型和必需项。用户提问后客户端判断需要调用哪个工具发送tools/call请求里面带工具名和参数。我的服务端执行函数访问串口读数据把结果组装成 MCP 的 content 数组返回。客户端把返回文本交给大模型大模型整理成自然语言回答用户。这个生命周期里有一个容易被忽略的细节AI 判断“该用哪个工具”依赖的是工具描述和参数名而不是你代码里的函数名注释。也就是说docstring 里写什么直接影响 AI 能不能正确调用。比如我在read_snapshot的 docstring 里明确写了“返回电压(V)、电流(A)、功率(W)单位分别是伏特、安培、瓦特”AI 就不会把数值误读成其他单位。这在后面实战里还会体会到重要性。2.3 串口侧协议设计功耗计这边的协议并不复杂但每个设备都不太一样。我目前用的这台 IoT Power 默认波特率 115200指令以 ASCII 文本行为单位典型命令长这样*IDN?查询设备身份信息。MEAS:VOLT?读电压。MEAS:CURR?读电流。MEAS:POW?读功率。OUTPut:STATe ON打开输出。如果你的设备是 SCPI 风格基本能无缝对接如果是 Modbus 风格需要把读写 PDU 封装一下。我这边先按 SCPI 风格设计因为这类指令人眼可读、调试方便也符合 MCP 工具“语义清晰”的要求。串口通信的几个要点要提前想清楚否则后面全是坑第一指令必须以\r\n结尾很多设备对换行符敏感只发\n可能导致它一直不回包。第二每次查询前最好清一次输入缓冲。设备偶尔会残留上一次的响应碎片不清缓冲会出现“把上次的尾巴当成这次的结果”这种诡异问题。第三串口是独占资源MCP 工具被 AI 并发调用时必须用线程锁保护否则两个查询同时写指令响应就交叉错乱了。第四超时处理要比想象中更严格。AI 客户端等待工具返回有时间窗口如果串口没接对或设备没上电函数一直阻塞AI 就会认为工具无响应。所以每次 query 都要设置超时超时后抛异常让 AI 看到明确错误而不是干等。3. 实操从零写一个可运行的 MCP 服务端3.1 环境准备先把 Python 环境准备好。建议用虚拟环境避免污染系统环境python -m venv .venv source .venv/bin/activate pip install fastmcp pyserial如果你是 Windows激活命令是.venv\Scripts\activate如果你要使用 MCP Inspector 调试工具再装一个官方 CLIpip install mcp硬件方面IoT Power 一般通过 USB 转 TTL 串口接电脑。连接时注意几个引脚TXD 接设备的 RXD、RXD 接设备的 TXD、GND 接 GND。接错 TX/RX 不会烧设备但你会发现“指令发出去没反应”因为两者在互相等待对方说话。Linux 下插入 USB 转串口后大概率会出现/dev/ttyUSB0或/dev/ttyCH340Windows 下通常是 COM3 这类端口名。可以用串口助手先手动发一条*IDN?确认通信链路正常再进行下一步——这一步能省掉后面一半的排查时间。3.2 服务端完整代码代码量不多核心就一个设备类加三个工具函数。先把串口设备封装成独立类这样 MCP 工具层只是薄薄一层转发import threading import time import serial from fastmcp import FastMCP from pydantic import Field mcp FastMCP(iot-power) class PowerMeter: def __init__(self, port: str /dev/ttyUSB0, baudrate: int 115200): self.ser serial.Serial( portport, baudratebaudrate, bytesize8, parityN, stopbits1, timeout1.0, ) self._lock threading.Lock() def query(self, command: str) - str: with self._lock: self.ser.reset_input_buffer() self.ser.write((command \r\n).encode(ascii)) line self.ser.readline().decode(ascii, errorsreplace).strip() if not line: raise RuntimeError(fCommand timeout: {command}) return line def snapshot(self) - dict: voltage float(self.query(MEAS:VOLT?)) current float(self.query(MEAS:CURR?)) power float(self.query(MEAS:POW?)) return { voltage_v: voltage, current_a: current, power_w: power, timestamp: time.time(), } dev PowerMeter(port/dev/ttyUSB0, baudrate115200) mcp.tool() def read_snapshot() - dict: 读取功耗计当前快照返回电压(V)、电流(A)、功率(W)。当用户询问当前电压、电流或功耗时调用。 return dev.snapshot() mcp.tool() def read_series( samples: int Field(ge1, le30, description采样次数最大 30), interval_ms: int Field(ge100, le5000, description采样间隔毫秒最小 100), ) - list: 按固定间隔连续采样返回一组电压电流功率数据适合分析平均值和波动。 result [] for _ in range(samples): result.append(dev.snapshot()) if _ samples - 1: time.sleep(interval_ms / 1000.0) return result mcp.tool() def raw_query(command: str) - str: 透传一条原始指令给功耗计返回设备原始响应文本。仅调试时使用。 return dev.query(command) if __name__ __main__: mcp.run(transportstdio)代码讲几个关键位置。PowerMeter.query里那把threading.Lock是必须的——FastMCP 默认按请求分发如果 AI 在一次对话里同时调用了多个工具没有锁的话两条指令会同时往串口里写读回来的数据就乱了。snapshot里直接float()解析设备返回值功耗计返回的都是纯数字字符串比如5.0123解析失败时异常会沿着 MCP 通道传回给 AIAI 会告诉你“设备响应解析失败”这比静默吞掉错误好得多。FastMCP实例化时传入的字符串iot-power是服务端名称会显示在客户端 MCP 服务器列表里。工具函数用mcp.tool()注册函数名就是工具名docstring 就是工具描述参数类型和 Field 约束会自动生成 JSON Schema。Field(ge1, le30)把采样次数限制在 1 到 30 之间否则用户让 AI 采样一万次工具会长时间阻塞很可能超过客户端等待时限。3.3 本地调试先用 MCP Inspector 验证工具写完代码别急着直接接客户端先跑一遍 MCP Inspector。这个工具会以图形界面加载你的服务端列出所有注册的工具你可以手动点击调用不用经过大模型推理。运行方式python -m mcp dev server.py浏览器里打开它给的地址左侧能看到read_snapshot、read_series、raw_query三个工具。点read_snapshot的 Call 按钮如果返回{voltage_v: 5.12, current_a: 1.35, power_w: 6.91}这类数据说明你的服务端协议没问题设备链路也正常。这一步特别值得养成习惯。因为 MCP Inspector 帮你剥离了“AI 会不会用”这个变量只验证“服务端能不能返回”。如果工具在 Inspector 里能跑通后面接入 AI 客户端就只剩配置问题如果不行你也不需要去读大模型日志直接看串口和函数逻辑就行。实测下来这个工作流至少帮我省掉了两小时无意义的“和 AI 对话式排查”。4. 接入 AI 客户端与实测效果4.1 客户端配置以 Claude Desktop 为例配置文件路径在claude_desktop_config.json里加一个mcpServers节点{ mcpServers: { iot-power: { command: /home/user/projects/iot-power-mcp/.venv/bin/python, args: [ /home/user/projects/iot-power-mcp/server.py ] } } }这里有个我实测踩过的大坑command字段一定要写虚拟环境里 Python 的绝对路径不要写python。原因是桌面客户端启动进程时不会加载你的 shell 配置PATH 环境变量很可能不指向虚拟环境如果写成裸python服务端可能用系统 Python 启动然后报ModuleNotFoundError: fastmcp。Linux 和 macOS 都有这个问题Windows 上则要注意写清python.exe的完整路径。Cursor 的配置位置稍有不同在项目根目录.cursor/mcp.jsonCodex 可以通过命令行添加。但本质相同都是给客户端提供“命令 参数”客户端负责拉起服务端子进程。配置完成后重启客户端如果一切正常MCP 服务器列表里会出现iot-power和它下面的几个工具。4.2 实测对话效果配置好后我通常先问一句“你现在能读到什么设备信息吗”AI 会调用raw_query(*IDN?)拿到设备厂商和型号然后回我一句“连接到了 IoT Power波特率 115200”。这种“AI 自己探索设备身份”的过程很能确认链路已经跑通。再试真正的功率读取。我问“帮我读一下当前负载的输出电压和功率。”AI 会调用read_snapshot返回类似{ voltage_v: 5.121, current_a: 1.352, power_w: 6.917 }它接着会把数值翻译成自然语言“当前输出电压 5.121V电流 1.352A功率约 6.92W。”如果问它“连续采样 10 次间隔 200 毫秒算一下平均功率”它会用read_series拿到 10 条记录然后用代码解释器算平均值和标准差最后给你一份波动情况总结。这种“读仪表 数据分析 语言总结”的组合能力正是单靠指令集交互很难实现的体验。4.3 可选的扩展工具如果你的设备支持控制类指令再封装一两个写操作也很顺手。比如我这台支持OUTPut:STATe就可以加一个工具mcp.tool() def set_output_enabled(enabled: bool Field(description是否打开输出)) - dict: 开关功耗计输出通道返回操作后的输出状态。 state ON if enabled else OFF response dev.query(fOUTPut:STATe {state}) return {output_enabled: enabled, device_response: response}加上这个工具后AI 就不只是“看功耗计”还能“操作功耗计”。比如你可以让它做一轮完整的电源测试开输出采样功率关输出生成一条时间线。这个场景对测试电源适配器、验证充电协议非常有用。不过要强调写操作工具必须有清晰的 docstring并且最好加一层参数校验AI 有时会误解自然语言比如你说“帮我关一下”它可能把 enabled 传成False所以返回里带上device_response能让你追踪设备侧真实状态。5. 常见问题与排错实录5.1 串口层问题现象可能原因解决办法启动服务端时报serial.serialutil.SerialException串口被占用或权限不足Linux 下把用户加入dialout组或加 udev 规则Windows 下确认串口助手已关闭能发指令但读不到响应TX/RX 接反或设备未上电先用串口助手手动发*IDN?测试检查 GND 是否连接返回内容乱码波特率不匹配或换行符不对翻设备手册确认波特率尝试\n与\r\n两种结尾串口问题的排查思路很简单先用排除法确认设备本身是好的。我会用串口助手把波特率调到 115200发*IDN?看有没有可读响应。如果串口助手里都没响应那就不是 MCP 的事是接线、供电或端口配置问题如果串口助手里正常但 MCP 服务端读不到问题在代码的换行符或超时设置上。5.2 MCP 协议与客户端配置问题我自己遇到最 spooky 的问题是服务端在命令行里跑得好好的但客户端就是连不上工具列表加载不出来。查了半天发现是有个调试日志用print写到了 stdout。MCP 使用 stdio 传输时stdout 是协议通道任何非协议内容的输出都会让客户端解析 JSON 失败。解决方法是把日志全部改道到 stderr比如import sys print([debug] query: MEAS:VOLT?, filesys.stderr)或者干脆用logging模块配置一个 StreamHandler 指向sys.stderr。凡是走 stdio transport 的 MCP 服务端一律不要向 stdout 写日志这条能记一辈子。另一个常见问题是启动后客户端显示“工具执行失败”但 Inspector 里正常。这种情况多半是工具函数里抛了异常而异常信息没有被结构化返回。FastMCP 默认会捕获异常并把错误信息作为文本返回但如果异常发生在serial.Serial初始化阶段服务端进程直接退出客户端就只显示“连接失败”。所以设备连接动作不要在 import 时执行最好放在工具首次调用时惰性初始化或者用 try/except 包起来把错误文本抛给 MCP 层。5.3 从踩坑中总结的经验最后分享两条我实际使用下来的体会。第一条经验是 docstring 要写成“给 AI 看的说明文档”而不是“给人看的技术注释”。我说的不是代码风格而是像“返回电压(V)、电流(A)、功率(W)”这种明确带单位、带调用时机的描述。AI 选择工具的准确度高度依赖这段文本我曾经把 docstring 写成read current voltage and current and power结果 AI 分不清该调read_snapshot还是read_series经常随机选一个。后来把描述改成“当用户询问当前电压、电流或功耗时调用”准确率立刻上来了。第二条经验是保留一个raw_query入口但只存在于调试阶段。它一方面让我能手动探索设备固件支持哪些指令另一方面也给 AI 留了一条“自己尝试新指令”的路。不过这个工具权限很大比如如果设备支持SYSTem:REBootAI 可能在你毫无防备情况下重启设备。所以正式使用时我会把它从注册表里删掉只保留语义清晰的业务工具。这算是我给所有 MCP 服务端定下的规矩宁可少一个工具也不要给 AI 过大的底层自由。