如果你路过我的工位,大概率会看到我一边开着 Excel 一边跟 AI 对话框说话。别误会,我不是在摸鱼——这是我折腾了两个月的成果:一个自己写的 MCP Server,用来让 AI 直接读写 Excel 文件。以前处理表格,靠的是手动复制粘贴配 vlookup,后来改成临时写 Python 脚本,再后来我发现,真正靠谱的方案不是让 AI 帮我写脚本,而是给 AI 一双手,让它直接操作文件。这个思路,就是大家常说的用 AI 重构 Excel 处理工作流。这篇文章把从零开发第一个 Excel MCP Server 的完整过程写出来,包括三个核心工具的实现、客户端配置、安全边界,以及让我崩溃过的几个坑。适合有一点 Python 基础、正在琢磨怎么给工作提效的读者,代码可以直接抄。
1. 为什么我决定自己写一个 MCP:从手动搓 Excel 到让 AI 替我干活
1.1 每天和 Excel 死磕的日子,我受够了
我手里有一堆每月都要更新的报表:销售明细、库存台账、客户对账单、渠道汇总。以前的工作方式是这样的:先打开 Excel,手动筛选,复制到新表,写 vlookup 匹配客户名称,再拉透视表,最后调格式。简单吧?架不住每月都来一遍。而且只要数据源格式稍微改一下,整套操作就得重新来过。
也试过让 AI 给我写处理脚本。但 AI 生成脚本有个致命问题——它看不到实际文件,只能靠我描述的表结构去猜。你告诉它“客户名称在 B 列,金额在 E 列”,它写出来的代码确实没错,可一旦某个文件里多了一行合并单元格表头,脚本当场崩掉。然后你又得把报错贴回去让它改,来回几个回合,人已经麻了。
后来我意识到,问题的本质不是“让 AI 写代码”,而是“让 AI 直接面对文件本身”。它如果能自己打开 Excel、自己看结构、自己调数据,那它就能像人一样处理这些琐碎活儿。这正是 MCP 的用武之地。
1.2 MCP 到底是什么:先忘记“协议”这个词
Model Context Protocol,模型上下文协议。翻译成大白话就是:AI 应用和外部工具之间的一套统一通信标准。
你可以把它理解成给 AI 装上 USB-C 接口。以前你买个键盘要一个接口,买个硬盘又要另一个接口,线材乱成一团;MCP 统一之后,任何 AI 客户端想调用任何外部能力,只要对方实现了 MCP 协议就行。对 AI 来说,MCP Server 就是一个“外挂工具箱”,它通过标准方式把工具暴露给 AI,AI 在需要的时候按名称调用这些工具,工具执行完把结果返回给 AI。
这套协议包含三个核心概念:工具(Tools)、资源(Resources)和提示(Prompts)。做 Excel 处理的主角是工具——也就是我们可以注册给 AI 调用的函数。资源用于提供上下文文件,提示用于预置模板,后面有需要再研究也不迟,第一版先把工具做扎实。
1.3 为什么现成的 MCP Server 不够用
网上开源的 Excel MCP Server 已经不少,GitHub 一搜一大把。但我在实际用了一圈之后还是决定自己写,原因有三。
第一,现成方案大多只覆盖通用的读写。我的表格有固定的模板格式、固定表头,还有不少历史遗留的合并单元格和复杂表头,通用工具读出来是一堆乱麻,AI 根本没法理解。第二,安全边界不匹配。有些现成 Server 给了挺宽泛的操作权限,我不放心让它在我的财务目录里乱逛。第三,也是最实际的——自己写一遍,才能真正理解工具设计思路。后面每加一个新工具、改一个参数,你都会知道它对 AI 行为有什么影响,这种掌控感是拿别人封装好的包换不来的。
所以这篇文章里,我会沿着自己从零做一遍的路线,把我踩过的坑和最终沉淀下来的工具设计方式完整讲一遍。
2. 动手前必懂的三件事:协议骨架、工具注册与参数设计
2.1 MCP 的三种能力和一个“USB-C 类比”
前面说了 MCP 有三个核心能力。我再展开讲一下它们的分工。
- Tools:AI 可以主动调用的函数。一个工具对应一段能力,比如“读取 Excel”“写入 Excel”。
- Resources:暴露给 AI 引用的文件、数据库记录或代码片段,相当于给它提供“背景资料”。
- Prompts:预定义好的提示词模板,方便把常用操作流固化下来。
对 Excel 场景来说,最核心的是 Tools。你可以把 Tools 想成给 AI 装上的手指:读文件是“摸”,写文件是“握”,聚合计算是“捏”。手指不需要多,但每一根都要灵活、可控、边界清晰。
关于通信机制,我快速说一下:客户端(AI 应用)和 Server 之间有两种常见传输方式,本地用 stdio(标准输入输出),远程用 HTTP 或 WebSocket。本地开发用 stdio 就够了,配置简单,调试方便;如果想把 Server 部署到内网共享给团队用,再考虑远程方式,但那会引入鉴权和网络安全的复杂度。
2.2 FastMCP 最小骨架:十行代码跑起来
我这里用的是 FastMCP,它是 MCP 官方 SDK 的简化封装。选它的理由很简单:装饰器语法非常直观,注册一个工具就是给普通函数加一个@mcp.tool(),不需要手写 JSON-RPC 的那套通信细节。
一个最小的 Server 是这样的:
from fastmcp import FastMCP mcp = FastMCP("ExcelAssistant") @mcp.tool() def say_hello(name: str) -> str: """问候对方,便于测试工具调用链路是否通。""" return f"你好,{name}!" if __name__ == "__main__": mcp.run()跑起来之后,配置了 MCP 的 AI 客户端就能发现自己多了个say_hello工具。第一次看到自己写的函数出现在 AI 的工具列表里,那种感觉还挺奇妙——你已经不是在写脚本,而是在给一个会思考的实体“装备技能”。
2.3 工具描述是 AI 的“说明书”,比代码本身更重要
这里要划重点:FastMCP 会自动把你的函数名、docstring、参数类型和注释转换成工具的 JSON-Schema 描述。AI 在决定“什么时候调用这个工具、怎么传参数”时,靠的就是这些描述。换句话说,docstring 不只是给程序员看的注释,它是 AI 理解工具的说明书。
我自己吃过亏。第一版 read_excel 的工具描述只写了“读取 Excel 文件”,结果 AI 在这个文件有几百行数据时,直接把 max_rows 传成 1000,返回结果把上下文撑爆了。后来我把描述改成下面这样:
读取 Excel 文件的结构信息和前 max_rows 行预览数据。适用于用户想查看表格内容时。对于大型表格,请先使用默认 30 行预览,不要一次性读取全部数据,以免占用过多上下文。
改了描述之后,AI 的行为立刻变得合理——它会先预览,再决定是否扩大读取范围。这说明工具设计的重点并不仅仅是实现功能,而是要站在 AI 的角度想清楚:它会在什么场景下调用我这个函数?它应该怎么用才不出错?把这个想明白了,工具才真正好用。
3. 从零写三个核心工具:读、写、算 Excel
3.1 环境准备
先列一下依赖:
python = ">=3.10" fastmcp = ">=2.0" pandas openpyxl安装命令:
pip install fastmcp pandas openpyxlpandas 负责数据处理,底层会自动调用 openpyxl 来读写 .xlsx 文件。解释一句:如果你只需要简单读写而不做数据统计,只装 openpyxl 也够;但既然目标是“重构工作流”,聚合、筛选、合并这类需求很快就会冒出来,所以直接上 pandas 更省心。
3.2 工具一 read_excel:让表格变成 AI 能理解的文本
直接看代码:
from pathlib import Path import pandas as pd from openpyxl import load_workbook from fastmcp import FastMCP mcp = FastMCP("ExcelAssistant") @mcp.tool() def read_excel(file_path: str, sheet_name: str = "", max_rows: int = 30) -> str: """读取 Excel 文件,返回工作表结构信息和数据预览。 Args: file_path: Excel 文件的绝对路径。 sheet_name: 工作表名称,留空则读取第一个工作表。 max_rows: 预览数据上限,默认 30 行;设为 -1 则读取全部数据。 """ path = Path(file_path).resolve() if not path.exists(): return f"错误:文件不存在,请检查路径:{path}" wb = load_workbook(path, read_only=True) if sheet_name and sheet_name not in wb.sheetnames: return f"错误:找不到工作表 {sheet_name}。当前工作表有:{wb.sheetnames}" ws = wb[sheet_name] if sheet_name else wb[wb.sheetnames[0]] total_rows = ws.max_row total_cols = ws.max_column wb.close() df = pd.read_excel( path, sheet_name=sheet_name if sheet_name else 0, nrows=max_rows if max_rows != -1 else None, ) preview = df.to_string(index=False) return ( f"文件:{path.name}\n" f"工作表:{sheet_name or '(第一个工作表)'}\n" f"总行数:{total_rows}\n总列数:{total_cols}\n" f"列名:{list(df.columns)}\n" f"预览数据(前 {max_rows} 行):\n{preview}" )这个工具做了两件事:先用 openpyxl 以只读模式拿到总行数和列名(这样即使文件很大也能快速获得结构信息),再用 pandas 读取预览数据。返回给 AI 的内容里既有结构信息又有样例行,AI 就能据此判断接下来该怎么处理。
说说我为什么不用pd.read_excel()一次读到底:当文件有十几万行时,read_excel 会把全部数据加载进内存,然后to_string()生成几千行文本塞进对话上下文。AI 的上下文窗口是有限的,这等于直接把它的“记忆力”烧光了。预览加结构信息,对大多数决策场景来说已经足够。
3.3 工具二 write_excel:让 AI 把结果落回文件
AI 只读不写没有意义,重构工作流必须把结果写回 .xlsx。这个工具需要注意的细节更多,我直接给完整实现:
from openpyxl import Workbook, load_workbook from openpyxl.utils.cell import coordinate_from_string, column_index_from_string import shutil @mcp.tool() def write_excel( file_path: str, data: list, sheet_name: str = "Sheet1", start_cell: str = "A1", overwrite_sheet: bool = False, ) -> str: """批量写入数据到 Excel 文件,支持新建文件或追加写入现有工作表。 Args: file_path: 目标 Excel 文件路径,若不存在则新建。 data: 二维数组,每个内部列表代表一行。 sheet_name: 目标工作表名称。 start_cell: 起始单元格地址,如 A1。 overwrite_sheet: 是否清空原有数据后重写,默认追加。 """ path = Path(file_path).resolve() if not path.parent.exists(): path.parent.mkdir(parents=True, exist_ok=True) if path.exists(): # 写前备份,防止 AI 加工出错把原始数据覆盖掉 shutil.copy2(path, path.with_suffix(path.suffix + ".bak")) try: wb = load_workbook(path) except PermissionError: return f"错误:文件 {path} 正被占用,请先关闭 Excel 程序再试。" else: wb = Workbook() ws = wb[sheet_name] if sheet_name in wb.sheetnames else wb.create_sheet(sheet_name) if overwrite_sheet: ws.delete_rows(1, ws.max_row) row_start, col_start = coordinate_from_string(start_cell) col_idx = column_index_from_string(col_start) for r_offset, row_data in enumerate(data): for c_offset, value in enumerate(row_data): ws.cell(row=row_start + r_offset, column=col_idx + c_offset, value=value) wb.save(path) wb.close() return f"写入完成:{len(data)} 行数据已写入 {path},工作表为 {sheet_name}"这个工具里最核心的一点是写前备份。原因很简单:AI 在中间推理时可能会先用 read 读出数据,再经过自己的“加工”覆盖写回——如果它加工错了,原数据就没了。一个 .bak 备份,成本极低,但能救命的次数不少。
3.4 工具三 aggregate_excel:把计算放到工具端,而不是塞给 AI
有个问题你可能已经想到了:AI 虽然能调用工具,但它本身的数值计算能力并不强,把十几万行数据喂给它让它求和纯属浪费。正确的做法是:在工具端用 pandas 做计算,只把结果返回给 AI。
@mcp.tool() def aggregate_excel( file_path: str, group_col: str, value_col: str, agg_func: str = "sum", ) -> str: """按指定列分组,对数值列执行聚合统计,返回统计结果文本。 Args: file_path: Excel 文件的绝对路径。 group_col: 分组依据的列名。 value_col: 需要聚合的数值列名。 agg_func: 聚合方式,支持 sum、mean、count、max、min。 """ df = pd.read_excel(file_path) if group_col not in df.columns or value_col not in df.columns: return f"错误:列名不存在。当前列名:{list(df.columns)}" result = df.groupby(group_col)[value_col].agg(agg_func) return f"按 {group_col} 分组,对 {value_col} 执行 {agg_func} 的结果:\n{result.to_string()}"这个工具的意义是“把处理逻辑留在数据旁边,只把答案带回去”。你让 AI 算“每个月的销售额总和”,它不用把每一条明细都读进上下文,只需要调用一次aggregate_excel,拿到一个几十行的分组结果文本就够了。这大大减轻了 AI 的上下文压力,也让错误率大幅下降。
到这里,三个工具已经覆盖了工作流的完整闭环:读、写、算。接下来就可以接入客户端进行真实测试了。
4. 在 MCP 客户端里跑通全流程:从多表合并到报表产出
4.1 客户端配置:本地命令方式与远程服务方式
我用的是支持 MCP 的桌面客户端,配置方式大同小异,核心就是一份 JSON:
{ "mcpServers": { "excel-server": { "command": "python", "args": ["D:/work/excel-mcp/server.py"], "cwd": "D:/work/excel-mcp", "env": { "EXCEL_ROOT": "D:/data" } } } }这里有几个容易踩的坑。
第一,command里的python必须是你装了 fastmcp 的那个解释器。我一开始在系统默认 Python 环境里没装 fastmcp,客户端连接了一分钟然后报“工具列表为空”,排查半天才发现是虚拟环境没对其。
第二,Windows 下路径里的反斜杠要写正斜杠,或者在 JSON 里写成\\。我在D:\work\excel-mcp这种路径上踩过 JSON 转义的坑。
第三,env里的EXCEL_ROOT是我约定的数据根目录,server 端会用它做路径校验,限制 AI 只能操作指定目录下的文件。
如果你想部署成远程服务,JSON 的写法会变成带url的方式,并且在服务端配置鉴权 token。本地开发阶段完全没必要,直接用 stdio 模式跑起来最省事。
4.2 实测任务:三个月销售明细自动汇总
配好之后,我做的第一个真实任务是这样的:
把 D:/data 目录下的 销售_2025_01.xlsx、销售_2025_02.xlsx、销售_2025_03.xlsx 合并成一张总表,按产品类别汇总销售额,并把汇总结果保存到 汇总_2025_Q1.xlsx。
你猜 AI 是怎么干活的?它在客户端里会做这样一连串行为:
- 先列一下自己有哪些工具,看到 read_excel、write_excel、aggregate_excel。
- 调用 read_excel 读第一个文件,拿到表头和前 30 行,确认列名。
- 又读了 02、03 两个文件,确认它们的表头是否一致。
- 如果发现表头一致,就连续三次调用 aggregate_excel,分别做分组汇总。
- 最后把三次返回的汇总文本中提取数据,拼成二维列表,调用 write_excel 写入新文件。
整个过程它会自己纠错。比如在第一次读文件时发现列名和我口头描述的不一样,它会停下来根据实际表头调整后续调用参数。这在以前用脚本的方式里,是需要我人工去发现的差异。
4.3 AI 的规划能力:工具调用链是如何形成的
你可能好奇,AI 是怎么做到“自己规划”的。其实原理不神秘:AI 在对话过程中观察到自己能调用哪些工具,遇到一个任务时,它会先生成需要的工具调用序列,然后根据每个工具的返回结果决定下一步。MCP 只是把“工具的存在”这件事暴露给了它,至于怎么组合使用,是模型本身推理能力的体现。
这里有一个点值得展开:合并动作发生在 AI 的推理中——它拿到三次汇总文本,提取出数据行,拼成统一的列表,传给 write_excel。这意味着我们设计的工具要尽量让返回结果“结构化、可解析”。如果 aggregate_excel 返回的是一段混乱文本,AI 就很难从中提取有效数据。所以我刻意让它输出分组结果的行文本,目的就是让 AI 能直接把结果喂给下一个工具。
这也是为什么 MCP Server 的设计要“小而精”的原因。工具职责越单一、描述越清晰,AI 就越容易做出正确的组合。如果你把一个工具做成“大杂烩”,什么都能干,AI 反而不知道应该在什么时候用它——它太难判断边界了。这和你带实习生是一个道理:你说“帮我把这些数据整理整理”,他懵;你说“先做前三行的清洗,再按日期排序”,他马上就能执行。
5. 工作流重构的边界:安全、权限与异常处理
5.1 给 AI 一双手,也要给它一个“保险箱”
MCP Server 最恐怖的地方在于:它给了 AI 直接修改文件的能力,而你不可能预判 AI 会怎么调用工具。所以工具设计的底线,就是不能给 AI 超出任务需要的权限。
我的约定有三条。
第一,路径白名单。所有工具在拿到路径后先调用一个内部函数做校验,确认目标在EXCEL_ROOT目录内,否则直接拒绝。实现起来很简单:
EXCEL_ROOT = Path(os.environ.get("EXCEL_ROOT", ".")).resolve() def within_workdir(path: Path) -> bool: return path.is_relative_to(EXCEL_ROOT)第二,不提供删除类工具。我整个 Server 里没有 delete、remove、clear,只保留了“写入时覆盖指定工作表”的能力,且必须先备份。
第三,写文件前自动备份上一次版本。这个在前面 write_excel 的实现里提过了,这里再强调一遍:AI 一次写错的代价,比脚本时代高了不止一个数量级,备份必须成为默认行为。
5.2 异常信息是最佳提示词:让 AI 自己修复问题
这是个很有意思的观察:MCP 工具如果抛了异常,AI 可以看到错误信息,它真的会尝试自己修复。
举个真实例子。有一次我让 AI 聚合 A 列的销售额,结果它传错参数,把分组列写成了“产品类别”,而这个列名在表里实际叫“产品名称”。工具端返回了一行错误:“错误:列名不存在。当前列名:[客户名称, 产品名称, 销售额, 日期]”。AI 看到这个错误后,没有停下来问用户,而是立刻重新调用 read_excel 确认列名,然后改用“产品名称”重新聚合。
所以,工具内部捕获异常时不要只返回一句“处理失败”,而要把尽可能多的可读信息返回出去——尤其是“当前有哪些合法选项”这个信息。它能让 AI 瞬间理解问题并自我修正。这比任何刻意设计的错误处理逻辑都管用。
5.3 日志别用 print:stdout 是协议通道
最后一个不太明显但非常要命的坑:用 stdio 方式运行 MCP Server 时,标准输出是协议通信的专用通道,绝对不能直接往 stdout 打日志。一旦打了,客户端收到的流就被污染了,轻则日志乱插在 JSON-RPC 消息里,重则连接直接中断。
正确的做法是用 logging 输出到 stderr:
import logging import sys logging.basicConfig(level=logging.INFO, stream=sys.stderr) logger = logging.getLogger("excel-mcp") logger.info("read_excel called with %s", file_path)这是我第一版开发时踩得最惨的坑之一:服务端明明在干活,客户端却连接报错,查了半天发现是 print 干的好事。从此我养成了一个习惯——服务端日志一律走 stderr,调试看得清清楚楚,协议通道干干净净。
6. 开发过程中踩过的 6 个坑,每一个都让我多花了至少一小时
6.1 工具输出太长,AI 直接“失忆”
第一版 read_excel 没有 max_rows 限制,AI 一旦读了完整表格,返回文本能到几千行。客户端那边对话立刻开始卡顿,更重要的是 AI 会“忘记”之前几步的计划,开始胡言乱语。
解决办法就是我前面说的:返回结构信息 + 预览 30 行。数据太多时让 AI 分批读,或者直接调用聚合工具。记住一个原则:返回给 AI 的内容永远是最小必要信息。如果一段文字和当前任务无关,就不要出现在工具返回值里。
6.2 相对路径是一场灾难
MCP Server 启动时的当前工作目录,通常是客户端配置里的 cwd,而不是你想象的项目目录。我第一版读写 Excel 全部用相对路径,结果在客户端里测试时,AI 明明说“写入完成”,我打开文件却什么都找不到——因为文件写到 cwd 去了,跟数据目录完全不是一回事。
从此我的每个工具签名里都有明确要求:file_path 必须传绝对路径,内部再调用 Path.resolve() 归一化。给出声明的路径不存在的错误提示,不要给 AI“猜路径”的机会。
6.3 openpyxl 读公式单元格返回 None
Excel 单元格如果存的是公式,openpyxl 默认读到的不是计算后的值,而是公式字符串本身;如果文件从没被 Excel 程序打开过,更只有一个公式没有缓存值,读出来就是 None。
我踩过这个坑后,在 read_excel 的工具描述里特别加了一句话:
如果某列大量出现 None,可能是公式单元格,请提示用户先用 Excel 打开并保存一次,以便生成公式缓存。
这个提示能引导 AI 向用户解释清楚,而不是傻傻把 None 当作空数据处理。
6.4 文件被占用:进程锁与并发
Windows 环境下,如果目标 .xlsx 正被 Excel 程序打开,openpyxl 保存时会抛 PermissionError。处理方式有两个:一是工具内尝试保存,出错则返回“文件被占用,请先关闭 Excel”;二是写临时文件再 os.replace 替换,减少占用窗口期。
并发问题同样出现在 MCP Server 内部。如果 AI 同时发起多个写请求,两个进程同时操作同一个文件,就可能损坏文件。我给写工具加了一个简单的全局线程锁(threading.Lock),同一时间只允许一个写操作,稳妥。
6.5 中文路径与编码
我第一次在测试里用中文文件名“销售明细.xlsx”,结果没问题,但读一个从网上下载的 CSV 时遇到了 gbk 编码报错。pandas 的 read_csv 默认按 utf-8 解码,遇到 gbk 编码的 CSV 直接崩掉。
后来我把读取这类文本文件的逻辑改成尝试多种编码:先 utf-8,失败就 gbk,再失败就 latin-1。这样至少能在第一时间把文件内容读出来,而不至于因为编码问题卡住整个流程。
6.6 同步阻塞卡死整个 Server?尽量用 async
MCP Server 本身是异步框架,如果你的工具函数写成同步的,框架虽然能跑,但遇到长时间任务(比如遍历几十个大文件)时,事件循环会被阻塞,其他工具的调用都会排队,客户端看起来就像卡死了。
FastMCP 支持 async def 工具,如果有潜在耗时的操作,比如合并多个大文件,用 async 方式加 await asyncio.sleep(0) 让步,或者用 asyncio.to_thread 把阻塞调用丢到线程池,用户体验会好很多。当然,普通小文件处理同步就够了,不必过度设计。
最后再分享一点个人体会。我从决定写这个 MCP Server 到真正把它跑稳,前前后后花了两周时间,真正写代码的时间可能不到三天,其余时间全耗在调试、调优工具描述和理解 AI 的行为模式上。但这两周非常值——因为我现在处理报表的方式彻底变了,不再是“打开 Excel,手动操作,保存关闭”,而是打开 AI 客户端说一句“把上个月的渠道数据按区域汇总,产出对比表格”,然后等它自己完成验证。
如果你也想把手里的 Excel 工作流重构一遍,我的建议很简单:不要一上来就规划一个大而全的 Server,从 read_excel 这一个工具开始,配上客户端跑通闭环,然后发现缺哪个能力就加哪个。工具会越用越顺手,你对 AI 能做什么、不能做什么的理解,也会在一次次实跑中清晰起来。这大概就是“亲手造一把趁手的工具”最有意思的地方了。