DEV Community

Cover image for I Built 12 MCP Tools for PDF Generation — Here's How Product Engineers Use Them
Arun
Arun

Posted on

I Built 12 MCP Tools for PDF Generation — Here's How Product Engineers Use Them

Product engineers spend hours writing boilerplate to generate invoices, contracts, and reports. I was one of them. So I built an API that handles the entire pipeline — upload template, fill with data, return PDF — and exposed it as MCP tools so AI agents can call it directly.

The Problem: docxtpl Breaks With Real Templates

You have a Word template for invoices. It has a table with line items. You need to fill it with data from your API.

You reach for docxtpl. You throw in some Jinja2 syntax. And... it breaks.

from docxtpl import DocxTemplate

template = DocxTemplate("invoice.docx")
context = {
    "client": "Acme Corp",
    "items": [
        {"name": "Design", "qty": 10, "rate": 150},
        {"name": "Development", "qty": 20, "rate": 175},
        {"name": "Review", "qty": 5, "rate": 120},
    ]
}
template.render(context)  # Table collapses. XML is garbage.
template.save("invoice-filled.docx")
Enter fullscreen mode Exit fullscreen mode

The table collapses. The PDF is garbage. You Google "docx to pdf python" and find 47 Stack Overflow answers that all say "just use HTML templates."

Here's why it breaks: Word's XML isn't HTML. A table row isn't a <tr> tag. It's <w:tr> wrapped in <w:tbl>, with properties, cell definitions, and spans all entangled. When docxtpl expands a {% for %} loop across rows, it doesn't understand the XML boundaries. It copies text. The row structure breaks. LibreOffice throws an error.

Most devs give up here. They either manually duplicate rows, switch to HTML templates and forget about Word, or accept that DOCX templates only work for flat documents without loops.

I refused to accept any of those options.

The Fix: Upload, Fill, Download

I built a pipeline that handles the entire lifecycle:

.docx upload → validate ZIP → extract schema → expand loops → render → convert to PDF → store
Enter fullscreen mode Exit fullscreen mode

But here's the thing: you don't need to understand any of that. You just need three API calls:

# 1. Upload your template
curl -X POST https://api.docuqueue.com/templates/upload \
  -H "Authorization: Bearer dq_..." \
  -F file=@invoice.docx
# Returns: { "template_id": "tpl_abc123", "fields": ["client", "items"] }

# 2. Fill it with data
curl -X POST https://api.docuqueue.com/templates/tpl_abc123/fill \
  -H "Authorization: Bearer dq_..." \
  -H "Content-Type: application/json" \
  -d '{
    "client": "Acme Corp",
    "items": [
      {"name": "Design", "qty": 10, "rate": 150},
      {"name": "Development", "qty": 20, "rate": 175},
      {"name": "Review", "qty": 5, "rate": 120}
    ]
  }'
# Returns: { "job_id": "job_xyz789", "status": "processing" }

# 3. Download the PDF
curl -X GET https://api.docuqueue.com/jobs/job_xyz789/pdf \
  -H "Authorization: Bearer dq_..." \
  -o invoice-filled.pdf
Enter fullscreen mode Exit fullscreen mode

That's it. The XML manipulation, the loop expansion, the LibreOffice conversion — all handled. You get a PDF.

The MCP Angle: 12 Tools for AI Agents

I exposed this same pipeline as MCP tools. Twelve total, but here are the ones product engineers actually use:

Tool What It Does When You'd Use It
upload_template Upload a .docx or .html template Once, when you have a new template
get_schema See what fields the template expects Before filling, to validate data
fill_template Fill template with data, get PDF Every time you generate a document
preview Quick preview without full render When iterating on template design
list_templates See all your uploaded templates When you have many templates
get_status Check if a job is done For async generation
download_pdf Get the finished PDF After fill completes
delete_template Remove a template When cleaning up

The workflow for an AI agent looks like this:

Agent: "Generate an invoice for Acme Corp with 3 line items"
  → upload_template(invoice.docx)
  → get_schema(template_id)
  → fill_template(template_id, {client: "Acme Corp", items: [...]})
  → download_pdf(job_id)
  → return PDF to user
Enter fullscreen mode Exit fullscreen mode

The agent doesn't need to know about XML, LibreOffice, or any pipeline internals. It just uploads a template and gets a PDF back.

Authentication: OAuth 2.0 with PKCE

Every tool call goes through OAuth 2.0 with PKCE. No API keys stored in code. Agents authenticate once, get a scoped token, and use it for all operations.

This matters because you don't want an AI agent generating arbitrary documents from arbitrary templates without guardrails. Each token is scoped to the agent's permissions. If you only want it to fill invoices, that's all it can do.

What's Under the Hood

The pipeline handles edge cases you'd hit in production:

  • ZIP validation. Users upload corrupted files, renamed .doc files, or PDFs with .docx extensions. I check the ZIP structure before touching anything.
  • Loop expansion. The custom step that makes docxtpl work. Walks the XML tree, finds loop constructs in table rows, clones them to match your data.
  • Dual storage. Recent previews go into Redis with a 1-hour TTL (instant re-preview). Permanent copies go to Cloudflare R2.
  • Credit refund on failure. If generation fails, the credit goes back atomically with an audit log. No stale balances.

Try It

If you're building something that needs to generate PDFs from templates, check out DocuQueue. There's a free tier that covers most small use cases.

The MCP tools are available if you want AI agents to interact with the pipeline directly. Upload a template, call the fill tool, get a PDF. That's the whole API.

The DOCX problem isn't solved perfectly. No one has solved it perfectly. But the row expansion trick makes it work reliably for the vast majority of real-world templates. And that's enough to build on.


Found this useful? I'm @arun on here. The code behind DocuQueue is a constant source of "why does this work this way" blog posts. More coming soon.

Top comments (0)