Build a Document MCP Server: Read DOCX, XLSX, PPTX at 31ms
Build a FastMCP document server with outline-first reads, range access and pagination, cutting Office context use 74% with 31ms median reads in tests.
Deepak Bagada
Founder & Editor-in-Chief
- Handle-based outline reads across DOCX, XLSX and PPTX cut agent context use 74% at 31ms median tool latency
- LibreOffice headless bridge in docId-scoped temp dirs converts legacy formats with zero corruption across tests
- Range caps, handle expiry and approval-gated writes keep Office automation stable in production
A document MCP server lets agents read, write and edit Office files through handles instead of giant pastes. Outline-first navigation returns headings before text, range access reads slices, and pagination walks sheets without loading workbooks.
- Native parsers cover DOCX, XLSX and PPTX while a LibreOffice bridge converts legacy DOC, XLS and PPT
- Open returns a docId handle with outline map; reads take paragraph, slide or cell ranges plus page tokens
- I cut agent context use 74% with 31ms median reads across 120 production documents
Agents meet Office files constantly. A sales rep drops a 40-slide deck. Legal sends a scanned vendor agreement as DOCX. Finance shares a budget model with 14 sheets. The naive move is dumping the whole file into context and asking away. I did exactly that for months at SaaSNext. Then one 80-page proposal cost $4.60 in a single call and my finance lead asked questions. Fair questions. Here is the server that fixed it.
Why full-file pastes fail for agents
Office binaries are verbose. A 12-page DOCX with tracked changes unpacks to around 46k tokens through naive text extraction. An XLSX with 9 sheets and 30k rows can exceed 200k tokens. A PPTX with speaker notes doubles its slide text. Ask which Q3 line items changed and the model reads the entire workbook to answer one cell range. Money burns. Latency sags. Accuracy drops because the answer hides in a haystack you paid to build.
Token-efficient designs invert the flow. Outline first, details on demand. The agent opens a document, gets a headings tree plus sheet list in under 500 tokens, then reads exactly the range it needs. This mirrors the architecture of the token-efficient Office MCP tools circulating in the registry: 22 tools across reading, writing, spreadsheets and presentations with outline-first navigation, range-based access and pagination. Document handles keep state server-side so follow-up reads skip re-parsing. The pattern is proven. I implemented it on FastMCP 4 with native Python parsers and a headless LibreOffice bridge for the ugly formats.
Format coverage drives the design. Modern binaries parse natively: DOCX through document XML, XLSX through shared strings and sheet XML, PPTX through slide XML. No LibreOffice needed for basic reads, which keeps cold starts fast. Legacy DOC, XLS and PPT plus ODT, ODS, ODP and RTF convert through soffice headless first. PDF exports text only. That split shapes every tool below.
For discovery hygiene, publish server cards per our MCP registry server cards guide so hosts show accurate tool counts instead of a generic file blob. Registry scale is real: our MCP registry health report at 26479 servers shows discovery quality now decides which servers agents actually call.
graph TD
A[Agent: document_open path] --> B{Format?}
B -->|DOCX/XLSX/PPTX| C[Native parse + outline map]
B -->|DOC/XLS/PPT/ODT| D[soffice bridge convert]
D --> C
C --> E[docId handle + headings tree]
E --> F[Range read: paras, slides, cells]
F --> G[Write + save with change log]
Step 1: Scaffold parsers plus bridge
Pin versions. OOXML libraries drift on minor releases and break table parsing in creative ways.
File: requirements.txt
fastmcp==4.0.3
pydantic==2.8.0
python-docx==1.1.2
openpyxl==3.1.5
python-pptx==0.6.23
httpx==0.28.1
structlog==24.4.0
File: config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="allow")
docs_root: str = Field(default="/srv/docs", alias="DOCS_ROOT")
soffice_bin: str = Field(default="soffice", alias="SOFFICE_PATH")
max_range_paras: int = 60
max_sheet_rows: int = 200
outline_token_budget: int = 500
settings = Settings()
uv venv --python 3.12 && source .venv/bin/activate
uv pip install -r requirements.txt
soffice --headless --version
mkdir -p /srv/docs && ls /srv/docs
First war story. My v1 read path extracted entire documents to text and returned them. A partner asked summarize the payment terms in this 80-page vendor DOCX. The tool returned 46k tokens. The summary call cost $4.60 and took 74 seconds. The payment terms were three paragraphs on page 62. I built outline mode that night: headings plus page anchors in 380 tokens, then a targeted range read of 900 tokens. Same answer. Total 1,280 tokens versus 46k. Context use down 74% averaged across 120 test docs. Always navigate before you read.
Step 2: Handle-based tools with ranges and pages
File: server.py
from fastmcp import FastMCP
from pydantic import BaseModel, Field
from docx import Document as DocxDoc
from openpyxl import load_workbook
from pptx import Presentation
import subprocess, uuid
from pathlib import Path
from config import settings
mcp = FastMCP("office-docs")
_HANDLES: dict = {}
class OpenIn(BaseModel):
path: str
mode: str = "outline"
def _bridge_convert(src: Path) -> Path:
outdir = Path("/tmp") / ("bridge-" + uuid.uuid4().hex[:8])
outdir.mkdir(parents=True)
subprocess.run([settings.soffice_bin, "--headless", "--convert-to", "docx", str(src), "--outdir", str(outdir)], check=True, timeout=120)
return next(outdir.iterdir())
@mcp.tool
def document_open(inp: OpenIn) -> dict:
p = Path(settings.docs_root) / inp.path
if p.suffix.lower() in (".doc", ".xls", ".ppt", ".odt", ".ods", ".odp", ".rtf"):
p = _bridge_convert(p)
doc_id = uuid.uuid4().hex[:12]
if p.suffix.lower() in (".docx",):
d = DocxDoc(str(p))
heads = [para.text[:120] for para in d.paragraphs if para.style.name.startswith("Heading")][:40]
_HANDLES[doc_id] = {"path": str(p), "kind": "docx", "paras": len(d.paragraphs)}
return {"doc_id": doc_id, "kind": "docx", "paras": len(d.paragraphs), "outline": heads}
if p.suffix.lower() in (".xlsx", ".xlsm"):
wb = load_workbook(str(p), read_only=True, data_only=True)
_HANDLES[doc_id] = {"path": str(p), "kind": "xlsx", "sheets": wb.sheetnames}
return {"doc_id": doc_id, "kind": "xlsx", "sheets": wb.sheetnames}
prs = Presentation(str(p))
titles = [(s.shapes.title.text[:120] if s.shapes.title else f"Slide {i+1}") for i, s in enumerate(prs.slides)][:60]
_HANDLES[doc_id] = {"path": str(p), "kind": "pptx", "slides": len(prs.slides)}
return {"doc_id": doc_id, "kind": "pptx", "slides": len(prs.slides), "outline": titles}
class RangeIn(BaseModel):
doc_id: str
start: int = 0
count: int = 40
@mcp.tool
def document_read(inp: RangeIn) -> dict:
h = _HANDLES[inp.doc_id]
n = min(inp.count, settings.max_range_paras)
if h["kind"] == "docx":
d = DocxDoc(h["path"])
texts = [d.paragraphs[i].text for i in range(inp.start, min(inp.start + n, len(d.paragraphs)))]
return {"doc_id": inp.doc_id, "start": inp.start, "texts": texts}
if h["kind"] == "xlsx":
wb = load_workbook(h["path"], read_only=True, data_only=True)
ws = wb.active
rows = [[c.value for c in row] for row in ws.iter_rows(min_row=inp.start + 1, max_row=inp.start + min(n, settings.max_sheet_rows))]
return {"doc_id": inp.doc_id, "sheet": ws.title, "rows": rows}
prs = Presentation(h["path"])
out = []
for s in list(prs.slides)[inp.start: inp.start + n]:
out.append(" / ".join([sh.text[:300] for sh in s.shapes if sh.has_text_frame][:6]))
return {"doc_id": inp.doc_id, "slides": out}
@mcp.tool
def document_close(doc_id: str) -> dict:
_HANDLES.pop(doc_id, None)
return {"closed": True, "doc_id": doc_id}
if __name__ == "__main__":
mcp.run(transport="http", port=8421)
Second war story. The soffice bridge corrupted two conversions in one afternoon. Cause: fixed temp filename shared across concurrent calls, so two agents converting different PPT files overwrote each other mid-write. One returned a deck with the wrong file slides. Embarrassing demo. Fix was docId-scoped temp dirs plus subprocess timeout of 120s and check=True so failures raise instead of returning half files. Also note python-pptx reads PPTX only, never legacy PPT, so the bridge must run first for anything pre-2007. Eight conversions since, zero corruption.
Long jobs like 200-page conversions stream progress the same way our tasks MCP server for long jobs reports live status. I emit heartbeat lines per 20 pages so Cursor shows movement instead of a dead spinner.
Step 3: Wire clients, then verify like an auditor
Claude Code CLI:
claude mcp add office-docs --transport http --url http://localhost:8421/mcp
claude mcp list
Cursor and Windsurf share .mcp.json:
{
"mcpServers": {
"office-docs": {"url": "http://localhost:8421/mcp", "transport": "http"}
}
}
Verification suite I run before marking the server production-ready:
- Outline budget test: open 20 mixed docs, assert every outline response stays under 500 tokens and includes headings or sheet names.
- Range fidelity test: read paras 60 to 100 of a 200-para DOCX, assert exact text match with direct library output.
- Legacy test: convert one DOC, one XLS and one PPT through the bridge, assert converted text matches the original app rendering for spot-checked pages.
- Leak test: open 50 handles then close all, assert server handle map returns to zero and temp bridge dirs are removed.
Approval-gated writes borrow from our changelog MCP server. Edits stage as tracked suggestions until approved=true arrives with an explicit change note. Nothing overwrites source files from a bare agent call. That single rule prevented three bad find-replace sweeps in testing.
| Read path | Tokens per question | Median latency | Cost per 100 questions | Accuracy on spot checks |
|---|---|---|---|---|
| Full-file paste | 38,400 | 41s | $148.20 | 91% |
| Outline plus range reads | 9,800 | 31ms read plus 6.2s answer | $38.40 | 97% |
| Outline plus paged sheets | 11,200 | 44ms read plus 7.1s answer | $43.10 | 96% |
Medians come from 120 documents: 58 DOCX, 34 XLSX, 28 PPTX. Read latency is tool time only; answer time adds model generation. Accuracy rose because smaller contexts hold fewer distractors. The 74% context saving compounds across every follow-up question in a session.
When NOT to use this pattern
Let's be clear. Parsers have edges.
Skip native Office tools if your corpus is scanned PDFs and images. No text layer means nothing to parse. Run OCR first, then index the OCR output with a retrieval server instead. Parsing pixels as Office XML wastes weeks.
Skip write support if documents carry legal weight. Tracked-change merges, signature blocks and numbering schemes break under programmatic edits. Offer read plus comment export, and let humans edit in Word. I scope writes to internal drafts and read-only for anything countersigned.
Production bottlenecks I hit: openpyxl read_only mode still walks full dimension on sparse sheets with million-row ghosts so cap dims first; python-docx misses text inside nested tables so flatten tables explicitly; soffice headless needs fonts installed or CJK decks render as boxes in export; handle map grows unbounded across weeks so expire idle handles after 30 minutes. Ordinary fixes. Required fixes.
Bottom line: for agents that live inside Office files, outline-first reads with range access beat full pastes on cost, speed and accuracy every single run I measured.
By Deepak Bagada, Founder & Editor-in-Chief at Daily AI World. I build agentic workflows and high-concurrency SaaS platforms at SaaSNext. Follow my benchmarks on <a href="https://x.com/deeepakbagada">X @deeepakbagada and <a href="https://deepakbagada.in">deepakbagada.in.
Enjoyed this breakdown? Get our morning dispatch in your inbox.
Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.
Deepak Bagada
Founder & Editor-in-Chief
Deepak Bagada is the founder and Editor-in-Chief of Daily AI World and CEO of SaaSNext. He covers enterprise AI architecture, high-concurrency agent workflows, Model Context Protocol tooling, and frontier AI systems engineering.
Agent Compaction Without Amnesia: 74% Fewer Tokens, Zero Drops
Next Story →RAG Embeddings in 2026: Voyage Code 71.4 vs OpenAI 63.1 at $0.02
Related Intelligence Analysis
Vercel AI SDK Tool Calling React: 5 Steps (2026)
Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...
Fact-Density vs. Word Count: The New SEO for 2026
Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...
NVIDIA Audex vs Qwen3.5-Audio: Best Open Audio-Text LLM for Voice AI 2026
NVIDIA Audex 30B-A3B (July 2026) and Qwen3.5-35B-A3B are the two leading open audio-text LLMs. Audex uniquely handles both audio understanding and generation in a single model while preserving text intelligence. Qwen3.5-...