1. 为什么我要自己动手写一个 MCP
1.1 从一次崩溃的 Excel 合并说起
上个月帮朋友处理一批销售数据,二十多个 Excel 文件,每个文件结构还不完全一样,有的表头在第一行,有的在第三行,有的列名是"销售额",有的是"销售金额"。我一开始想用 Python 脚本硬写,结果光是对齐列名就改了七八版,中间还因为某个文件里混了个合并单元格,直接报错中断。那天晚上我盯着屏幕想,这种活儿明明有规律,为什么每次都要重新写一遍胶水代码。
后来接触到 MCP 这个概念,才意识到问题的本质:我缺的不是脚本,而是一个能被 AI 直接调用的"能力接口"。传统做法是我写脚本、我调试、我运行,AI 只能在旁边看着。而 MCP 的思路是,我把"读 Excel""写 Excel""统计某列关键词求和"这些能力封装成标准工具,AI 自己决定什么时候调用哪个工具、传什么参数。这就把"我指挥 AI 写代码"变成了"AI 指挥我的工具干活",效率完全不是一个量级。
这篇文章就是记录我从零开发第一个 MCP 服务的完整过程。核心目标很明确:用 Python 写一个能处理 Excel 的 MCP 服务,让 AI 能直接读取表格、筛选数据、做统计、写回结果。适合有 Python 基础、被 Excel 重复劳动折磨过、想搞明白 MCP 到底怎么落地的人。如果你连 Python 都没装过,建议先花半小时把环境搞定再回来,后面会提到具体怎么装。
1.2 MCP 到底是个什么东西
先把概念说清楚,不然后面全是糊涂账。MCP 全称 Model Context Protocol,翻译过来叫"模型上下文协议"。你可以把它理解成 AI 和外部工具之间的"USB 接口标准"。以前每个 AI 应用要调用外部能力,都得自己定一套私有协议,A 家的插件 B 家用不了。MCP 就是把这个接口标准化了:只要你的服务按 MCP 规范暴露能力,任何支持 MCP 的 AI 客户端都能直接调用。
它跟"工作流"的区别在哪?像 Coze 工作流、ComfyUI 工作流那种,是你提前把步骤编排好,AI 按固定路线走。MCP 更灵活,它只提供"工具箱",具体用哪个工具、按什么顺序用,由 AI 在对话过程中动态决定。比如你说"帮我把这三个表里销售额超过一万的记录挑出来合并",AI 会自己判断:先调读取工具、再调筛选工具、最后调写入工具。这个决策过程是它自己完成的,不需要你预先画流程图。
MCP 服务端一般暴露三类东西:工具(Tools),就是可执行的动作,比如读文件、算数据;资源(Resources),就是可读取的数据,比如某个文件的内容;提示(Prompts),就是预设的指令模板。我们这次主要用工具这一类,因为 Excel 处理的核心就是"执行动作"。
注意:MCP 是软件层面的协议,跟硬件接口那个概念不是一回事。别被名字里的"协议"两个字带偏,它本质是一套 JSON-RPC 风格的通信规范,跑在本地进程或者网络连接上。
2. 开发前的环境准备与工具选型
2.1 Python 环境怎么装才不踩坑
Python 安装这件事,看着简单,坑不少。我推荐直接用官网的安装包,别用某些第三方整合包,那些包经常自带一堆你用不上的库,还容易和系统环境打架。去 python.org 下载 3.10 以上的版本,安装的时候务必勾选"Add Python to PATH",这一步漏了后面命令行里敲 python 会提示找不到命令,新手最容易卡在这。
装完之后验证一下,打开终端敲:
python --version pip --version两个都能正常输出版本号才算成功。如果 pip 报错,多半是环境变量没配好,重新跑一遍安装程序选"Modify"把 PATH 选项勾上。
接下来建虚拟环境。这一步很多人嫌麻烦跳过,结果项目依赖和系统 Python 混在一起,后面出问题很难排查。我的习惯是每个项目单独建一个:
python -m venv mcp_excel_env # Windows mcp_excel_env\Scripts\activate # macOS / Linux source mcp_excel_env/bin/activate激活之后命令行前面会出现(mcp_excel_env)字样,说明你在这个隔离环境里了。后面所有 pip 安装都装在这里面,不会污染全局。
2.2 核心依赖库的选择理由
处理 Excel 的库有好几个,我选的是openpyxl,理由很实在:它同时支持读写.xlsx,能处理公式、样式、合并单元格,而且纯 Python 实现,不依赖 Excel 软件本身。pandas 虽然强大,但它更适合做数据分析,写回 Excel 时对格式的控制不如 openpyxl 精细。至于 xlrd,它只读不写,而且新版已经不支持 xlsx 了,直接排除。
MCP 服务端我用官方推荐的mcpPython SDK,它把协议细节都封装好了,我们只需要用装饰器定义工具函数就行。安装命令:
pip install mcp openpyxl如果你还想让 AI 能处理更复杂的表格统计,可以顺手装上 pandas:
pip install pandas但注意,pandas 读进来的 DataFrame 和 openpyxl 的 workbook 是两套对象,混用的时候要清楚自己在操作哪个。我一般用 openpyxl 做读写,用 pandas 做复杂聚合,中间用列表或字典转换。
2.3 目录结构规划
别小看目录结构,一开始乱放文件,后面改起来想死。我的项目结构是这样的:
mcp-excel-server/ ├── server.py # MCP 服务主入口 ├── excel_tools.py # Excel 处理逻辑 ├── requirements.txt # 依赖清单 └── test_data/ # 测试用的 Excel 文件把业务逻辑和协议入口分开,好处是以后想换协议实现(比如从 stdio 换成别的传输方式),业务代码一行不用动。requirements.txt里写清楚版本,方便复现:
mcp>=1.0.0 openpyxl>=3.1.0 pandas>=2.0.0实操心得:虚拟环境 + requirements.txt 这套组合,是我踩过无数次"换台电脑就跑不起来"的坑之后养成的习惯。多花两分钟,省下两小时。
3. 核心工具的设计与实现细节
3.1 工具划分的思路
MCP 服务好不好用,关键看工具怎么切分。切得太粗,一个工具干十件事,AI 传参容易出错;切得太细,AI 要调十几次,效率低还容易乱。我的划分原则是:一个工具对应一个明确的、可独立验证的动作。
针对 Excel 处理,我设计了四个核心工具:
| 工具名 | 功能 | 输入 | 输出 |
|---|---|---|---|
| read_excel | 读取表格内容 | 文件路径、工作表名 | 二维数组 |
| filter_rows | 按条件筛选行 | 数据、列名、条件 | 筛选后数据 |
| sum_by_keyword | 某列含关键词的对应列求和 | 数据、关键词列、求和列、关键词 | 求和结果 |
| write_excel | 写入数据到表格 | 数据、输出路径 | 成功状态 |
这四个覆盖了"读-筛-算-写"的完整链路。你会发现sum_by_keyword这个工具很具体,它对应的正是热搜里那个"Excel 同一列中统计含关键词对应数据求和"的需求。与其让 AI 自己写公式,不如把这个高频操作封装成专用工具,一次调用搞定。
3.2 读取工具的实现与边界处理
先看read_excel的实现。核心逻辑不复杂,但边界情况必须处理:
from openpyxl import load_workbook def read_excel(file_path: str, sheet_name: str = None) -> list: wb = load_workbook(file_path, data_only=True) ws = wb[sheet_name] if sheet_name else wb.active data = [] for row in ws.iter_rows(values_only=True): data.append(list(row)) wb.close() return data这里有几个关键点。data_only=True很重要,它让 openpyxl 读取公式的计算结果而不是公式本身。如果你不加这个参数,读到的单元格值是=SUM(A1:A10)这种字符串,根本没法用。我第一次就栽在这,统计出来全是公式文本,排查了半天。
sheet_name默认取活动工作表,但实际文件里活动表不一定是你要的那张。所以我在工具描述里明确告诉 AI:如果不确定表名,先调用一个列出所有工作表名的工具。这就引出了第五个工具list_sheets,虽然简单但很实用:
def list_sheets(file_path: str) -> list: wb = load_workbook(file_path, read_only=True) names = wb.sheetnames wb.close() return namesread_only=True让 openpyxl 用只读模式打开,大文件时内存占用低很多。一个几十兆的 Excel,只读模式几秒就能列出表名,普通模式可能要等十几秒。
3.3 关键词求和工具的参数设计
sum_by_keyword是这次的重头戏,因为热搜里明确提到了这个需求。它的逻辑是:在指定列里找包含关键词的行,把这些行对应的另一列数值加起来。
def sum_by_keyword(data: list, keyword_col: int, sum_col: int, keyword: str) -> float: total = 0.0 for row in data[1:]: # 跳过表头 cell = row[keyword_col] if cell is not None and keyword in str(cell): value = row[sum_col] if isinstance(value, (int, float)): total += value return total参数用列索引而不是列名,是因为列名可能有重复或者带空格,AI 传参时容易出错。但列索引对人不友好,所以我在工具的 schema 描述里写清楚:"keyword_col 是关键词所在列的索引,从 0 开始计数"。同时提供一个辅助工具get_headers返回表头,让 AI 先看表头再决定索引。
这里有个细节:keyword in str(cell)用的是包含匹配,不是相等匹配。因为实际数据里关键词往往嵌在长文本里,比如"华东区-销售额"包含"华东"。如果你要精确匹配,得改成==。我在工具描述里注明了默认是包含匹配,需要精确匹配时传一个exact参数。
注意:数值判断用
isinstance(value, (int, float)),因为 Excel 里有些单元格看着是数字,实际是文本格式,直接相加会报错。这个坑我在处理财务数据时踩过,明明看着是 1000,加进去变成字符串拼接了。
3.4 写入工具的格式保留策略
write_excel最容易出问题的地方是格式丢失。如果你用 openpyxl 新建一个 workbook 写入,原来的字体、颜色、列宽全没了。我的做法是:如果目标文件已存在,先加载它,再往指定位置写,这样原有格式能保留。
def write_excel(data: list, output_path: str, sheet_name: str = None, start_row: int = 1): import os if os.path.exists(output_path): wb = load_workbook(output_path) ws = wb[sheet_name] if sheet_name else wb.active else: from openpyxl import Workbook wb = Workbook() ws = wb.active if sheet_name: ws.title = sheet_name for i, row in enumerate(data): for j, value in enumerate(row): ws.cell(row=start_row + i, column=j + 1, value=value) wb.save(output_path) wb.close() return f"已写入 {len(data)} 行到 {output_path}"start_row参数是为了支持追加写入。比如你想把筛选结果写到原表下方,传start_row=原表行数+2就行。这个参数不加的话,每次写入都从第一行开始,会覆盖原有数据。
4. 把工具接入 MCP 服务端
4.1 用装饰器注册工具
MCP Python SDK 的用法很直观,用@server.tool()装饰器把普通函数变成 MCP 工具:
from mcp.server import Server from mcp.server.stdio import stdio_server server = Server("excel-processor") @server.tool() def read_excel(file_path: str, sheet_name: str = None) -> list: """读取 Excel 文件内容,返回二维数组。sheet_name 不传则读活动工作表。""" # ... 实现同上 @server.tool() def sum_by_keyword(data: list, keyword_col: int, sum_col: int, keyword: str) -> float: """在 keyword_col 列中查找包含 keyword 的行,对 sum_col 列求和。""" # ... 实现同上装饰器会自动读取函数的类型注解和文档字符串,生成 MCP 协议需要的工具描述。所以类型注解一定要写全,文档字符串要写清楚每个参数的含义。AI 就是靠这些信息判断该传什么参数的。我见过有人函数写得没问题,但文档字符串就一句"处理数据",结果 AI 完全不知道该传什么,调用一直失败。
4.2 启动服务与传输方式
MCP 服务启动用 stdio 传输,也就是标准输入输出。这是本地开发最常用的方式,AI 客户端把服务当子进程启动,通过管道通信:
import asyncio async def main(): async with stdio_server() as (read_stream, write_stream): await server.run(read_stream, write_stream, server.create_initialization_options()) if __name__ == "__main__": asyncio.run(main())这段是模板代码,基本不用改。server.run会进入事件循环,等待客户端发来的请求。你不需要自己处理 JSON 解析、请求路由这些,SDK 全包了。
启动命令就是python server.py。但注意,直接运行的话它会在那等着,不会输出任何东西,这是正常的。它是在等客户端连接。你要测试的话,得用 MCP 客户端或者支持 MCP 的 AI 工具来连。
4.3 在 AI 客户端里配置
不同客户端的配置方式不一样,但核心都是告诉它"启动哪个命令来运行这个 MCP 服务"。以常见的配置文件为例,大概长这样:
{ "mcpServers": { "excel-processor": { "command": "python", "args": ["/path/to/mcp-excel-server/server.py"] } } }command是启动命令,args是参数。如果你用了虚拟环境,command要指向虚拟环境里的 python,比如/path/to/mcp_excel_env/bin/python,否则可能找不到安装的依赖。这个路径问题是我调试时最常遇到的,服务启动就报ModuleNotFoundError,一查就是用了系统 python 而不是虚拟环境的。
配置好之后重启客户端,它应该能列出你的工具。你可以直接对话测试:"帮我读一下 test_data/sales.xlsx 里有哪些工作表",看它会不会调用list_sheets。
5. 实战演练:一个完整的处理流程
5.1 准备测试数据
我造了一个销售数据表sales.xlsx,结构如下:
| 区域 | 产品 | 销售额 | 备注 |
|---|---|---|---|
| 华东区 | A产品 | 12000 | 重点客户 |
| 华北区 | B产品 | 8500 | 华东区调货 |
| 华东区 | C产品 | 15000 | 新客户 |
| 华南区 | A产品 | 9800 | 常规 |
| 华东区 | B产品 | 11000 | 续约 |
这个表故意设计了一个陷阱:第三行"备注"列里也出现了"华东区",如果统计时不指定列,可能会把备注里的关键词也算进去。这正是考验工具参数设计的地方。
5.2 对话式调用全过程
我在 AI 客户端里输入:"读取 sales.xlsx,统计区域列中包含'华东区'的行的销售额总和。"
AI 的决策过程是这样的:先调list_sheets确认表名,再调read_excel拿到数据,然后调get_headers确认"区域"在第 0 列、"销售额"在第 2 列,最后调sum_by_keyword传参keyword_col=0, sum_col=2, keyword="华东区"。返回结果 38000(12000+15000+11000)。
整个过程我一句话没说参数,AI 自己搞定的。这就是 MCP 的价值:我把能力封装好,AI 负责编排。如果换成传统脚本,我得自己写if "华东区" in row[0]这样的判断,还得处理表头、列索引这些琐事。
5.3 处理结果写回
接着我说:"把销售额大于 10000 的行筛选出来,写到新文件 result.xlsx。"
AI 调filter_rows拿到筛选结果,再调write_excel写入。这里有个细节:filter_rows返回的数据包含表头,因为写回时也需要表头。所以筛选工具的实现里,我特意保留了第一行:
def filter_rows(data: list, col: int, threshold: float) -> list: result = [data[0]] # 保留表头 for row in data[1:]: value = row[col] if isinstance(value, (int, float)) and value > threshold: result.append(row) return result如果不保留表头,写出来的文件第一行就是数据,看着很别扭。这种小细节,只有真正用过的人才会注意到。
实操心得:工具之间的数据格式要统一。我所有工具都约定"第一行是表头",这样工具可以随意串联,不用每次转换格式。这个约定写在每个工具的文档字符串里,AI 也能理解。
6. 常见问题与排查技巧
6.1 服务启动失败怎么查
最常见的问题是依赖找不到。排查顺序:先确认虚拟环境激活了没,再确认pip list里有没有 mcp 和 openpyxl,最后确认客户端配置里的 python 路径是不是虚拟环境的。我遇到过配置文件里写了相对路径,结果客户端工作目录不对,找不到 server.py,改成绝对路径就好了。
第二个常见问题是端口或管道冲突。stdio 方式一般不会冲突,但如果你同时开了多个客户端连同一个服务,可能会互相干扰。解决办法是每个客户端配一个独立的服务实例。
6.2 工具调用报参数错误
AI 传参错误通常是因为工具描述不清楚。比如keyword_col我写的是"列索引",AI 可能传列名"区域"进来。后来我在描述里加了示例:"例如区域在第 0 列,就传 0",错误率明显下降。
还有一种情况是数据类型不匹配。AI 可能把数字传成字符串,比如threshold="10000"而不是10000。我在函数内部加了类型转换兜底:
threshold = float(threshold)这样即使 AI 传字符串也能正常工作。防御性编程在 MCP 开发里特别重要,因为你无法控制 AI 怎么传参。
6.3 大文件处理超时
Excel 文件超过几万行时,读取和写入会变慢。我的优化手段是:读取用read_only=True,写入用write_only=True(如果不需要保留格式)。另外可以在工具里加一个max_rows参数,只读前 N 行做预览,确认没问题再全量处理。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 启动报 ModuleNotFoundError | python 路径不对 | 配置里用虚拟环境绝对路径 |
| 读到公式文本而非结果 | 没加 data_only | load_workbook 加 data_only=True |
| 求和结果为 0 | 单元格是文本格式 | 加 isinstance 判断或转换 |
| 写入后格式丢失 | 新建了 workbook | 先加载原文件再写 |
| AI 传参类型错误 | 描述不清晰 | 文档字符串加示例,函数内做转换 |
6.4 工具描述怎么写才让 AI 不迷糊
这是最容易被忽视但影响最大的环节。好的工具描述要包含三要素:做什么、参数含义、返回什么。我对比过两种写法:
差的写法:"""筛选数据"""
好的写法:"""按数值条件筛选行。col 是列索引(从0开始),threshold 是阈值,返回包含表头的筛选结果。"""
后者 AI 一次就能传对参数,前者要试好几次。工具描述就是给 AI 看的说明书,写得越清楚,它用得越准。
7. 后续可以怎么扩展
这套基础框架搭好之后,扩展方向很多。比如接入 pandas 做分组聚合,封装一个group_by_sum工具;或者加一个merge_sheets工具,把多个工作表按某列合并。热搜里提到的"markdown 表格转换 Excel"也可以做成一个工具,输入 markdown 文本输出 xlsx。
再往深了走,可以结合其他 MCP 服务做联动。比如一个负责读数据库的 MCP,一个负责处理 Excel 的 MCP,AI 可以先把数据库查出来,再调 Excel 工具生成报表。这就是 MCP 生态的威力:每个服务专注一件事,AI 负责把它们串起来。
我自己下一步打算加的是"条件格式"工具,让 AI 能根据数值自动给单元格上色。这个用 openpyxl 的PatternFill就能实现,但参数设计要花点心思,得让 AI 容易表达"大于 10000 标红"这种规则。
最后分享一个我调试 MCP 的小技巧:在工具函数里加日志输出到文件,因为 stdio 传输时 print 会干扰协议通信。用logging写到单独的文件,出问题时翻日志比猜快得多。这个习惯帮我省了大量排查时间。