
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 CSV data lets an AI assistant read the exports you already have: sales reports, ad spend, CRM lists. You point the server at one folder. It gives the model three tools: list the files, preview one, and total a column by group. No database is needed.
This guide builds that server with the official Python SDK, version 2, and only the standard library csv module. It locks the server to one folder, returns clear errors the model can fix, and keeps large files from flooding the model's context.
Checked against: the Python SDK docs and the Python csv module docs on 2 November 2026, with mcp 2.3.0 (the latest PyPI release). The code ran locally on 2 November 2026 on Python 3.12.
Key Takeaways
- Keep the server inside one data folder and reject any path that resolves outside it.
- Give the model a preview tool that returns column names and a few rows, so it knows what it is working with.
- Do the math in your code, not in the model. A
sum_bytool returns exact totals. - Open CSV files with
newline=""as the Python docs require, andutf-8-sigto strip the byte-order mark Excel adds. - For big files or complex questions, switch to SQLite or a database rather than loading everything into the model.
Why a CSV server instead of pasting the file?
You can paste a small CSV into a chat. That stops working once the file has thousands of rows, or when you want the same answer every week. Language models are also unreliable at adding up long columns by reading them.
A server fixes both. The model asks for what it needs, and your Python code does the counting. The answer is exact, and the model only sees the summary.
Prerequisites
- Python 3.11 or newer.
- A folder of CSV files with a header row. This guide uses
data/sales.csvwith columnsdate, region, product, units, revenue. uvorpip, plus Node.js 22.19+ for the Inspector CLI.
If you have not built an MCP tool before, lesson 14 of the Learn MCP course is a good first step.
Install
uv init csv-mcp --python 3.12
cd csv-mcp
uv add "mcp[cli]"
mkdir data
Copy your CSV exports into data/.
Minimal working server
Create server.py:
import csv
import os
from pathlib import Path
from typing import Annotated, Any
from pydantic import Field
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
DATA_DIR = Path(os.environ.get("CSV_DIR", "data")).resolve()
READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)
mcp = MCPServer("csv-data")
def safe_path(name: str) -> Path:
"""Resolve a file name inside DATA_DIR and refuse anything outside it."""
path = (DATA_DIR / name).resolve()
if not path.is_relative_to(DATA_DIR) or path.suffix.lower() != ".csv":
raise ToolError(f"{name!r} is not a CSV file in the data folder.")
if not path.exists():
raise ToolError(f"No file named {name!r}. Call list_csv_files first.")
return path
def read_rows(name: str) -> list[dict[str, str]]:
with open(safe_path(name), newline="", encoding="utf-8-sig") as f:
return list(csv.DictReader(f))
@mcp.tool(annotations=READ_ONLY)
def list_csv_files() -> list[str]:
"""List the CSV files you can read."""
return sorted(p.name for p in DATA_DIR.glob("*.csv"))
@mcp.tool(annotations=READ_ONLY)
def preview_csv(
name: str,
rows: Annotated[int, Field(ge=1, le=50)] = 5,
) -> dict[str, Any]:
"""Show the column names, row count and the first few rows of a CSV file."""
data = read_rows(name)
return {"columns": list(data[0].keys()) if data else [], "row_count": len(data), "rows": data[:rows]}
if __name__ == "__main__":
mcp.run()
Line by line:
safe_pathresolves the requested name and checks it is still insideDATA_DIR. A request for../server.pyor/etc/passwdresolves elsewhere and is refused. This matters because the model chooses the file name, and text inside your data could try to steer it.newline="". Thecsvdocs say a file object "should be opened withnewline=''," because otherwise newlines inside quoted fields are not read correctly.csv.DictReaderuses the first row as field names when you do not pass any, per the same docs. Each row becomes a dict keyed by header.utf-8-sigremoves the invisible byte-order mark that Excel often writes at the start of CSV exports. Without it, your first column name can come back asdate.READ_ONLYannotations tell hosts these tools change nothing and work on a closed set of files. The SDK's Tools guide describesopen_world_hint=Falseas working on a closed set of things, not the open web.
Add a tool that does the math
Add a grouping tool below preview_csv:
from collections import defaultdict
@mcp.tool(annotations=READ_ONLY)
def sum_by(
name: str,
group_column: Annotated[str, Field(description="Column to group by, e.g. region.")],
value_column: Annotated[str, Field(description="Numeric column to add up, e.g. revenue.")],
) -> dict[str, float]:
"""Add up a numeric column for each value of a grouping column."""
data = read_rows(name)
if data and (group_column not in data[0] or value_column not in data[0]):
raise ToolError(f"Unknown column. Available: {', '.join(data[0])}")
totals: dict[str, float] = defaultdict(float)
for row in data:
try:
totals[row[group_column]] += float(row[value_column] or 0)
except ValueError:
raise ToolError(f"{value_column} has a non-numeric value: {row[value_column]!r}")
return dict(totals)
The error for an unknown column lists the real column names. That is deliberate. The model reads the message, picks a valid column and tries again without asking you. Our error handling lesson covers this pattern in depth.
You can add more tools the same way: filter_rows with a column and value, top_n by a numeric column, or date_range for time series. Keep each one narrow and typed.
Test it in memory
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_csv_files", {})).structured_content)
print((await c.call_tool("sum_by", {
"name": "sales.csv", "group_column": "region", "value_column": "revenue"})).structured_content)
r = await c.call_tool("preview_csv", {"name": "../server.py"})
print(r.is_error, r.content[0].text)
r = await c.call_tool("sum_by", {
"name": "sales.csv", "group_column": "country", "value_column": "revenue"})
print(r.is_error, r.content[0].text)
anyio.run(main)
With a four-row sample file, the output on 2 November 2026 was:
{'result': ['sales.csv']}
{'West': 330.0, 'East': 340.0}
True Error executing tool preview_csv: '../server.py' is not a CSV file in the data folder.
True Error executing tool sum_by: Unknown column. Available: date, region, product, units, revenue
The path escape was refused, and the bad column came back with a list of valid ones.
Test it with the MCP Inspector
npx @modelcontextprotocol/inspector --cli \
-e CSV_DIR=/ABSOLUTE/PATH/csv-mcp/data \
uv --directory /ABSOLUTE/PATH/csv-mcp run server.py \
--method tools/list --format json
Check that each tool's description and input schema read clearly. The model sees only that text when deciding which tool to call. The MCP Inspector docs and our Inspector lesson explain the other methods and flags.
Connect it to a host
Use absolute paths for both the project and CSV_DIR. In Claude Desktop's claude_desktop_config.json:
{
"mcpServers": {
"csv-data": {
"command": "/ABSOLUTE/PATH/TO/uv",
"args": ["run", "--directory", "/ABSOLUTE/PATH/csv-mcp", "server.py"],
"env": { "CSV_DIR": "/ABSOLUTE/PATH/csv-mcp/data" }
}
}
}
The SDK's real-host guide lists the config file location for macOS and Windows, and the equivalent setup for Claude Code, Cursor and VS Code. You can also generate the JSON with our MCP config generator.
Then ask: "Which region had the most revenue in sales.csv?" The model should call sum_by and quote exact totals.
Deploy notes
A CSV server is usually local. The files stay on your computer, and the server runs only while the host is open. That is the simplest and safest setup.
If a team needs shared access, put the files in one controlled location and run the server remotely with sign-in. At that point, loading the CSVs into SQLite or PostgreSQL often pays off. See building an MCP server for SQLite.
Pitfalls
- Loading huge files every call. This server reads the whole file each time. That is fine for files of a few megabytes. For larger ones, cache parsed rows in the lifespan, or move the data into SQLite.
- Numbers stored as text. CSV has no types.
"1,200.00"with a thousands separator failsfloat(). Clean the export or strip separators in code. - Different delimiters. European exports often use semicolons. The
csvmodule'sSniffercan guess the format, but the docs call its header check "a rough heuristic." Prefer a known format. - Old tutorials. Code that imports
mcp.server.fastmcpis written for SDK v1 and fails on v2. Usefrom mcp.server import MCPServer. - Sensitive columns. If a file holds emails or phone numbers, the model will see them. Export only the columns the job needs.
Next step
The free Learn MCP course takes this server further with resources, prompts and remote deployment. If you want reporting tools built on your real exports, eSEOspace's MCP development service can scope them with you. Working with workbooks instead? See building an MCP server for Excel files.
More in the MCP Guide
Free tools: server.json Validator · MCP Error Code Lookup
Latest research: State of MCP: October 2026
Frequently asked questions
Why not let the model read the raw file as a resource?
You can expose a file as an MCP resource, and for small files that works. Tools are better for analysis, because your code does the math and returns only the answer. Our page on MCP resources explains when resources fit.
Can I use pandas instead of the csv module?
Yes. pandas is handy for grouping and dates. It adds a large dependency, so the standard library is a good start for simple totals.
How do I stop the model reading files outside the folder?
Resolve every requested path and check it is still inside the data folder, as safe_path does. Never join a model-supplied name to a path without that check.
Can the server write CSV files?
It can, but start read-only. If you add a write tool, make it create new files with a fixed name pattern rather than overwrite existing exports.
What file size is too big?
There is no fixed rule. If calls feel slow or results get cut off, move the data into a database and query it there.











