
This tutorial is part of Build an MCP Server, within The MCP Guide. Want it built for you? See MCP development.
An MCP server for Excel files lets an AI assistant open the workbooks on your computer and read them sheet by sheet. You build it in Python with the official MCP SDK and the openpyxl library. It needs two tools to be useful: one that lists workbooks and their sheets, and one that reads a sheet.
The tricky part is not MCP. It is Excel formulas. openpyxl reads files, but it does not run Excel, so formula results depend on what Excel saved last. This guide shows that problem in a real test and how to handle it.
Checked against: the Python SDK docs and the openpyxl docs on 2 November 2026, with mcp 2.3.0 (the latest PyPI release) and openpyxl 3.1.5. The code ran locally on 2 November 2026 on Python 3.12.
Key Takeaways
- Use
openpyxl.load_workbook(..., read_only=True)for large files, and always callclose()afterwards, as the openpyxl docs require. data_only=Truereturns the values Excel stored when the file was last saved. A workbook never opened in Excel can returnNonefor formula cells.- Offer a "formulas" switch so the model can see the formula text when values are missing.
- Lock the server to one folder and to
.xlsxand.xlsmfiles. - Keep the first version read-only. Writing to a workbook someone has open in Excel causes conflicts.
What this server is for
Many small businesses run on spreadsheets: budgets, price lists, schedules, inventory. A local Excel server lets you ask, "What is the Q2 total for ads in budget.xlsx?" and get an answer from the actual file.
It reads local .xlsx files. If your workbooks live in Google Sheets, see building an MCP server for Google Sheets. For plain exports, a CSV server is simpler.
Prerequisites
- Python 3.11 or newer.
- A folder of
.xlsxfiles. The openpyxl docs describe the library as reading and writing "Excel 2010 xlsx/xlsm files," so convert older.xlsfiles first. uvorpip, and Node.js 22.19+ for the Inspector CLI.
New to MCP? Lesson 14 of our course walks through your first Python tool.
Install
uv init excel-mcp --python 3.12
cd excel-mcp
uv add "mcp[cli]" openpyxl
mkdir files
Minimal working server
Create server.py:
import os
from pathlib import Path
from openpyxl import load_workbook
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
FILES_DIR = Path(os.environ.get("EXCEL_DIR", "files")).resolve()
READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)
mcp = MCPServer("excel-files")
def workbook_path(name: str) -> Path:
path = (FILES_DIR / name).resolve()
if not path.is_relative_to(FILES_DIR) or path.suffix.lower() not in {".xlsx", ".xlsm"}:
raise ToolError(f"{name!r} is not an .xlsx or .xlsm file in the files folder.")
if not path.exists():
raise ToolError(f"No workbook named {name!r}. Call list_workbooks first.")
return path
@mcp.tool(annotations=READ_ONLY)
def list_workbooks() -> dict[str, list[str]]:
"""List each workbook and its sheet names."""
result = {}
for path in sorted(FILES_DIR.glob("*.xls[xm]")):
wb = load_workbook(path, read_only=True)
result[path.name] = wb.sheetnames
wb.close()
return result
if __name__ == "__main__":
mcp.run()
What it does:
workbook_pathkeeps every request insideFILES_DIRand limits it to Excel files. The model picks the file name, so never trust it blindly.read_only=True. The openpyxl optimized modes page says read-only mode uses "lazy loading" and is meant for very large files. It also says "The workbook must be explicitly closed with theclose()method."list_workbooksgives the model a map of what exists, so it does not guess sheet names.
Add a tool that reads a sheet
Add this below list_workbooks:
from typing import Annotated, Any
from pydantic import Field
@mcp.tool(annotations=READ_ONLY)
def read_sheet(
workbook: str,
sheet: str,
max_rows: Annotated[int, Field(ge=1, le=1000)] = 100,
formulas: Annotated[bool, Field(description="True returns formula text instead of cached values.")] = False,
) -> list[list[Any]]:
"""Read rows from one sheet. Values are what Excel last saved, not recalculated."""
wb = load_workbook(workbook_path(workbook), read_only=True, data_only=not formulas)
try:
if sheet not in wb.sheetnames:
raise ToolError(f"No sheet {sheet!r}. Sheets: {', '.join(wb.sheetnames)}")
rows = []
for row in wb[sheet].iter_rows(values_only=True):
rows.append([str(v) if v is not None and not isinstance(v, (int, float, str, bool)) else v for v in row])
if len(rows) >= max_rows:
break
return rows
finally:
wb.close()
Key details:
data_only. The openpyxl tutorial says this flag controls whether formula cells return the formula or "the value stored the last time Excel read the sheet."values_only=Truereturns plain cell values instead of cell objects.- The
str(v)conversion turns dates and times into text, so the result is always valid JSON. - The
finallyblock closes the workbook even when the sheet name is wrong. - The sheet error lists real sheet names, so the model can correct itself.
Test it in memory
To test, create a workbook with a formula. This script builds one with openpyxl, which is exactly the case that catches people out:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "Budget"
ws.append(["Item", "Q1", "Q2", "Total"])
ws.append(["Ads", 1000, 1200, "=B2+C2"])
ws.append(["Hosting", 300, 300, "=B3+C3"])
wb.create_sheet("Notes").append(["Owner", "Dana"])
wb.save("files/budget.xlsx")
Then create check.py:
import anyio
from mcp import Client
from server import mcp
async def main() -> None:
async with Client(mcp) as c:
print((await c.call_tool("list_workbooks", {})).structured_content)
print((await c.call_tool("read_sheet", {"workbook": "budget.xlsx", "sheet": "Budget"})).structured_content)
print((await c.call_tool("read_sheet", {
"workbook": "budget.xlsx", "sheet": "Budget", "formulas": True})).structured_content)
r = await c.call_tool("read_sheet", {"workbook": "budget.xlsx", "sheet": "Sheet1"})
print(r.is_error, r.content[0].text)
anyio.run(main)
The output on 2 November 2026:
{'budget.xlsx': ['Budget', 'Notes']}
{'result': [['Item', 'Q1', 'Q2', 'Total'], ['Ads', 1000, 1200, None], ['Hosting', 300, 300, None]]}
{'result': [['Item', 'Q1', 'Q2', 'Total'], ['Ads', 1000, 1200, '=B2+C2'], ['Hosting', 300, 300, '=B3+C3']]}
True Error executing tool read_sheet: No sheet 'Sheet1'. Sheets: Budget, Notes
Look at the second line. The Total column is None. The file was written by openpyxl and never opened in Excel, so no calculated value was stored. With formulas: True, the model sees =B2+C2 and can do the math itself or tell you the file needs recalculating.
Files saved by Excel normally store calculated values, so data_only returns numbers. Still, write your tool description so the model knows that values are "what Excel last saved."
Test with the MCP Inspector
npx @modelcontextprotocol/inspector --cli \
-e EXCEL_DIR=/ABSOLUTE/PATH/excel-mcp/files \
uv --directory /ABSOLUTE/PATH/excel-mcp run server.py \
--method tools/call --tool-name list_workbooks --format json
The Inspector docs explain the web UI and CLI. Our Inspector lesson shows how to script checks like this one.
Connect it to a host
Register it in Claude Code, with options before the server name:
claude mcp add -e EXCEL_DIR=/ABSOLUTE/PATH/excel-mcp/files --transport stdio excel-files \
-- uv run --directory /ABSOLUTE/PATH/excel-mcp server.py
For Claude Desktop, Cursor or VS Code, follow the SDK's real-host guide, or build the JSON with our MCP config generator. Use absolute paths everywhere.
Deploy notes
Keep an Excel server local. The workbooks stay on your machine and nothing listens on a network port. If several people need the same workbook, a shared spreadsheet service or a database is a better home than a file on one laptop.
If you later add writes, use a separate tool that writes to a new file, such as budget-ai-edits.xlsx. The openpyxl docs say read-only worksheets cannot be edited, so you would load the workbook in normal mode for writes. Saving with openpyxl also drops cached formula values until Excel recalculates, as the test above shows.
Pitfalls
- Forgetting
close(). Read-only workbooks hold the file open until closed, which can lock it on Windows. - Trusting
data_onlyblindly. Cells with formulas can returnNoneor stale values. Offer formula text as a fallback. - Huge sheets. Cap rows. A 50,000-row sheet will overflow the model's context. Add a range or filter tool instead.
- v1 code.
from mcp.server.fastmcp import FastMCPdoes not exist in SDK v2. Usefrom mcp.server import MCPServer. - Hidden sheets and personal data. The server reads every sheet, including hidden ones. Remove sheets the AI should not see.
- Macros.
.xlsmfiles can contain macros. This server reads cell values only and never runs them.
Next step
Learn the rest of the MCP building blocks in the free Learn MCP course. If your team runs on spreadsheets and wants a safe, scoped server for them, eSEOspace MCP development can help design it. Before connecting any server, review its permissions with our tool permission planner.
More in the MCP Guide
Free tools: server.json Validator · MCP Error Code Lookup
Latest research: State of MCP: October 2026
Frequently asked questions
Can openpyxl recalculate formulas?
No calculation step appears in its docs. With data_only=True you get the value Excel stored at last save. If you need fresh results, open and save the file in Excel, or compute the totals in your own Python code.
Does this work with .xls or .csv files?
Not as written. openpyxl targets .xlsx and .xlsm. Convert .xls files in Excel first, and use a CSV server for .csv exports.
Can the model edit cells?
You can add a write tool, but start read-only. If you add one, write to a copy and keep the original untouched until a person reviews it.
What about Excel files in OneDrive or SharePoint?
This server reads files on disk. A synced OneDrive folder works if the files are downloaded locally. For cloud-only workbooks you need an API-based server instead.
Will dates come back correctly?
openpyxl returns Python date and time objects for date cells. The server converts them to text, so the model sees a readable date string.











