1. 为什么我要自己动手写一个 MCP
1.1 从一次崩溃的 Excel 批量处理说起
上个月帮朋友处理一批销售数据,大概三百多个 Excel 文件,每个文件结构类似但列名有细微差异,需要统一格式后汇总统计。我一开始用传统脚本硬写,结果光是对齐列名就改了七八版,中间还遇到“excel加载项被禁用”导致脚本调用 COM 组件失败、某几个文件里“excel同一列中统计含关键词对应数据求和”的逻辑写错导致结果偏差。折腾到凌晨两点,我突然意识到一个问题:我一直在用“写死规则”的方式处理 Excel,但 Excel 本身的结构是灵活多变的,为什么不让 AI 来理解表格语义,然后动态生成处理逻辑?
这就是我决定开发自己第一个 MCP 的起点。MCP 全称 Model Context Protocol,直译过来是“模型上下文协议”,你可以把它理解成一套让 AI 模型和外部工具、数据源之间标准化对话的“插头与插座”规范。以前我们想让 AI 操作 Excel,要么把数据全部塞进提示词里(token 爆炸且容易截断),要么写一堆胶水代码把 Excel 转成 JSON 再喂给模型(格式丢失严重)。MCP 的出现让这件事变得优雅很多:AI 通过标准协议调用一个“Excel 处理服务”,服务端负责真正的读写和计算,模型只负责决策“下一步该做什么”。
这个项目适合谁呢?如果你经常和 Excel 打交道,写过 Python 脚本处理表格,对 AI Agent 有兴趣但不知道怎么落地,或者你正在研究“工作流编码”和“轻量级工作流”的搭建方式,那这篇内容应该能给你不少可直接复用的思路。我会从零开始拆解整个 MCP 的设计、实现和踩坑过程,代码部分用 Python 写,保证你能跟着跑起来。
1.2 MCP 到底解决了什么痛点
在没有 MCP 之前,我做 Excel 自动化一般有三种方案。第一种是纯 Python 脚本,用 openpyxl 或 pandas 读写,优点是快,缺点是规则写死,遇到表头变体、合并单元格、多级索引就歇菜。第二种是 Excel 自带的 VBA 宏,优点是贴近 Excel 原生环境,缺点是调试痛苦、跨平台差、AI 很难直接生成可靠代码。第三种是低代码平台的工作流,比如 coze 工作流或 dify 工作流,优点是可视化,缺点是处理复杂表格逻辑时节点爆炸,而且“dify 工作流 上下文超长”这个问题在表格场景下特别致命。
MCP 的思路不一样。它把“AI 决策”和“工具执行”彻底分开。AI 端只负责理解用户意图,比如“把这一列里包含‘已完成’的对应金额求和”,然后它通过 MCP 协议调用一个叫sum_by_keyword的工具,把列名、关键词、目标列作为参数传过去。服务端收到参数后,用 pandas 做实际计算,把结果返回。整个过程 AI 不需要看到全部数据,只需要看到工具的描述和返回结果。这样做的好处是:token 消耗极低、数据不出本地、逻辑可测试、工具可复用。
我选择自己开发而不是直接用现成方案,还有一个原因:市面上很多 Excel 相关的 AI 工具要么要求上传文件到云端,要么对中文列名支持不好,要么无法处理“excel写uuid”这种需要生成唯一标识的场景。自己写一个 MCP Server,这些都能按需定制。
2. 核心设计与技术选型拆解
2.1 整体架构:三层分离
我的 MCP 设计遵循三层分离原则。最上层是AI 交互层,可以是任何支持 MCP 协议的客户端,比如 Claude Desktop、Cursor,或者你自己写的 Agent。中间层是MCP Server 层,用 Python 实现,负责注册工具、解析请求、调度底层逻辑。最下层是Excel 操作层,基于 pandas 和 openpyxl,负责真正的文件读写和计算。
为什么这样分?因为 AI 交互层的变化太快了,今天用这个客户端,明天可能换另一个。如果把 Excel 逻辑写死在客户端里,迁移成本极高。而 MCP Server 作为一个独立进程,只要协议不变,上层换什么客户端都能对接。Excel 操作层独立出来,也方便我单独写单元测试,不用每次都启动 AI 来验证。
具体通信方式上,MCP 支持 stdio 和 SSE 两种传输。我选的是 stdio,因为本地开发调试最方便,不需要额外开端口,也不涉及网络配置。Server 启动后监听标准输入,客户端通过标准输出接收响应。如果你要部署到远程给团队用,可以改成 SSE,但那就需要考虑并发和鉴权了,初期建议先用 stdio 跑通。
2.2 工具设计:少而精,语义清晰
MCP 的核心是“工具”(Tool)。每个工具就是一个函数,有名字、描述、参数 schema。AI 根据这些信息决定什么时候调用哪个工具。我一开始设计了十几个工具,后来发现工具太多反而让 AI 困惑,经常选错。精简到六个核心工具后,准确率明显提升。
| 工具名 | 功能 | 关键参数 |
|---|---|---|
read_excel_info | 读取表格结构信息 | 文件路径、sheet 名 |
query_data | 按条件查询数据 | 列名、条件、返回列 |
sum_by_keyword | 关键词求和 | 关键词列、关键词、求和列 |
write_column | 写入新列 | 列名、生成规则 |
merge_files | 合并多个文件 | 文件列表、对齐策略 |
export_result | 导出结果 | 输出路径、格式 |
这里重点说sum_by_keyword的设计。用户说“统计同一列中含着‘已完成’对应数据的求和”,传统做法是让 AI 生成 pandas 代码,但 AI 生成的代码经常有边界问题,比如大小写、空格、部分匹配。我把它封装成一个工具,内部用str.contains加case=False和na=False,并且支持正则。这样 AI 只需要传三个参数,剩下的鲁棒性由工具保证。
提示:工具描述要写得像给新人看的文档,不要假设 AI 知道你的业务背景。比如
sum_by_keyword的描述我写的是“在指定列中查找包含关键词的行,对这些行在目标列的值求和。关键词匹配不区分大小写,支持正则表达式。”
2.3 为什么用 Python 而不是其他语言
Python 在 Excel 处理领域的生态是压倒性的。pandas 处理结构化数据的能力、openpyxl 对 xlsx 格式的完整支持、xlrd 对老格式的兼容,这些库经过多年打磨,稳定性远超其他语言的同类库。而且 MCP 的官方 SDK 对 Python 支持最好,社区示例也最多。
另一个原因是“python安装numpy库的方法”这类问题在网上有海量答案,新手遇到环境问题容易搜到解决方案。如果我用 Rust 或 Go 写,虽然性能更好,但读者复现的门槛会高很多。这个项目的定位是“第一个 MCP”,重点是跑通流程、理解概念,不是追求极致性能。
不过 Python 也有坑。比如在 Windows 上,如果 Excel 文件被打开着,openpyxl 写入会报 PermissionError。我的处理方式是先尝试写入,失败后自动复制一份临时文件再操作,最后提示用户关闭原文件。这个逻辑后面会详细讲。
3. 从零搭建 MCP Server 的完整实操
3.1 环境准备与依赖安装
先确保你的 Python 版本在 3.10 以上,因为 MCP SDK 用到了较新的类型语法。我实测 3.11 和 3.12 都稳定。如果你还没装 Python,去官网下载安装包,安装时勾选“Add Python to PATH”。装完后打开终端,输入python --version确认。
接下来创建虚拟环境。这一步很多人跳过,结果不同项目的依赖打架。我习惯用 venv:
python -m venv mcp-excel-env # Windows mcp-excel-env\Scripts\activate # macOS/Linux source mcp-excel-env/bin/activate激活后安装核心依赖:
pip install mcp pandas openpyxl如果你需要处理 xls 老格式,再加一个xlrd。numpy 会作为 pandas 的依赖自动装上,不用单独装。这里注意,有些教程会让你装mcp[cli],那个是带命令行工具的版本,开发 Server 不需要,装了反而多一堆依赖。
注意:如果你在公司网络环境下 pip 安装很慢,可以换国内镜像源,比如
pip install mcp pandas openpyxl -i https://pypi.tuna.tsinghua.edu.cn/simple。这不是必须的,但能省不少时间。
3.2 编写第一个工具:读取表格结构
MCP Server 的骨架很简单。创建一个server.py,先导入必要模块:
import asyncio import json from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent import pandas as pd然后初始化 Server 实例,并注册工具列表。工具列表是一个数组,每个元素描述一个工具的名称、描述和输入 schema。schema 用 JSON Schema 格式,定义参数类型和是否必填。
app = Server("excel-mcp") @app.list_tools() async def list_tools(): return [ Tool( name="read_excel_info", description="读取Excel文件的结构信息,包括sheet列表、每个sheet的列名、行数、前5行样例数据。用于AI了解表格长什么样。", inputSchema={ "type": "object", "properties": { "file_path": {"type": "string", "description": "Excel文件的绝对路径"}, "sheet_name": {"type": "string", "description": "可选,指定sheet名,不传则返回所有sheet"} }, "required": ["file_path"] } ) ]这里有个细节:description要写得足够详细,因为 AI 就是靠这个判断什么时候调用。我试过写得太简略,AI 经常在不需要的时候调用它,浪费 token。
接下来实现工具的执行逻辑:
@app.call_tool() async def call_tool(name: str, arguments: dict): if name == "read_excel_info": file_path = arguments["file_path"] sheet_name = arguments.get("sheet_name") try: xls = pd.ExcelFile(file_path) result = {} sheets = [sheet_name] if sheet_name else xls.sheet_names for s in sheets: df = pd.read_excel(xls, sheet_name=s, nrows=5) full_df = pd.read_excel(xls, sheet_name=s, usecols=[0]) result[s] = { "columns": df.columns.tolist(), "total_rows": len(full_df), "sample": df.head(5).to_dict(orient="records") } return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, default=str))] except Exception as e: return [TextContent(type="text", text=f"读取失败: {str(e)}")]注意default=str这个参数,因为 Excel 里可能有日期类型,json 序列化会报错,转成字符串最省事。ensure_ascii=False保证中文正常显示。
最后加上启动入口:
async def main(): async with stdio_server() as (read_stream, write_stream): await app.run(read_stream, write_stream, app.create_initialization_options()) if __name__ == "__main__": asyncio.run(main())跑起来后,这个 Server 就在标准输入上等待 MCP 请求了。你可以用官方的 MCP Inspector 工具测试,也可以直接接到 Claude Desktop 的配置里。
3.3 实现关键词求和工具
这是整个项目里最实用的工具。需求场景很明确:一列是文本描述,另一列是金额,要统计描述里包含某个关键词的金额总和。比如“excel同一列中统计含关键词对应数据求和”这个热搜词描述的就是这个需求。
Tool( name="sum_by_keyword", description="在指定列中查找包含关键词的行,对这些行在目标列的值求和。关键词匹配不区分大小写,支持正则表达式。返回求和结果和匹配行数。", inputSchema={ "type": "object", "properties": { "file_path": {"type": "string"}, "sheet_name": {"type": "string"}, "keyword_column": {"type": "string", "description": "要搜索关键词的列名"}, "keyword": {"type": "string", "description": "关键词或正则表达式"}, "sum_column": {"type": "string", "description": "要求和的数值列名"}, "use_regex": {"type": "boolean", "description": "是否启用正则匹配,默认false"} }, "required": ["file_path", "keyword_column", "keyword", "sum_column"] } )执行逻辑:
def sum_by_keyword(file_path, sheet_name, keyword_column, keyword, sum_column, use_regex=False): df = pd.read_excel(file_path, sheet_name=sheet_name) if keyword_column not in df.columns: return f"列 '{keyword_column}' 不存在,可用列: {df.columns.tolist()}" if sum_column not in df.columns: return f"列 '{sum_column}' 不存在,可用列: {df.columns.tolist()}" mask = df[keyword_column].astype(str).str.contains( keyword, case=False, na=False, regex=use_regex ) matched = df[mask] total = pd.to_numeric(matched[sum_column], errors="coerce").sum() return json.dumps({ "matched_rows": len(matched), "sum_result": round(float(total), 2), "keyword": keyword, "column": sum_column }, ensure_ascii=False)这里有几个关键处理。第一,astype(str)防止列里有数字或空值导致str.contains报错。第二,na=False让空值不参与匹配。第三,pd.to_numeric加errors="coerce"把无法转换的值变成 NaN,求和时自动忽略。第四,结果保留两位小数,避免浮点误差看起来奇怪。
我实测过一个两万行的表格,这个函数跑完不到 0.3 秒。如果你数据量更大,可以加dtype参数优化读取速度,但一般业务场景没必要。
3.4 写入新列与生成 UUID
“excel写uuid”是另一个高频需求。比如给每行数据生成唯一标识,方便后续追踪。Python 标准库的uuid模块就能做,但要注意写入格式。
Tool( name="write_column", description="向Excel写入新列。支持生成UUID、拼接现有列、填充固定值。", inputSchema={ "type": "object", "properties": { "file_path": {"type": "string"}, "sheet_name": {"type": "string"}, "new_column": {"type": "string", "description": "新列名"}, "mode": {"type": "string", "enum": ["uuid", "concat", "fixed"], "description": "生成模式"}, "source_columns": {"type": "array", "items": {"type": "string"}, "description": "concat模式下的源列"}, "separator": {"type": "string", "description": "concat模式下的分隔符,默认-"}, "fixed_value": {"type": "string", "description": "fixed模式下的固定值"}, "output_path": {"type": "string", "description": "输出文件路径,不传则覆盖原文件"} }, "required": ["file_path", "new_column", "mode"] } )实现时,UUID 模式用uuid.uuid4().hex生成 32 位无连字符字符串,这样在 Excel 里不会被识别成日期或科学计数法。concat 模式把多个列的值用分隔符拼起来。fixed 模式直接填充。
写入时有个坑:如果原文件有多个 sheet,pd.read_excel只读一个,to_excel会丢掉其他 sheet。我的处理是先读取所有 sheet 到字典,修改目标 sheet,再全部写回。这样虽然慢一点,但数据安全。
def write_column(file_path, sheet_name, new_column, mode, **kwargs): all_sheets = pd.read_excel(file_path, sheet_name=None) df = all_sheets[sheet_name] if mode == "uuid": df[new_column] = [uuid.uuid4().hex for _ in range(len(df))] elif mode == "concat": cols = kwargs.get("source_columns", []) sep = kwargs.get("separator", "-") df[new_column] = df[cols].astype(str).agg(sep.join, axis=1) elif mode == "fixed": df[new_column] = kwargs.get("fixed_value", "") all_sheets[sheet_name] = df output = kwargs.get("output_path", file_path) with pd.ExcelWriter(output, engine="openpyxl") as writer: for s, d in all_sheets.items(): d.to_excel(writer, sheet_name=s, index=False) return f"已写入列 '{new_column}',共 {len(df)} 行,输出到 {output}"注意:
to_excel默认会写 index,加index=False避免多出一列。另外如果原文件有格式、公式、图表,pandas 重写会丢失。这种情况建议用 openpyxl 直接操作单元格,而不是 pandas 整体重写。我在处理带格式的报表时会切换成 openpyxl 模式。
4. 接入 AI 客户端与工作流编排
4.1 配置 Claude Desktop 调用本地 MCP
Server 写好了,得让 AI 能调用它。以 Claude Desktop 为例,找到配置文件claude_desktop_config.json,在mcpServers字段里加上:
{ "mcpServers": { "excel-mcp": { "command": "python", "args": ["/absolute/path/to/server.py"] } } }重启 Claude Desktop 后,它就能看到你注册的工具了。你可以直接说“帮我看看 D:\data\sales.xlsx 里有哪些列”,它会自动调用read_excel_info。
这里有个常见问题:路径要用绝对路径,而且 Windows 下反斜杠要转义或改用正斜杠。我踩过这个坑,配置里写相对路径一直报找不到文件,换成绝对路径就好了。
4.2 用工作流串联多个 MCP 工具
单个工具调用只是开始,真正的威力在于工作流编排。比如一个完整的“简历筛选工作流”可以这样设计:先用read_excel_info读取简历汇总表,了解列结构;然后用query_data筛选出“工作经验大于3年”的行;接着用sum_by_keyword统计“技能”列里包含“Python”的人数;最后用write_column给通过初筛的候选人写入 UUID 和筛选标记。
在支持 MCP 的 Agent 框架里,这些步骤可以由 AI 自动规划。你只需要用自然语言描述目标,AI 会决定调用顺序和参数。我实测下来,对于结构清晰的表格,AI 的规划准确率很高。但如果表格列名有歧义,比如同时有“金额”和“金额(元)”,AI 可能会选错。解决办法是在工具描述里强调“优先使用带单位的列”,或者在提示词里明确指定。
4.3 与 coze/dify 工作流的对比思考
我同时用过 coze 工作流和 dify 工作流来做 Excel 处理。coze 的优势是可视化拖拽,上手快,但处理复杂表格逻辑时节点会非常多,而且“dify 工作流 上下文超长”的问题在传递大表格数据时特别明显。MCP 的方式相当于把重计算放在本地 Server,AI 只传参数和接收摘要结果,上下文压力小很多。
不过 MCP 也不是银弹。如果你的需求是“每天定时处理固定格式的报表”,那用传统脚本加定时任务更稳定。MCP 适合的是“格式不固定、需要 AI 理解语义、人工交互频繁”的场景。选型时想清楚这一点,能省很多折腾。
5. 踩坑记录与排查技巧
5.1 Excel 文件被占用导致写入失败
这是最高频的问题。Windows 下如果 Excel 开着某个文件,Python 写入会抛PermissionError: [Errno 13] Permission denied。我的处理逻辑是捕获异常后,自动生成一个带时间戳的副本路径,写入副本,然后返回提示“原文件被占用,结果已保存到 xxx_副本.xlsx”。
import shutil from datetime import datetime def safe_write(df, output_path): try: df.to_excel(output_path, index=False) return output_path except PermissionError: base, ext = output_path.rsplit(".", 1) new_path = f"{base}_副本_{datetime.now().strftime('%Y%m%d%H%M%S')}.{ext}" df.to_excel(new_path, index=False) return new_path这个逻辑看起来简单,但能避免大量“为什么写入没反应”的困惑。
5.2 列名匹配失败与模糊匹配
AI 传过来的列名可能和实际列名有细微差异,比如多个空格、大小写不同、全角半角混用。我在每个工具里都加了一层列名归一化:去掉首尾空格、转小写、全角转半角,然后再匹配。如果还匹配不上,返回可用列名列表让 AI 重新选择。
def normalize_column_name(name): name = str(name).strip().lower() # 全角转半角 result = "" for ch in name: code = ord(ch) if code == 12288: code = 32 elif 65281 <= code <= 65374: code -= 65248 result += chr(code) return result这个函数帮我解决了好几次“明明列名一样却找不到”的诡异问题。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| 工具调用无响应 | Server 未启动或路径错误 | 检查配置文件绝对路径,手动运行 server.py 看报错 |
| 读取中文乱码 | 文件编码非 UTF-8 | pandas 读取时指定 encoding="gbk" 或 "utf-8-sig" |
| 日期变成数字 | Excel 日期序列号未转换 | 读取时加 parse_dates 参数,或手动转换 |
| 大文件读取慢 | 一次性读全部列 | 用 usecols 只读需要的列,nrows 限制行数 |
| 写入后格式丢失 | pandas 重写覆盖 | 改用 openpyxl 单元格级操作 |
| AI 选错工具 | 工具描述不够清晰 | 补充使用场景和反例,减少工具数量 |
5.4 性能优化的几个实用技巧
处理超过十万行的表格时,pandas 默认读取会占很多内存。我的做法是先用read_excel(nrows=100)采样,让 AI 了解结构,真正计算时再用chunksize分块读取。对于只需要求和的场景,甚至可以用 openpyxl 的read_only模式流式读取,内存占用极低。
另一个技巧是缓存。如果 AI 连续调用多个工具处理同一个文件,每次都重新读取很浪费。我在 Server 里加了一个简单的字典缓存,以文件路径和修改时间为 key,存最近一次读取的 DataFrame。文件修改时间变了就自动失效。这个优化让连续操作的响应速度提升了三倍左右。
6. 后续扩展方向与个人体会
这个 MCP 目前只覆盖了 Excel 处理的基础场景,但扩展空间很大。比如可以加一个chart_generator工具,让 AI 根据数据自动生成图表;或者加formula_engine,用 AI 解释和生成 Excel 公式;再或者对接数据库,实现 Excel 和 SQL 之间的数据流转。我最近在尝试把“arcgis批量出图想插入excel表格”这个场景也纳入进来,思路是让 MCP 输出符合 ArcGIS 格式要求的表格,不过这块还在调试。
我个人在实际操作中的体会是,MCP 的价值不在于技术多复杂,而在于它把“AI 能做什么”和“工具能做什么”的边界划清楚了。以前我总想让 AI 直接生成 pandas 代码,结果调试成本很高。现在我把稳定逻辑封装成工具,AI 只做决策,整个系统的可靠性上了一个台阶。如果你也在做类似的事情,建议先从一两个高频工具开始,跑通闭环后再逐步扩展,不要一上来就设计大而全的架构。
最后分享一个小技巧:在工具返回值里加上hint字段,给 AI 下一步操作的提示。比如sum_by_keyword返回结果时附带“如需按其他关键词统计,可再次调用本工具”。这样 AI 在多轮对话中会更顺畅,减少来回确认的次数。这个细节在官方文档里没写,是我自己试出来的,效果不错。