{
  "markdown": "<div align=\"center\">\n\n<img src=\"https://raw.githubusercontent.com/TrendTweekers/mintpdf/main/public/mark.svg\" width=\"64\" alt=\"MintPDF\">\n\n# MintPDF\n\n**HTML &amp; Markdown → PDF, as a REST API and an MCP server.**\n\nNo template editor. No template IDs. No dashboard. No signup to try.\n\n**[mintpdf.dev](https://mintpdf.dev)**\n\n[Try it](#quickstart) · [MCP setup](#use-it-from-claude-or-any-mcp-client) · [API](#api) · [Self-host](#self-host) · [Security](#security) · [Limitations](#limitations)\n\n</div>\n\n---\n\nSend HTML or Markdown, get a PDF back. It is Chromium under the hood, with the print CSS already\nworked out so tables don't split across pages, table headers repeat, and Markdown comes out looking\nlike a document rather than a text file.\n\n- **Pagination is the point.** `break-inside`, repeating `thead`, orphans and widows, and headers\n  and footers that actually inherit your styling. See [How it works](#how-it-works).\n- **MCP native** — `generate_pdf` and `pdf_from_url` over streamable HTTP, so an agent can produce a\n  document mid-conversation.\n- **Documents aren't kept** — rendered files are deleted after an hour, and their contents are\n  never logged. Keys, emails and usage counters obviously are stored. See [Security](#security).\n- **Run it yourself** — MIT, with a published image. The hosted service exists so you don't have to\n  operate Chromium, not because the renderer is secret.\n\n## Quickstart\n\nNo signup, no key:\n\n```bash\ncurl -X POST https://mintpdf.dev/v1/pdf \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"markdown\":\"# Invoice #42\\n\\n| Item | Price |\\n|---|---|\\n| Widget | $9.00 |\",\"pageNumbers\":true}' \\\n  --output invoice.pdf\n```\n\nWant more than 10 renders a day? A free key (email only, no card) raises it to 100 a month:\n\n```bash\ncurl -X POST https://mintpdf.dev/v1/keys \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\":\"you@example.com\"}'\n# → {\"key\":\"pm_…\",\"daily_limit\":100}   # 100 renders per month\n```\n\nThen send `Authorization: Bearer pm_…` with your requests.\n\n## Use it from Claude (or any MCP client)\n\n```json\n{\n  \"mcpServers\": {\n    \"mintpdf\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"mintpdf-mcp\"]\n    }\n  }\n}\n```\n\nPrefer the hosted endpoint directly? Use `mcp-remote` instead:\n\n```json\n{\n  \"mcpServers\": {\n    \"mintpdf\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"mcp-remote\", \"https://mintpdf.dev/mcp\"]\n    }\n  }\n}\n```\n\nRestart your client, then just ask:\n\n> \"Summarise this thread as a one-page brief with page numbers and give me a PDF.\"\n\n| Tool | Input | Returns |\n|---|---|---|\n| `generate_pdf` | `html` **or** `markdown`, plus options | download URL, valid 1 hour |\n| `pdf_from_url` | `url` (public http/https), plus options | download URL, valid 1 hour |\n\n## API\n\n### `POST /v1/pdf`\n\nBody takes **exactly one** source, plus options:\n\n| Field | Type | Notes |\n|---|---|---|\n| `html` | string | Full document or fragment |\n| `markdown` | string | Rendered with the default stylesheet |\n| `url` | string | Public page to render. Private/internal addresses are blocked |\n| `format` | string | `A4` (default), `Letter`, `Legal`, `A3`, `A5` |\n| `landscape` | boolean | default `false` |\n| `margin` | string | all sides, e.g. `\"18mm\"` |\n| `headerText` / `footerText` | string | small text on every page |\n| `pageNumbers` | boolean | adds `3 / 7` to the footer |\n| `output` | `\"pdf\"` \\| `\"url\"` | default returns PDF bytes; `\"url\"` returns JSON with a link |\n\n### `POST /v1/keys`\n\n`{\"email\":\"you@example.com\"}` → a free key. No card, no verification loop.\n\n### `POST /mcp`\n\nMCP streamable-HTTP endpoint, stateless. Same capabilities as the REST API.\n\n### Postman\n\nA ready-made collection covering every endpoint and option lives in\n[`postman/`](postman/mintpdf.postman_collection.json). Import it by link:\n\n```\nhttps://raw.githubusercontent.com/TrendTweekers/mintpdf/main/postman/mintpdf.postman_collection.json\n```\n\nThe first request runs with no key at all, and fetching a free key stores it into the collection\nvariable automatically, so the rest of the collection works straight after.\n\n## Limits\n\n| Tier | Limit | Price |\n|---|---|---|\n| Anonymous | 10 renders/day per IP | free, no signup |\n| Free key | 100 renders/month | free, email only |\n| Solo | 3,000 renders/month | $19/month |\n| Team | 12,000 renders/month | $49/month |\n| Scale | 50,000 renders/month | $129/month |\n\n## Self-host\n\nMintPDF is MIT-licensed; run your own if you'd rather.\n\n```bash\nnpm install\nnpm run build\nnpm start                # http://localhost:3000\nnode dist/smoke.js       # end-to-end render check\n```\n\nOr pull the published image, which has Chromium and the fonts baked in:\n\n```bash\ndocker run -p 3000:3000 \\\n  -e BASE_URL=http://localhost:3000 \\\n  -e DATA_DIR=/data -v mintpdf-data:/data \\\n  ghcr.io/trendtweekers/mintpdf:latest\n```\n\nThen it is the same API on your own machine, with no limits and nothing leaving it:\n\n```bash\ncurl -X POST http://localhost:3000/v1/pdf \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"markdown\":\"# Local\",\"pageNumbers\":true}' --output local.pdf\n```\n\nImages are built and published by CI on every change, and each is smoke-tested by starting the\ncontainer and rendering a real PDF from it before being tagged. Tags are `latest` and the short\ncommit SHA. Building it yourself works too:\n\n```bash\ndocker build -t mintpdf .\ndocker run -p 3000:3000 -e BASE_URL=http://localhost:3000 mintpdf\n```\n\nEnvironment: `BASE_URL` (used in download links), `DATA_DIR` (defaults to `/tmp/mintpdf`; mount a\nvolume to persist keys), `ANON_DAILY_LIMIT`, `FREE_MONTHLY_LIMIT`, `SOLO_MONTHLY_LIMIT`,\n`TEAM_MONTHLY_LIMIT`, `SCALE_MONTHLY_LIMIT`, `OVERAGE_FACTOR`, `RENDER_CONCURRENCY`,\n`RENDER_QUEUE`, `RENDER_QUEUE_WAIT_MS`.\n\n### Load and admission control\n\nEvery render is a Chromium tab, so **memory bounds concurrency long before CPU or cost does**.\nUnbounded, a traffic spike opens a tab per request until the OOM reaper kills the container and\nevery request fails, including ones nearly finished. Measured here: 30 concurrent renders with no\ngate left 30 orphaned Chrome processes and an unusable machine.\n\n`RENDER_CONCURRENCY` renders run at once, `RENDER_QUEUE` more may wait, and anything beyond that is\nrefused immediately with **503 and a `Retry-After`** rather than being allowed to pile up. Turning\nsome callers away in under a second is strictly better than serving everyone a timeout.\n\nCode defaults are conservative (3 and 20). Measured on one small Railway instance at 10 and 70:\n\n| Burst | Served | Refused | Median | Wall clock | Instance after |\n|---|---|---|---|---|---|\n| 45 | 45 | 0 | 1.5s | 2.3s | healthy |\n| 120 | 81 | 39 | 3.0s | 4.0s | healthy |\n| 250 | 80 | 170 | 3.4s | 4.4s | healthy, 0.24s homepage |\n\nRoughly 18 renders a second sustained, with the refused share answered in under 2.7s. Raise the\nnumbers only against a measurement on your own instance size, never on hope.\n\nIf you want a fuller self-hosted PDF toolchain (Office formats, merging, splitting),\n[Gotenberg](https://gotenberg.dev) is excellent and does more than this does.\n\n## Security\n\nThis service renders HTML and URLs supplied by anyone, so the interesting questions are about what\nthat content can reach.\n\n**Submitted HTML executes JavaScript.** It has to: Mermaid diagrams and KaTeX maths are rendered in\nthe page. Treat the renderer as running untrusted code, which is why the network restrictions below\nmatter more than they would for a static converter.\n\n**SSRF is blocked at two layers.**\n\n1. A submitted `url` is parsed, restricted to `http`/`https`, and resolved. If *any* resolved address\n   is private, the request is refused with a 400 before a browser is involved.\n2. Independently, every request Chromium makes is intercepted and the destination **resolved again at\n   request time**, then blocked if private. This covers embedded images, stylesheets, fonts,\n   redirects and `fetch()` from submitted JavaScript, not just the URL you asked for.\n\nThe second layer resolves rather than trusting the hostname, and **caches only refusals, never\napprovals**: caching \"this host is public\" would reopen the exact hole the check exists to close.\nUnresolvable names fail closed.\n\nBeing precise about what that does and does not achieve: a DNS-rebinding attempt can no longer wait\nout a cached approval, so it has to win a race between this lookup and Chromium's own, on every\nrequest. That is a much narrower target than a fixed window, but it is a narrowed race rather than a\nclosed door. Eliminating it entirely means pinning the resolved address at the socket layer, which\nis not implemented.\n\nBlocked: loopback, `0.0.0.0`, RFC1918, CGNAT (100.64/10), link-local and cloud metadata\n(169.254.169.254); IPv6 loopback, unspecified, link-local, site-local, unique-local, multicast,\nNAT64 and Teredo; **IPv4-mapped and IPv4-compatible forms in either spelling**, so `::ffff:10.0.0.1`\nand `::ffff:a00:1` are the same address and both are refused; `localhost`/`.local`/`.internal`\nnames; any public hostname that resolves to a private address; and every scheme except http, https,\ndata and blob.\n\nAddresses are judged from their bytes rather than by matching text, because the same address has\nmany spellings and a text match catches one and misses the rest.\n\nThere is a test suite for exactly this, and it is meant to be run rather than trusted:\n\n```bash\nBASE=https://mintpdf.dev node scratchpad/ssrf_suite.mjs\n```\n\nIt checks the bypasses above *and* that ordinary rendering still works, because a guard that also\nblocks web fonts is a different bug rather than a fix.\n\nThe IPv6 parser has its own table of adversarial literals, since an invalid string silently becoming\na valid address is the failure that matters in this kind of code:\n\n```bash\nnode scratchpad/ipv6_table_test.mjs\n```\n\n**Download links** use a `crypto.randomUUID()` identifier and are **not authenticated**: anyone with\nthe link can fetch the file for the hour it exists. That is deliberate, so a link can be emailed or\nhanded to a browser, but it means the link is the secret.\n\n**Logging.** Request metadata is logged (method, path, status, duration). **Request bodies are never\nlogged**, so the HTML and Markdown you send are not written anywhere except the temporary file. The\nanalytics table stores event kind, path, referrer, country and a daily-salted hash of the IP. No\ndocument content, and no way to reconstruct a document from it.\n\n## Limitations\n\nWorth knowing before you build on it.\n\n- **Files are deleted after one hour.** There is no document library and no way to fetch a render\n  again later. Generate, use, done. If you need permanence, save the bytes on your side.\n- **No Office formats, merging or splitting.** This converts HTML, Markdown and web pages, and\n  nothing else. [Gotenberg](https://gotenberg.dev) is more mature and covers far more ground if you\n  are self-hosting and need that.\n- **One instance.** Keys and quotas live in SQLite on a mounted volume, so running several replicas\n  against one volume will not work. Horizontal scaling needs a real database first.\n- **Renders are admission-controlled.** Over capacity the API returns `503` with `Retry-After`\n  rather than queueing without limit. See the table above for measured behaviour.\n- **Two days old at the time of writing**, with no paying users yet.\n\n## How it works\n\nTypeScript, Fastify, and Puppeteer driving one shared Chromium with a page per request. `node:sqlite`\nholds keys, quotas and events, so there are no native dependencies to build.\n\nThe parts that took the actual work are the unglamorous ones:\n\n- **Print CSS.** `break-inside: avoid` on tables, rows, list items, code blocks, blockquotes and\n  figures; `thead { display: table-header-group }` so headers repeat; `orphans`/`widows`;\n  `break-after: avoid` on headings so none is stranded at the foot of a page.\n- **Header and footer templates**, which are a separate document from your page: they ignore the page\n  CSS and render at near-zero font size unless the styles are inlined, and they sit outside the\n  content margins.\n- **Admission control**, because one Chromium tab per concurrent request is how the container runs\n  out of memory.\n- **Network isolation** for a renderer that executes untrusted JavaScript. See Security.\n\n## Licence\n\nMIT — see [LICENSE](LICENSE).\n",
  "bytes": 12278,
  "sha": "11c3479af22fa06efd3b99dc0ea0c9c2eb051fb3c4af1af1449c375a2e08d162",
  "repo_slug": "trendtweekers/mintpdf",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_trendtweekers_mintpdf_edacfb80/readme"
}