An MCP server that parses office documents and writes them.
| Tool | Takes | Returns |
|---|---|---|
read_document(content, filename?) |
DOCX, PPTX, XLSX, HWP, HWPX, ODT/ODS/ODP, RTF — bytes as base64 | the text, as Markdown that keeps the document's shape |
inspect_document(content, filename?, from?, to?) |
the same formats except XLSX | the structure, one line per block — spans, alignment, levels, pictures |
inspect_spreadsheet(content, filename?, mode?, includeHidden?) |
XLSX bytes as base64 | addressed cached values and formulas, without executing them |
render_spreadsheet(sheets, title?, filename?) |
named rows and explicit formula cells | a new XLSX file, as an MCP resource block |
render_document(format, content, profile?, title?, filename?, assets?) |
Markdown → docx pptx pdf hwpx |
the file, as an MCP resource block |
A document is not a list of its words. A report's table says which column a
figure belongs to, a contract's numbering says which clause is which, and a deck
says what was a title. Reading all of that out as one line after another loses
the part a reader was going to use, so read_document returns Markdown that
keeps it — real tables with their alignment, lists that count, headings at their
level, links with their targets, a mark where a picture stood.
inspect_document carries what Markdown has no syntax for: which cells a merge
covers, which row the document itself called a header, where a shape sat on its
slide.
It exists because a document is not its text, and a report is not a file. An
agent handed a .hwp or a .docx cannot open it — the format is a container it
has no way through. And an agent that has written a report has nothing to hand
to a person: text in a chat window is not a document somebody can file, print or
send on. This closes both gaps, and only those.
What it deliberately no longer does: fetch, store, or read a PDF. All three
left in the same change, and for one reason — each was a second copy of
something the caller already had. The outbound boundary here (an SSRF guard, a
pinned-DNS fetch, a redirect policy) was byte-for-byte the sibling's. The PDF
reader was byte-for-byte the caller's, around the same unpdf. And the S3
upload meant a second bucket, a second retention policy and a second AWS
credential for bytes the caller was already storing everything else in.
So this is the parser and renderer, and only those: the formats that genuinely need one. A caller sending a PDF, a web page or a text file gets a refusal that names the format and says who reads it — silence would read as "this document is unreadable", which is a different and much more damaging claim.
| Architecture | Why the protocol uses the SDK, and why a rendered file comes back as bytes |
| Reading | What the readers take, how a format is decided, and what extraction omits |
| Writing | The Markdown render_document understands, and the images it embeds |
| Spreadsheets | Formula-safe XLSX inspection and new-workbook creation |
| The design system | The palette, the type scale and the language every renderer reads from theme.ts |
| The document engine | How docx, pdf and hwpx become a report — and the Korean font inside the PDF |
| The presentation engine | How pptx becomes a planned deck |
| Safety | The threat model, the limits, and the two authentication modes |
| Operations | Configuration, the endpoints, the image, and registering it with a client |
| Development | The commands, the release, and what the tests do and do not cover |
npm install # Node >= 24
npm run dev # tsx, no build step
npm test # node --test, no test framework
MCP_API_KEY=<secret> node dist/server.js # after npm run buildPORT (default 3000) and MCP_API_KEY are the only two settings, and with the
key unset the server answers anyone that can reach it —
Safety says what that mode
assumes. Everything else about deploying it is in
Operations.