
1. 为什么我要自己动手写一个 MCP1.1 从一次崩溃的 Excel 处理经历说起上个月帮朋友处理一批销售数据大概三百多个 Excel 文件每个文件结构类似但列名有细微差异需要统一格式后合并汇总。我一开始想的是写个 Python 脚本批量跑一遍就完事了结果打开第一个文件就发现列名有中文有英文有的表头在第一行有的在第三行还有几个文件里混着合并单元格。纯脚本处理不是不行但每遇到一种新情况就得改代码、重跑、再检查来回折腾了整整一个下午。后来我换了个思路能不能让 AI 来帮我判断这些乱七八糟的表格结构然后自动生成处理逻辑试了几个现成的方案要么是上传文件到网页端手动操作要么是写死的规则引擎灵活性都不够。直到我认真研究了一下 MCP 这个东西才发现它正好能解决我的问题——把 Excel 处理能力封装成一个 AI 可以调用的工具让 AI 根据实际数据情况动态决定怎么处理。这就是我开发第一个 MCP 项目的起点。说白了MCP 就是一套让 AI 模型能够调用外部工具的协议标准你可以把它理解成 AI 和你的代码之间的一座桥。桥这边是 AI 的对话界面桥那边是你写的 Python 函数AI 说“帮我读一下这个 Excel 的列名”你的函数就去读然后把结果返回给 AIAI 再决定下一步做什么。1.2 这个项目适合谁来参考如果你符合下面任意一条这篇内容应该对你有用经常需要处理 Excel 数据但每次遇到格式不统一的文件就头疼有 Python 基础想试试怎么把自己的代码接入 AI 工作流听说过 MCP 但还没动手写过想找一个完整的入门项目练手已经在用一些 AI 编程工具想进一步扩展 AI 能做的事情不需要你是什么 AI 专家也不需要你懂什么大模型原理。只要你会写基本的 Python 函数知道 Excel 大概是怎么回事就能跟着走下来。我尽量把每一步都拆开讲清楚包括我踩过的坑和后来想明白的道理。1.3 整体方案是怎么设计的我的核心思路很简单把 Excel 处理拆成若干个原子操作每个操作封装成一个 MCP 工具让 AI 根据任务需要自己组合调用。具体来说我定义了这么几个工具工具名称功能输入输出read_excel_info读取表格基本信息文件路径列名、行数、数据类型read_excel_data读取指定范围数据文件路径、列名、行范围二维数组write_excel_data写入数据到表格文件路径、数据、目标位置操作结果transform_column对某列做转换文件路径、列名、转换规则新列数据merge_excel_files合并多个表格文件路径列表、对齐规则合并后文件路径这样设计的好处是AI 不需要一次性理解整个复杂任务它可以先读信息、再判断、再操作、再验证像人一样一步步来。比如遇到列名不统一的情况AI 可以先读所有文件的列名然后自己判断哪些列是同一个意思再决定怎么对齐。为什么不用一个大而全的函数搞定所有事情因为 AI 调用工具的次数是有限的每次调用都有开销。如果工具粒度太粗AI 很难精确控制如果粒度太细调用次数又会爆炸。我试了几轮之后发现按“读、写、转换、合并”这四个维度来拆分颗粒度刚刚好。2. 动手之前先把这些概念理清楚2.1 MCP 到底是个什么东西MCP 全称是 Model Context Protocol翻译过来叫“模型上下文协议”。这个名字听起来很唬人但其实概念特别简单。你可以把它想象成 USB 接口。以前每个设备都有自己的充电口诺基亚的、摩托罗拉的、索尼的都不一样出门得带一堆线。后来有了 USB-C一个口就能搞定所有设备。MCP 做的事情类似以前每个 AI 平台接入外部工具的方式都不一样OpenAI 有一套、Claude 有一套、其他平台又有一套开发者要适配很多遍。MCP 就是想让大家都用同一个标准你按这个标准写一次工具所有支持 MCP 的 AI 平台都能用。具体到技术层面MCP 定义了一套通信规则AI 端叫 Client工具端叫 Server两者之间通过标准化的消息格式来交互。Server 端需要声明自己提供哪些工具、每个工具需要什么参数、返回什么结果。Client 端负责把 AI 的意图翻译成对工具的调用请求再把结果传回给 AI。我一开始以为 MCP 是什么高深的技术实际写下来发现核心就是定义一个工具列表然后实现每个工具的处理函数。协议本身帮你处理了通信、序列化、错误传递这些事情你只需要关注业务逻辑。2.2 为什么选 Python 来写选 Python 的理由很直接Excel 处理生态最成熟。openpyxl、pandas、xlrd 这些库经过多年打磨各种边界情况都处理得差不多了。你要是用 JavaScript 或者 Go 来写光是处理 Excel 的各种格式兼容性就够喝一壶的。另外 Python 写 MCP Server 的入门门槛确实低。官方提供了 SDK你装好包之后定义一个函数、加个装饰器、声明一下参数类型基本上就能跑了。不需要你懂什么异步编程、网络协议SDK 都帮你封装好了。还有一个实际考虑我平时做数据分析、自动化脚本都是用 PythonExcel 处理这块的代码积累比较多直接复用起来方便。如果你主力语言是别的也不用担心MCP 协议本身是语言无关的用你熟悉的语言写 Server 完全没问题只是 Python 的现成轮子最多。2.3 环境准备装什么、怎么装先把基础环境搭好。我假设你已经装了 Python如果还没装去官网下载最新稳定版就行安装的时候记得勾选“Add Python to PATH”不然后面命令行里调不到 python 命令。装好 Python 之后打开终端或命令行依次执行下面几个命令pip install mcp pip install openpyxl pip install pandas这三个包分别是 MCP 的 Python SDK、Excel 读写库、数据处理库。版本方面mcp 建议用 1.0 以上的openpyxl 用 3.1 以上pandas 用 2.0 以上。如果你之前装过旧版本可以先升级一下pip install --upgrade mcp openpyxl pandas装完之后可以验证一下import mcp import openpyxl import pandas print(mcp.__version__) print(openpyxl.__version__) print(pandas.__version__)能正常打印出版本号就说明环境没问题了。注意如果你用的是 Windows 系统建议在 WSL 或者 Git Bash 里操作原生 CMD 和 PowerShell 有时候会有路径和编码的坑。我一开始在 CMD 里跑中文路径死活读不出来换成 Git Bash 就正常了。2.4 项目目录结构怎么安排我习惯把 MCP 项目按下面这个结构来组织excel-mcp-server/ ├── server.py # MCP Server 主入口 ├── tools/ │ ├── __init__.py │ ├── read_tools.py # 读取相关工具 │ ├── write_tools.py # 写入相关工具 │ └── transform_tools.py # 转换相关工具 ├── utils/ │ ├── __init__.py │ └── excel_helper.py # Excel 操作辅助函数 ├── requirements.txt └── README.md这样分的好处是工具多了之后不会全挤在一个文件里。每个工具模块只负责一类操作改起来方便也容易测试。utils 里放一些公共的辅助函数比如路径处理、数据类型判断、异常封装这些。requirements.txt 里把依赖列清楚mcp1.0.0 openpyxl3.1.0 pandas2.0.03. 核心工具的实现细节3.1 读取表格信息让 AI 先“看一眼”数据这是最基础也最重要的一个工具。AI 在处理任何 Excel 之前都需要先了解这个文件长什么样有多少行、多少列、列名叫什么、每列大概是什么类型的数据。实现思路很直接用 openpyxl 打开文件读第一行作为列名然后统计行数再对每列做一下类型推断。from openpyxl import load_workbook from mcp.server import Server from mcp.types import Tool, TextContent server Server(excel-mcp) server.tool() async def read_excel_info(file_path: str, sheet_name: str None) - str: 读取 Excel 文件的基本信息包括列名、行数、数据类型 try: wb load_workbook(file_path, read_onlyTrue, data_onlyTrue) if sheet_name: ws wb[sheet_name] else: ws wb.active # 读第一行作为列名 headers [] for cell in ws[1]: headers.append(cell.value if cell.value else f列{cell.column}) # 统计行数 row_count ws.max_row - 1 # 对每列做类型推断 column_types {} for idx, header in enumerate(headers): sample_values [] for row in ws.iter_rows(min_row2, max_rowmin(12, ws.max_row), min_colidx1, max_colidx1): for cell in row: if cell.value is not None: sample_values.append(cell.value) if not sample_values: column_types[header] 空列 elif all(isinstance(v, (int, float)) for v in sample_values): column_types[header] 数值 elif all(isinstance(v, str) for v in sample_values): column_types[header] 文本 else: column_types[header] 混合 wb.close() result f文件: {file_path}\n result f工作表: {ws.title}\n result f数据行数: {row_count}\n result f列数: {len(headers)}\n\n result 列信息:\n for header in headers: result f - {header} ({column_types[header]})\n return result except Exception as e: return f读取失败: {str(e)}这里有几个细节值得说一下。为什么用 read_onlyTrue只读模式打开文件速度快很多尤其是大文件。我们只是读信息不需要写入用只读模式最合适。data_onlyTrue 是为了拿到公式计算后的值而不是公式本身。为什么只采样前 10 行做类型推断全量扫描太慢而且实际数据里经常有异常值。采样前 10 行基本能判断出这一列的主要类型。如果前 10 行都是数字第 11 行突然来个文本那属于数据质量问题不是类型推断要解决的。列名为空怎么处理实际 Excel 里经常有列没有表头我用“列序号”来兜底保证每个列都有个标识符AI 后续引用的时候不会搞混。3.2 读取数据按需取数不要一次全读AI 有时候只需要看几行数据来确认格式有时候需要读整列来做分析。所以读取工具要支持灵活的范围指定。server.tool() async def read_excel_data( file_path: str, sheet_name: str None, columns: list None, start_row: int 2, max_rows: int 100 ) - str: 读取 Excel 指定范围的数据 try: wb load_workbook(file_path, read_onlyTrue, data_onlyTrue) ws wb[sheet_name] if sheet_name else wb.active # 获取所有列名 all_headers [cell.value for cell in ws[1]] # 确定要读的列索引 if columns: col_indices [] for col in columns: if col in all_headers: col_indices.append(all_headers.index(col)) else: return f列 {col} 不存在可用列: {all_headers} else: col_indices list(range(len(all_headers))) # 读取数据 data_rows [] for row in ws.iter_rows(min_rowstart_row, max_rowstart_row max_rows - 1, values_onlyTrue): selected [row[i] if i len(row) else None for i in col_indices] data_rows.append(selected) wb.close() # 格式化为表格文本 selected_headers [all_headers[i] for i in col_indices] result | .join(str(h) for h in selected_headers) \n result - * 40 \n for row in data_rows: result | .join(str(v) if v is not None else for v in row) \n return result except Exception as e: return f读取失败: {str(e)}这个工具的关键在于参数设计要符合 AI 的使用习惯。AI 不会一次性告诉你“我要第 3 到第 15 行、第 2 和第 5 列”它更可能说“帮我看看‘销售额’这一列的前 20 个值”。所以我把参数设计成列名列表 起始行 最大行数AI 理解起来更自然。max_rows 默认给 100是防止 AI 不小心把整个大表都读出来导致返回内容过长。如果确实需要更多数据AI 可以自己调大这个参数但至少有个默认的安全边界。3.3 写入数据小心再小心写入操作比读取危险得多因为一旦写错就可能覆盖原始数据。我的做法是默认不覆盖原文件而是写入新文件除非调用方明确指定要覆盖。import shutil from datetime import datetime server.tool() async def write_excel_data( file_path: str, data: list, sheet_name: str None, start_cell: str A1, create_backup: bool True ) - str: 向 Excel 写入数据默认创建备份 try: # 创建备份 if create_backup: timestamp datetime.now().strftime(%Y%m%d_%H%M%S) backup_path f{file_path}.backup_{timestamp} shutil.copy2(file_path, backup_path) wb load_workbook(file_path) ws wb[sheet_name] if sheet_name else wb.active # 解析起始单元格 from openpyxl.utils import coordinate_to_tuple start_row, start_col coordinate_to_tuple(start_cell) # 写入数据 for i, row_data in enumerate(data): for j, value in enumerate(row_data): ws.cell(rowstart_row i, columnstart_col j, valuevalue) wb.save(file_path) wb.close() return f写入成功共 {len(data)} 行起始位置 {start_cell} except Exception as e: return f写入失败: {str(e)}备份机制是必须的。我在这上面吃过亏有一次测试的时候不小心把客户给的原始数据覆盖了还好之前手动复制过一份。从那以后所有写入操作我都强制加备份哪怕多占点磁盘空间也值得。start_cell 参数用 Excel 的坐标格式如“A1”、“C5”而不是行号列号分开传是因为 AI 对 Excel 坐标更熟悉它生成坐标比生成两个数字更不容易出错。3.4 列转换最常用的数据处理操作实际工作中大部分 Excel 处理需求都可以归结为“对某一列做某种转换”。比如把文本转成数字、把日期格式化、把分类值映射成另一组值、对数值做计算等等。server.tool() async def transform_column( file_path: str, column_name: str, operation: str, params: dict None, output_column: str None ) - str: 对指定列执行转换操作 try: wb load_workbook(file_path) ws wb.active headers [cell.value for cell in ws[1]] if column_name not in headers: return f列 {column_name} 不存在 col_idx headers.index(column_name) 1 output_idx headers.index(output_column) 1 if output_column in headers else len(headers) 1 if output_column and output_column not in headers: ws.cell(row1, columnoutput_idx, valueoutput_column) params params or {} for row in range(2, ws.max_row 1): cell ws.cell(rowrow, columncol_idx) value cell.value if operation to_number: try: new_value float(value) if value else 0 except (ValueError, TypeError): new_value params.get(default, 0) elif operation to_text: new_value str(value) if value is not None else elif operation multiply: factor params.get(factor, 1) new_value (value or 0) * factor elif operation replace: old params.get(old, ) new params.get(new, ) new_value str(value).replace(old, new) if value else elif operation extract: import re pattern params.get(pattern, ) match re.search(pattern, str(value)) if value else None new_value match.group(0) if match else else: return f不支持的操作: {operation} ws.cell(rowrow, columnoutput_idx, valuenew_value) wb.save(file_path) wb.close() return f转换完成: {column_name} - {operation} except Exception as e: return f转换失败: {str(e)}operation 参数我设计成了字符串枚举而不是让 AI 直接传 Python 代码。这样做更安全也更容易控制。AI 只需要说“把这一列转成数字”或者“把这一列里的‘元’字去掉”我来负责翻译成具体的操作。params 用字典传额外参数这样不同操作可以有自己的参数集扩展起来方便。比如 multiply 需要 factorreplace 需要 old 和 newextract 需要 pattern。3.5 合并多个表格处理列名不一致的情况这是我最常用的功能也是最能体现 AI 价值的地方。传统脚本合并表格要求所有表格列名完全一致但实际数据里列名经常有差异。有了 AI 参与它可以先读所有表格的列名判断哪些列是同一个意思然后决定怎么对齐。server.tool() async def merge_excel_files( file_paths: list, output_path: str, column_mapping: dict None ) - str: 合并多个 Excel 文件支持列名映射 try: all_data [] all_headers set() # 第一遍收集所有列名 for fp in file_paths: wb load_workbook(fp, read_onlyTrue, data_onlyTrue) ws wb.active headers [cell.value for cell in ws[1] if cell.value] all_headers.update(headers) wb.close() all_headers sorted(all_headers) # 第二遍读取数据并按统一列名对齐 for fp in file_paths: wb load_workbook(fp, read_onlyTrue, data_onlyTrue) ws wb.active headers [cell.value for cell in ws[1]] # 应用列名映射 if column_mapping: headers [column_mapping.get(h, h) for h in headers] for row in ws.iter_rows(min_row2, values_onlyTrue): row_dict {} for h, v in zip(headers, row): if h: row_dict[h] v all_data.append(row_dict) wb.close() # 写入合并结果 wb_out Workbook() ws_out wb_out.active ws_out.append(all_headers) for row_dict in all_data: ws_out.append([row_dict.get(h) for h in all_headers]) wb_out.save(output_path) wb_out.close() return f合并完成: {len(file_paths)} 个文件 - {output_path}共 {len(all_data)} 行 except Exception as e: return f合并失败: {str(e)}column_mapping 参数是关键。AI 可以先调用 read_excel_info 读取所有文件的列名然后自己判断“销售额”和“销售金额”是同一个意思、“产品名称”和“商品名”是同一个意思生成一个映射字典传进来。这个设计把“判断列名是否等价”这个最需要智能的环节交给了 AI而把“按映射对齐数据”这个纯机械操作留给了代码。各司其职效率最高。4. 把 Server 跑起来并接入 AI4.1 启动脚本怎么写所有工具定义好之后需要一个入口来启动 Server。MCP 的 Python SDK 提供了 stdio 和 SSE 两种传输方式本地开发用 stdio 最简单。# server.py import asyncio from mcp.server import Server from mcp.server.stdio import stdio_server # 导入所有工具模块触发装饰器注册 from tools import read_tools, write_tools, transform_tools async def main(): server Server(excel-mcp) 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())这里有个容易踩的坑工具模块必须被导入装饰器才会执行注册。如果你只写了 tools/read_tools.py 但没在 server.py 里 import那些工具就不会出现在工具列表里。我一开始就犯了这个错误调试了半天才发现是导入的问题。4.2 怎么测试工具能不能用在接入 AI 之前先单独测试每个工具函数确保逻辑正确。可以写一个简单的测试脚本import asyncio from tools.read_tools import read_excel_info async def test(): result await read_excel_info(test.xlsx) print(result) asyncio.run(test())准备一个测试用的 Excel 文件包含各种情况有中文列名、有英文列名、有数字列、有文本列、有空值。跑一遍看看输出是否符合预期。实操心得测试文件一定要包含“脏数据”。我专门做了一个测试文件里面有合并单元格、有空行、有公式、有日期格式、有超长文本。每次改完代码都拿这个文件跑一遍能提前发现很多边界问题。4.3 接入 AI 客户端的配置方法不同 AI 客户端的配置方式略有差异但核心都是告诉客户端“去哪里启动这个 MCP Server”。以配置文件为例通常需要指定命令、参数和环境变量{ mcpServers: { excel-tools: { command: python, args: [/path/to/excel-mcp-server/server.py], env: { PYTHONPATH: /path/to/excel-mcp-server } } } }配置好之后重启客户端AI 就能看到你定义的那些工具了。你可以直接对 AI 说“帮我看看 test.xlsx 里有哪些列”它就会自动调用 read_excel_info 工具。如果工具没有出现按下面这个顺序排查确认 server.py 能独立运行不报错确认所有工具模块都被导入了确认配置文件路径写对了查看客户端日志看有没有连接错误信息4.4 一个完整的处理流程演示假设我有三个销售数据文件列名分别是文件A日期、产品、销售额、数量文件B日期、商品名、销售金额、数量文件C日期、产品名称、销售额、销售数量我想把它们合并成一个表。整个交互过程是这样的第一步我对 AI 说“帮我看看这三个文件分别有哪些列。”AI 依次调用 read_excel_info返回三个文件的列信息。第二步AI 自己判断“产品”、“商品名”、“产品名称”是同一个意思“销售额”、“销售金额”是同一个意思“数量”、“销售数量”是同一个意思。然后生成映射{ 商品名: 产品, 产品名称: 产品, 销售金额: 销售额, 销售数量: 数量 }第三步AI 调用 merge_excel_files传入三个文件路径和映射字典生成合并后的文件。整个过程我只需要说一句话AI 自己完成了信息收集、判断、执行。这就是 MCP 的价值所在——把 AI 的理解能力和代码的执行能力结合起来。5. 踩过的坑和对应的解决方案5.1 中文编码问题Windows 系统下用 openpyxl 读中文 Excel有时候会遇到编码错误。表现是读出来的中文变成乱码或者直接抛 UnicodeDecodeError。根本原因是 Windows 默认编码是 GBK而 Excel 文件内部用的是 UTF-8。解决方案是在打开文件时显式指定编码或者用二进制模式读取wb load_workbook(file_path, read_onlyTrue, data_onlyTrue)openpyxl 本身处理编码没问题问题往往出在文件路径上。如果路径里有中文在某些终端环境下会出问题。我的做法是统一用绝对路径并且在代码里做一次路径规范化import os file_path os.path.abspath(os.path.normpath(file_path))5.2 大文件处理超时有一次处理一个 50MB 的 Excel 文件read_excel_info 跑了快两分钟才返回AI 客户端直接超时了。后来我做了两个优化一是 read_only 模式必须开这个前面说过了二是类型推断的采样行数从 10 行降到 5 行并且加了缓存机制同一个文件第二次读取时直接返回缓存结果。_info_cache {} async def read_excel_info(file_path: str, sheet_name: str None) - str: cache_key f{file_path}:{sheet_name} if cache_key in _info_cache: return _info_cache[cache_key] # ... 读取逻辑 ... _info_cache[cache_key] result return result缓存的有效期我设的是当前会话不持久化。因为文件可能在两次调用之间被修改持久化缓存会导致读到旧数据。5.3 工具调用参数格式错误AI 有时候会传一些意料之外的参数格式。比如 columns 参数我期望的是[列A, 列B]但 AI 可能传成列A,列B或者[[列A], [列B]]。解决办法是在工具函数入口做参数规范化def normalize_columns(columns): if columns is None: return None if isinstance(columns, str): return [c.strip() for c in columns.split(,)] if isinstance(columns, list): result [] for c in columns: if isinstance(c, list): result.extend(c) else: result.append(c) return result return columns这个函数看起来简单但能避免很多莫名其妙的报错。永远不要相信 AI 传过来的参数格式完全符合你的预期做好防御性编程。5.4 常见问题速查表问题现象可能原因解决方法工具列表为空工具模块未导入在 server.py 中 import 所有工具模块读取返回乱码路径编码问题使用绝对路径规范化路径大文件超时全量读取开启 read_only减少采样行数加缓存写入后文件损坏未正确关闭工作簿确保 wb.close() 在 save 之后调用AI 传参格式错误参数类型不匹配在函数入口做参数规范化合并后列顺序混乱未排序对 all_headers 做 sorted 排序日期变成数字Excel 日期序列号用 openpyxl 的日期格式化处理6. 后续可以怎么扩展6.1 增加更多实用工具目前只实现了最基础的读写和转换实际用下来还有几个工具很值得加数据校验工具检查某列是否有空值、重复值、格式异常透视表生成工具按指定维度做分组汇总图表生成工具根据数据自动生成柱状图、折线图条件格式工具对满足条件的单元格设置格式每加一个工具AI 能做的事情就多一分。但也不要贪多工具太多会让 AI 选择困难反而降低效率。我的经验是保持在 10 到 15 个工具左右比较合适。6.2 支持更多文件格式Excel 只是起点。同样的思路可以扩展到 CSV、JSON、甚至数据库。把“读数据”和“写数据”这两个底层操作抽象出来上层工具就可以复用。比如加一个 read_csv_info 工具内部实现和 read_excel_info 几乎一样只是换了个读取库。AI 不需要知道底层是 Excel 还是 CSV它只需要知道“读信息”和“读数据”这两个操作。6.3 加入错误恢复机制现在的工具遇到错误就直接返回错误信息AI 收到后需要自己判断怎么处理。可以进一步优化让工具在遇到可恢复错误时自动重试。比如读取文件时遇到临时锁定可以等一秒后重试写入时遇到权限问题可以尝试用只读方式打开再另存。这些逻辑封装在工具内部AI 就不用操心这些细节了。6.4 性能优化方向如果经常处理大文件可以考虑这几个优化用 pandas 替代 openpyxl 做批量数据处理速度更快对频繁读取的文件做内存缓存把耗时的操作改成异步执行避免阻塞支持分块读取不要一次性加载整个文件我在实际使用中的体会是大部分 Excel 处理场景的数据量都在几千行以内性能不是瓶颈。真正花时间的是 AI 的理解和判断过程而不是代码执行。所以优化重点应该放在减少 AI 调用次数上而不是单纯提升代码速度。最后分享一个小技巧如果你也在开发 MCP 工具建议从最简单的“读信息”工具开始先跑通整个链路确认 AI 能正常调用然后再逐步增加复杂工具。我一开始想一口气把所有工具都写完再测试结果调试的时候根本不知道是哪个环节出了问题。后来改成写一个测一个效率反而高了很多。