"markitdown MCP reference — local docs-to-Markdown (PDF, DOCX, PPTX, XLSX, HTML, EPUB): config, decision tree, fallbacks."
Install
npx skillscat add darellchua2/civiltekk-skills/markitdown-mcp-skill Install via the SkillsCat registry.
What this skill does
- Documents the
markitdownMCP server and its single toolconvert_to_markdown - Provides
opencode.jsonconfiguration (themcp.serversentry andpermissionsrule) - Prescribes a decision tree for choosing markitdown vs
image-analyzer-subagentvspdf-specialist-skillvspdftotextvs built-inRead - Covers usage patterns (large docs, batch conversion, table post-processing)
- Documents privacy guarantees for company-internal document handling
- Provides fallback strategies when the MCP is unavailable
Reference: Upstream microsoft/markitdown · markitdown-mcp on PyPI
Requirements & Honesty Note
| Requirement | Status |
|---|---|
markitdown MCP server in opencode.json mcp.servers block |
Required for MCP tool access |
markitdown-mcp binary on PATH |
Installed via ./deploy/setup.sh (PyPI, pinned) or baked into Docker |
mcp.servers.markitdown.disabled: false in opencode.json |
Ships disabled: true — user must opt in (#262) |
permissions rule { "action": "markitdown*", "resource": "*", "effect": "allow" } |
No rule by default — user must opt in (#262) |
If any requirement is unmet, MCP tool calls return connection errors. Fall back to pdftotext, image-analyzer-subagent, or built-in Read (see Fallback Strategy below).
Privacy note (#487): markitdown is now the official markitdown-mcp from PyPI (stdio, MARKITDOWN_ENABLE_PLUGINS=false). Local document conversions (PDF/DOCX/PPTX/XLSX/MSG/...) make zero network calls, and Azure converters never activate. Residual: markitdown[all] ships audio/YouTube converters — an audio file input uploads to Google Speech and a YouTube URL contacts YouTube, so keep those input types away from this tool when zero egress is required. Opt-in (disabled: true by default per #262) remains a minimal-footprint choice.
opencode.json Configuration
The markitdown MCP server ships as opt-in (disabled: true) per #262. To enable:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"servers": {
"markitdown": {
"type": "local",
"command": ["markitdown-mcp"],
"environment": {
"MARKITDOWN_ENABLE_PLUGINS": "false"
},
"disabled": false
}
}
},
"permissions": [
{ "action": "markitdown*", "resource": "*", "effect": "allow" }
]
}Both flips are required:
mcp.servers.markitdown.disabled: false— starts the server processpermissionsrule{ "action": "markitdown*", "resource": "*", "effect": "allow" }— grants tool-calling permission. Rules live in the top-levelpermissionsarray (last matching rule wins); a legacypermissionmap key is ignored by opencode v2.
The sanctioned path does both flips and installs the launcher in one step: ./deploy/setup.sh --enable-pack markitdown (Linux/macOS) or .\deploy\setup.ps1 --enable-pack markitdown (Windows). Manual editing of the deployed config works too. Docker users get the launcher baked in at build time.
Verify: opencode mcp list shows markitdown as connected — after restarting opencode (MCP servers connect at session start; config edits need a restart).
Available MCP Tools
| Tool | Description | Use Case |
|---|---|---|
convert_to_markdown(uri) |
Convert a document at the given URI to Markdown. Accepts file:, data:, http:, https: schemes. |
Extract text from binary office docs, fetch remote URLs |
Tool name is exactly convert_to_markdown (snake_case). The skill name uses kebab-case (markitdown-mcp-skill); the MCP tool uses snake_case. Don't confuse them.
Input schema
{
"name": "convert_to_markdown",
"arguments": {
"uri": "file:///absolute/path/to/document.pdf"
}
}Returns: { "content": [{ "type": "text", "text": "<markdown>" }] }
Format Coverage
All converters below are 100% local (no network calls, verified via ss -tnp):
| Format | Local library | Output characteristic |
|---|---|---|
| pdfminer.six, pdfplumber | Page-separated text; tables best-effort | |
| DOCX | mammoth, lxml | Headings, paragraphs, lists; tables as Markdown tables |
| PPTX | python-pptx | <!-- Slide number: N --> separators per slide |
| XLSX / XLS | openpyxl, pandas, xlrd | Multi-sheet → ## Sheet N headers; cells as Markdown tables |
| Outlook MSG | olefile, extract-msg | Headers, body, attachment list |
| HTML | beautifulsoup4, markdownify | Cleaned Markdown (scripts/styles stripped) |
| CSV / JSON / XML | stdlib / pandas | Direct conversion to Markdown tables / fenced blocks |
| EPUB | zipfile + html parsing | Chapter-by-chapter |
| IPYNB | nbformat | Code cells as fenced blocks; outputs inline |
| ZIP | zipfile | Iterates contents; converts each member |
| Images | local EXIF (exiftool) | Metadata only (camera, GPS, timestamp). No LLM description. |
NOT supported (cloud/extra deps deliberately excluded — see Privacy Guarantees):
- Audio transcription (Google Speech API — excluded)
- YouTube transcripts (YouTube API — excluded)
- Azure Document Intelligence layout analysis (excluded)
- LLM-generated image descriptions (excluded —
llm_clientnever passed)
Decision Tree
Choose the right tool for the job. Read this before calling convert_to_markdown.
Need to understand a binary/office document?
│
├─ Is it PLAIN TEXT (.md, .txt, .json, .yaml, source code)?
│ └─ YES → Use built-in Read tool. markitdown adds nothing.
│
├─ Is it a BORN-DIGITAL office doc (.docx, .pptx, .xlsx, .xls, .msg)
│ or born-digital PDF (text-selectable, not scanned)?
│ └─ YES → Use markitdown.convert_to_markdown(uri).
│ Fast (~1s/50 pages), preserves text fidelity, no cloud calls.
│
├─ Did markitdown return EMPTY / GARBAGE / missing tables (complex layout,
│ multi-column, heavy formatting)?
│ └─ YES → Escalate to docling (layout-aware — see AGENTS.md routing rule
│ + docling-mcp-skill). CLI-on-demand: detect → ask consent →
│ pip install docling → docling convert. MCP: --enable-pack docling.
│
├─ Is the PDF SCANNED / image-only (no selectable text)?
│ └─ YES → pdftoppm (bash, if available) → image-analyzer-subagent.
│ markitdown will return empty/garbage for scanned PDFs.
│
├─ Do you need STRUCTURED PDF data (forms, tables as data, fillable fields,
│ OCR-as-purpose, post-edit)?
│ └─ YES → Use pdf-specialist-skill (purpose-built for structured PDF).
│ markitdown gives best-effort text dumps only.
│
├─ Do you need VISUAL UNDERSTANDING (charts, diagrams, screenshots,
│ layout, "what does this look like")?
│ └─ YES → image-analyzer-subagent. markitdown returns text only.
│
├─ Is the document at a REMOTE URL?
│ └─ TWO OPTIONS (equivalent, pick one):
│ • webfetch first → save locally → markitdown.convert_to_markdown(file://)
│ • markitdown.convert_to_markdown(https://...) directly
│ (single requests.get(), no telemetry, equivalent to webfetch)
│
└─ None of the above → Ask the user to clarify format/intent.PDF routing note: markitdown and pdf-specialist-skill overlap on .pdf. Use markitdown for fast text dumps of born-digital PDFs. Use pdf-specialist-skill for structured extraction, forms, OCR-as-purpose, or editing. When unsure, start with markitdown (cheap) and escalate to pdf-specialist-skill if the output is insufficient.
Usage Patterns
Large documents (50+ pages)
Pass the URI directly — markitdown handles streaming internally. Don't pre-split. If the result exceeds context, post-process with head/tail/grep via bash, or ask for specific page ranges.
Table fidelity
XLSX and CSV convert cleanly to Markdown tables. Complex PDF tables (merged cells, nested headers) may need re-alignment — verify before using in critical paths.
Multi-sheet XLSX
Output uses ## Sheet N headers (one per sheet). When querying for a specific sheet, grep the output for the sheet name.
Batch conversion
No batch API. Loop over URIs:
results = [call_tool("convert_to_markdown", {"uri": u}) for u in uris]Following up with visual analysis
After markitdown conversion, if the document contains charts/diagrams referenced as images, follow up with image-analyzer-subagent on those specific elements. markitdown extracts text; it does not interpret visuals.
Troubleshooting
MCP not connected / tool returns "server not found"
Three gates, all required:
markitdown-mcpbinary on PATH (--enable-pack markitdowninstalls it)mcp.servers.markitdown.disabled: falsein the deployed configpermissionsrule{ "action": "markitdown*", "resource": "*", "effect": "allow" }(last matching rule wins)
Missing any one leaves the MCP unreachable or its tools denied. Verify with opencode mcp list (should show markitdown connected) and restart opencode after config edits — there is no hot-reload.
Tool denied after upgrading from pre-#370 deploys
Earlier releases carried the opt-in denies under a nested permission.tool key, which opencode's permission engine never read — so hand-enabled servers worked despite the "deny". Under v2 the denies are permissions-array rules and enforce. If you enable markitdown/docling/next-devtools by hand, make sure no later deny rule shadows your allow (last matching rule wins) — or re-run --enable-pack <name>.
markitdown-mcp: command not found
The server binary isn't on PATH. Fix:
- Linux/macOS: run
./deploy/setup.sh(installs viapip install --userfrom PyPI); ensure~/.local/binis on PATH - Windows: run
.\deploy\setup.ps1; ensure%APPDATA%\Python\Scriptsis on PATH
Audio file / YouTube URL conversion silently contacts Google/YouTube
This is expected, not a bug — it is the documented residual of running upstream markitdown[all] (#487): an audio file input uploads to Google Speech and a YouTube URL contacts YouTube. Keep those input types away from this tool when zero egress is required; born-digital office documents remain fully local.
Conversion times out for very large file
Split the source:
- PDF: use
pdftkorpdfseparateto split into ranges, convert each - PPTX: convert slide-by-slide if needed (no native batch)
convert_to_markdown returns empty/garbage for a PDF
The PDF is likely scanned/image-only. Markitdown cannot OCR — switch to pdftoppm + image-analyzer-subagent.
Fallback Strategy (No MCP)
When markitdown MCP is disabled, unavailable, or you choose not to enable it:
| Priority | Approach | When to use |
|---|---|---|
| 1 | bash pdftotext input.pdf - (if installed) |
Born-digital PDFs, fast |
| 2 | bash pdftoppm → image-analyzer-subagent |
Scanned/image-only PDFs |
| 3 | Direct Python libs: python-docx, openpyxl, python-pptx |
If agent has bash: allow + Python + the lib |
| 4 | image-analyzer-subagent |
Universal fallback (slower, vision-based) |
| 5 | Tell user: "Please enable markitdown MCP for better results" + setup steps | When conversion quality matters and MCP is missing |
For company-internal docs, option 5 is preferred over option 4 (cheaper, preserves text fidelity, no vision-token cost).
Privacy Guarantees
This MCP is the official markitdown-mcp from PyPI (#487) — stdio-only in our config, plugins off via MARKITDOWN_ENABLE_PLUGINS=false. See the upstream repo for the full trust-boundary picture.
| Guarantee | Mechanism |
|---|---|
| Azure converters never register | They require constructor kwargs (docintelligence_endpoint etc.) — never passed by the MCP server |
| Document conversions are fully local | PDF/DOCX/PPTX/XLSX/XLS/MSG/HTML/CSV/EPUB/IPYNB converters make zero network calls |
| Plugins off | MARKITDOWN_ENABLE_PLUGINS=false in the server env (also upstream's default) |
| Residual — audio/YouTube | markitdown[all] ships an audio converter (uploads to Google Speech on audio input) and a YouTube converter (contacts YouTube on YouTube URLs) — feed only document formats when zero egress is required |
| No LLM image description (cloud) | Launcher never passes llm_client; image converter runs EXIF-only |
| No plugin converters (3rd-party) | enable_plugins=False hard-coded in constructor (env var belt-and-suspenders) |
| No telemetry / Application Insights | Confirmed absent in markitdown source; defense-in-depth via dep exclusion |
| Version drift protection | markitdown pinned >=0.1.1,<0.2.0 — bumps require explicit audit |
| Local file conversions | Zero TCP calls (verifiable via ss -tnp during conversion) |
User-supplied http:/https: URIs |
Single requests.get() — no Microsoft endpoints, no telemetry headers. Equivalent to built-in webfetch. |
Safe for company-internal documents. No data leaves the host unless the user explicitly passes an http:/https: URI (in which case the fetch is identical to what webfetch would do).