PDFiTT is an open-source PDF MCP for rendering Markdown into polished PDF documents and converting PDFs back to Markdown. It ships as a browser editor, direct HTTP APIs, a remote Streamable HTTP MCP server, and a portable local agent skill.
Use the hosted instance, fork it, or self-host your own branded Markdown↔PDF tool.
Deployed endpoint:
https://pdf-i-tt.vercel.app/mcp
LLM entry point:
https://pdf-i-tt.vercel.app/llms.txt
- Open-source Markdown↔PDF conversion under the MIT License.
- Remote MCP tools for agents and MCP clients.
- Direct Vercel Function APIs for apps and scripts.
- Browser editor with live preview, PDF download, PDF upload, optional Clerk save,
/sign-in,/dashboard,/account, and a Sign / Autofill panel. - Portable skill at
skills/pdfitt-markdown-pdf. - Shared Markdown→PDF renderer across browser, API, and MCP workflows.
- Classify-then-route PDF→Markdown (text, image/scan, form, multi-column, hybrid).
- GitHub Flavored Markdown parsing with
marked. - PDF generation with
pdfmake. - Support for headings, lists, task lists, blockquotes, code blocks, inline code, links, tables, horizontal rules, and image placeholders.
- Safer export behavior for raw HTML and script content.
- Filename derivation from the first H1 heading.
- 250,000-character Markdown input limit.
- Editor/in-browser convert accepts up to 300,000,000 bytes and 500 pages. Node/API/MCP stay at 8,000,000 bytes (40,000,000 for
source_urlfetch) and the first 15 pages. Hosted POST bodies (file/pdf_base64) still 413 around 4.5MB on Vercel Hobby — drop the file in the editor or usesource_url. - Image-only pages OCR with tesseract.js WASM after extracting embedded page images. Hosted
POST /api/markdown ocr:autoon a tiny scan (golden-tiny) is HTTP 200 in about 2s (in-process WASM from #27) — Hobby OCR is not dead. Text-layer pages are never rasterized, even when a full-bleed cover image sits underneath. Empty or hung OCR on one page (8s extract/raster, 45s page including OCR) falls back to that page's text layer or skips the page; later text pages still convert. The editor dropzone showsConverting page N of M…and yields between pages so a 370-page book can finish in this browser.
The full system documentation is available in both source and rendered form:
llms.txtAGENTS.mdSELF_HOSTING.mdCHANGELOG.mddocs/PDFiTT-System-Documentation.mddocs/PDFiTT-System-Documentation.pdf
If you are pointing an LLM at this repository, start with llms.txt.
npm install
npm run devBuild for production:
npm run buildQuality checks:
npm run lint
npm test
npm run build
npm auditcurl -X POST "https://pdf-i-tt.vercel.app/api/pdf" \
-H "Content-Type: application/json" \
--output document.pdf \
--data '{"markdown":"# Hello\n\nRendered by PDFiTT."}'Request JSON accepts one of:
markdownmarkdown_base64source_url
Optional:
filename— used as the download name. A trailing.pdfis kept as a single extension (hello.pdfstayshello.pdf;hellobecomeshello.pdf). Inner dots are preserved (my.invoice.v2.pdf).style— optional pdfmake overlay applied after the frozen default. Convert never looks up the user. omit/null/non-object/content/unknown keys leave LETTER / Roboto 11 / current margins unchanged.
Example overlay:
{
"pageMargins": [72, 72, 72, 72],
"defaultStyle": { "fontSize": 12 },
"styles": { "h1": { "color": "#1d4ed8" } },
"header": { "text": "Acme Legal", "alignment": "center", "fontSize": 9 },
"footer": { "left": "{{title}}", "right": "Page {{page}} of {{pages}}" }
}curl -X POST "https://pdf-i-tt.vercel.app/api/markdown" \
-H "Content-Type: application/json" \
-d '{"source_url":"https://www.irs.gov/pub/irs-pdf/fw9.pdf"}'curl -X POST "https://pdf-i-tt.vercel.app/api/markdown" \
-F file=@scan.pdfThe same route accepts JSON or multipart. Provide exactly one PDF source:
pdf_base64(JSON)source_url— publichttporhttpsonly.file:and local paths are rejected.file— multipart field used byPdfImport(curl -F file=@scan.pdf).
Optional ocr, document_type, and filename are accepted on the JSON body and as multipart form fields:
ocr—auto(default),always, ornever.alwaysstill OCRs image pages only; text-layer pages are never rasterized. Image-only PDFs withocr: "never"return an error, not empty Markdown.document_type—auto(default) or a forced path:text,image,hybrid,form,multi-column.filename— requested.mdname.
Successful JSON always includes markdown, page_count, ocr_used, warnings, filename, and document_class (plus page_classes and ocr_confidence). The editor dropzone converts local files in the browser and is never answered with 404 or 415. Hosted file / pdf_base64 POST bodies still cap at about 4.5MB.
W-9 and similar forms keep readable instructions and labeled fields. Expect form-fields-approximate after widgets are read; checkboxes become - [ ] / - [x]. Image-only scans OCR embedded page images. Low-confidence OCR adds low-ocr-confidence instead of failing with HTTP 500.
GET / POST / DELETE /api/documents require a Clerk session. Convert stays public.
- No
CLERK_SECRET_KEY: HTTP 501. Convert still works. - No session: HTTP 401. Never HTTP 402.
- After login the editor hydrates
users.styleThemeinto the download overlay. Free plan keepsstyleThemenull. Convert never looks up the user.
When VITE_CLERK_PUBLISHABLE_KEY is set, the header Sign in control goes to /sign-in. /sign-up, /dashboard, and /account are also first-class pages (afterSignInUrl /dashboard, afterSignOutUrl /). /dashboard lists saved documents (title, updated time, kind) with Open and Delete. The convert editor on / stays usable logged out.
POST /api/docusign/autofill is public. Send { "markdown": "..." } and get { tabs, warnings }. The editor Autofill sign fields button renders that tab list (label, type, value or empty + reason). It does not call live DocuSign and never invents a signed PDF.
POST /api/docusign/envelopes creates a real envelope through the existing DocuSign library. Clerk session required (401 without a token, 501 without Clerk). Missing DOCUSIGN_* is HTTP 501 with the existing not-configured message — never HTTP 200 with a fake envelope. The editor Send for signature button stays disabled with that 501 message when DocuSign is not configured.
Connect any MCP client that supports remote Streamable HTTP servers to:
https://pdf-i-tt.vercel.app/mcp
The MCP server exposes:
render_markdown_pdf
convert_pdf_to_markdown
get_setup_instructions
docusign_create_envelope
docusign_send_envelope
docusign_get_envelope
docusign_download_documents
docusign_void_envelope
render_markdown_pdf returns JSON containing filename, mimeType, byteLength, and base64 PDF bytes. Optional style is the same overlay as POST /api/pdf. Convert tools do not require a Clerk session and never return HTTP 402.
When CLERK_SECRET_KEY is set, /mcp also registers save_document, list_documents, get_document, and delete_document. Those require a Clerk bearer token. No session is HTTP 401, never 402. Without Clerk, save is HTTP 501 and convert stays public.
convert_pdf_to_markdown accepts exactly one of pdf_base64 or a public source_url (XOR; empty both or both set is JSON-RPC -32602). Multipart file is HTTP POST /api/markdown only, not this tool. Optional ocr is auto | always | never. Optional document_type is auto | text | image | hybrid | form | multi-column. It classifies the PDF and returns Markdown plus document_class, page_classes, ocr_used, warnings, ocr_confidence, and a .md filename. PdfConvertError / bad input returns HTTP 400 { code: -32602, message: "..." } with the request id and never leaks a TypeError.
get_setup_instructions returns generic MCP, Claude Code, OpenCode, Codex, local skill, script, and direct API setup instructions. A normal browser or curl GET to /mcp returns the same setup payload.
DocuSign fill-and-sign v1 creates a real DocuSign envelope from a filled unsigned PDF. It is not a PNG stamp. Tabs are placed with anchorString only. dateSigned uses the DocuSign clock. docusign_send_envelope refuses envelopes that have no signHere tab. docusign_download_documents works only when status is completed. Name / SSN-adjacent / FTC-sensitive tabs stay required and unlocked unless the sender explicitly locks them. A tab value that does not appear in convert markdown is left empty with reason: value_mismatch. Missing DOCUSIGN_INTEGRATION_KEY, DOCUSIGN_USER_ID, DOCUSIGN_ACCOUNT_ID, or DOCUSIGN_RSA_KEY returns HTTP 503 — envelopes are not faked.
curl https://pdf-i-tt.vercel.app/mcpClaude Code remote HTTP setup:
claude mcp add --transport http pdfitt https://pdf-i-tt.vercel.app/mcpCodex remote MCP setup:
codex mcp add pdfitt --url https://pdf-i-tt.vercel.app/mcpOpenCode remote MCP setup:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"pdfitt": {
"type": "remote",
"url": "https://pdf-i-tt.vercel.app/mcp",
"enabled": true
}
}
}Merge that block into ~/.config/opencode/opencode.json for global use or opencode.json in a project.
PDFiTT is designed so other developers and teams can host their own PDF generation MCP.
- Fork this repository.
- Deploy it to Vercel.
- Set
PDFITT_PUBLIC_URLto your deployed origin, for examplehttps://your-pdf-tool.vercel.app. - Optionally set
PDFITT_ALLOWED_ORIGINSto restrict browser/API callers. - Connect MCP clients to
https://your-pdf-tool.vercel.app/mcp. - Use the direct APIs at
https://your-pdf-tool.vercel.app/api/pdfandhttps://your-pdf-tool.vercel.app/api/markdown.
For local development:
npm install
npm run devFor a production check:
npm run lint
npm run buildSee SELF_HOSTING.md for a fuller deployment checklist and customization notes.
After deployment, replace every https://pdf-i-tt.vercel.app example with your own origin.
The portable skill lives at:
skills/pdfitt-markdown-pdf
Codex local skill setup:
mkdir -p "${CODEX_HOME:-$HOME/.codex}/skills"
cp -R skills/pdfitt-markdown-pdf "${CODEX_HOME:-$HOME/.codex}/skills/"Use it from Codex, OpenCode, or any agent runtime that supports local skills by copying that folder into the relevant skills directory. The included agents/openai.yaml and agents/opencode.json templates point at the hosted MCP endpoint.
Scripted render example:
node skills/pdfitt-markdown-pdf/scripts/render_pdf_via_mcp.mjs \
--mcp-url https://pdf-i-tt.vercel.app/mcp \
--markdown-file ./notes.md \
--out ./notes.pdfPDF to Markdown:
node skills/pdfitt-markdown-pdf/convert_pdf_via_mcp.mjs \
--mcp-url https://pdf-i-tt.vercel.app/mcp \
--pdf-file ./scan.pdf \
--out ./scan.mdvercel.json rewrites /mcp to /api/mcp, then /sign-in, /sign-up, /dashboard, and /account (including nested Clerk paths) to /index.html so those client routes are not HTTP 404. Function maxDuration / memory / OCR includeFiles stay owner-locked (api/markdown.js and api/mcp.js 60s / 1024MB; api/pdf.js 30s, no includeFiles). OCR uses tesseract.js WASM and pdf.js image extraction only — no native canvas.
Relevant environment variables:
PDFITT_PUBLIC_URL: base URL used in generated setup instructions.PDFITT_ALLOWED_ORIGINS: comma-separated allowed origins for browser calls to/api/pdf,/api/markdown, and/mcp. When unset, same-host and localhost origins are allowed. No-Origin requests are always allowed. MCP clients that sendMCP-Protocol-VersionorMcp-Methodare not blocked by a foreign Origin.PDFITT_MCP_URL: local skill script fallback for the MCP endpoint URL.PDFITT_TESS_CACHE: writable cache directory for tesseract.js language data (default/tmp/pdfitt-tess).DOCUSIGN_INTEGRATION_KEY,DOCUSIGN_USER_ID,DOCUSIGN_ACCOUNT_ID,DOCUSIGN_RSA_KEY: JWT impersonation for the five DocuSign MCP tools. OptionalDOCUSIGN_AUTH_SERVER(defaulthttps://account-d.docusign.com) andDOCUSIGN_BASE_URI. Do not commit secrets. Without these, the DocuSign tools return HTTP 503.VITE_CLERK_PUBLISHABLE_KEY/CLERK_SECRET_KEY: optional Clerk accounts. Missing keys keep the editor and convert APIs public. Save is/api/documents(401 without a session, 501 without Clerk).CONVEX_URL: optional Convex store forusers(planfree|premium,styleThemenull on free),documents, andimports. Without it, signed-in saves use compact Clerk publicMetadata.
MIT License. See LICENSE.