paperpress
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@paperpressRender this markdown as a branded PDF from stripe.com"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
paperpress
An API that detects a company's brand from its live URL — logo, primary color, font — and renders markdown into a PDF styled in that brand, in one HTTP call. Ships as a plain REST API and as an MCP server.
POST /v1/documents
{ "markdown": "# Q4 report\n...", "brandFromUrl": "stripe.com" }
→ ~1s → signed URL to a PDF in Stripe's brandAbout this project
This was built and briefly run as a real deployed service before I looked closely at the market it would need to compete in: Claude ships native PDF/PPTX/DOCX generation now, and Brandfetch already sells a Brand Context API built specifically for grounding AI agents, with real paying customers. Both halves of what this does — detect a brand, render a document — are now commodity-adjacent or already owned by a funded competitor. I'm not pursuing this as a product.
It's public as a portfolio piece / reference implementation: a working Playwright-based brand detector (CSS custom properties, CTA color sampling, scored logo-candidate extraction, WCAG contrast guard), a sync Fastify render pipeline, an SSRF-hardened URL fetcher, and an MCP server wrapping it. Read the code, fork it, run it — it's MIT licensed. It is not maintained as a product: no payment processing is wired up and the MCP package (mcp/) is not published to npm.
Related MCP server: Markitdown Universal MCP Server
How it works
A single Fastify process does three things:
Detect (
src/render/detect.ts) — navigates the target URL with a pooled Playwright/Chromium instance, readstheme-color, brand CSS custom properties, CTA button colors, header<img>candidates scored by position/size/format, and computed font stacks. Filters near-white/near-black/near-gray noise, applies a WCAG luminance guard so a too-light brand color doesn't blow out text contrast.Render (
src/render/) — turns markdown into HTML viaunified/remark/rehype(withallowDangerousHtml: false), applies one of five themes plus the detected/supplied brand kit, and prints to PDF with Playwright.Serve — PDFs go to local disk (or a mounted volume) behind HMAC-signed, time-limited URLs.
No queue, no worker process, no Redis. Renders are sync and typically 100–400ms once Chromium is warm; detection is cached 24h per host.
What's here
.
├── src/ Fastify API (single process)
│ ├── index.ts Bootstrap, route registration
│ ├── env.ts Env validation (zod)
│ ├── lib/ prisma, auth, billing, storage, email, url-fetch (SSRF guard), inline-image
│ ├── render/ markdown → HTML → PDF (themes/, detect.ts)
│ └── routes/ auth, documents, demo, account, pdf, brand-kits, admin
├── prisma/schema.prisma 5 models: User, ApiKey, Document, CreditTransaction, BrandKit
├── mcp/ MCP server (unpublished — see mcp/README.md)
├── samples/ Example output (see Examples below) + input markdown used to generate it
├── scripts/ preview.ts / detect.ts — regenerate the samples/ output locally
├── Dockerfile Single-image deploy (Playwright base)
└── railway.json Railway config (healthcheck only — start cmd is in Dockerfile)API surface (v1)
Auth
Method | Path | Auth | What it does |
POST |
| - | Request a key by email. Always 202; key is sent to the inbox. First time = new user + free credits. Subsequent calls = rotate (old keys valid 24h, then revoked). |
Render
Method | Path | Auth | What it does |
POST |
| Bearer key | Markdown → PDF. Accepts |
GET |
| signed URL | Stream the PDF |
Brand kits
Method | Path | Auth | What it does |
POST |
| Bearer key | Create/update a saved kit by name (one per user per name) |
GET |
| Bearer key | List your kits |
GET |
| Bearer key | Read one kit |
DELETE |
| Bearer key | Delete a kit |
POST |
| Bearer key | Pass a URL, get back |
POST |
| Bearer key | Up to 20 URLs in parallel through the existing Playwright pool. Per-item errors return inline. |
Account / health
Method | Path | Auth | What it does |
GET |
| Bearer key | Email, credits, list of active API keys (plaintext, so users can recover) |
GET |
| - |
|
Demo (anonymous, gated)
No auth, no credits charged. Strict per-IP rate limit (30/hour) on top of the global 60/min.
Method | Path | What it does |
POST |
|
|
Admin (read-only)
Gated by X-Admin-Token header. When ADMIN_TOKEN is unset, every /admin/* route returns 404 — no surface, no discovery.
Method | Path | What it does |
GET |
| Totals: users, active keys, docs, pages, bytes, credits, doc counts for last 24h / 7d |
GET |
| Paginated list with per-user doc and key counts |
GET |
| Paginated list with user email joined. Filter by |
GET |
| Stream any PDF without a signed URL |
Security posture
API keys: 192-bit random, stored as plaintext (so
/accountcan show them); revocation uses arevokedAttimestamp with a grace window.Signed URLs: HMAC-SHA256,
exp+sigquery params, 7-day default TTL.SSRF guard:
assertPublicUrlresolves DNS and rejects RFC1918, loopback, link-local, IPv6 ULA on the URL a caller submits. Applied tobrandKit.logoUrland/v1/brand-kits/detect. The submitted host being public doesn't guarantee every hop is — a public host can redirect to a private address — so both the Playwright navigation path (src/render/detect.ts) and the image-inlining fetch (src/lib/inline-image.ts) re-validate the address on every redirect hop before following it and again after final navigation. This narrows the window but doesn't fully eliminate it: the initial connection to a redirect target happens before the re-check can reject it, so a determined attacker can still cause a blind outbound request (no response data is returned to them) even though no page content is ever extracted or rendered from a rejected target. Full closure would need IP-pinning at the network layer.CSS injection guard: the
cssfield rejects<style>,</style>,<script>,</script>— otherwise the raw embed inside<style>${css}</style>would let an attacker break out and run JS in the Chromium pool.Markdown sanitization:
remark-rehyperuns withallowDangerousHtml: false, so<script>in markdown bodies is stripped.Limits: markdown ≤ 500KB, css ≤ 50KB, body ≤ 2MB, render ≤ 30s, rendered pages ≤
MAX_PAGES_PER_RENDER(default 200), rate ≤ 60 req/min/key.Admin endpoint: constant-time token compare; routes return 404 (not 401) when token wrong or unset.
Known limitations
Not a product, so these are disclosed rather than tracked as a backlog:
No automated test suite. Everything above was verified by hand against a running instance; there's no regression net.
No committed Prisma migrations. The container start command runs
prisma db push --skip-generate --accept-data-loss.In-memory rate limiting and detect-cache. Fine single-instance; wouldn't survive multiple replicas without moving both to something shared.
No payment processing wired up. The credit system exists in the schema and API; nothing charges a card.
The MCP package (
mcp/) is not published to npm and isn't going to be — see mcp/README.md.
Local dev
# 1. Postgres running locally on 5432
# 2. Env
cp .env.example .env
# (set SIGNING_SECRET to `openssl rand -base64 32`)
# 3. Install + migrate
npm install
npx prisma migrate dev
# 4. Run
npm run devSmoke test:
# Request a key. Response is { "sent": true } - the key arrives by email.
# In dev (RESEND_API_KEY unset) the server logs the email to stdout; grab the
# key from there.
curl -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com"}'
export PP_KEY="pp_live_..."
# Render
curl -X POST http://localhost:3000/v1/documents \
-H "Authorization: Bearer $PP_KEY" \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello\n\nWorld.","title":"Test"}'MCP
See mcp/README.md. A thin MCP client over the REST API. Not published to npm — included as reference code, not as an installable tool.
Deploy (Railway example)
The exact steps used for the real deployment this was run under — kept here as documentation, not as an invitation to run this in production.
# 1. Create project with a Postgres database
railway init --name paperpress
railway add --database postgres
# 2. Create the app service. DATABASE_URL is wired via service reference.
railway add --service paperpress \
--variables "DATABASE_URL=\${{Postgres.DATABASE_URL}}" \
--variables "SIGNING_SECRET=$(openssl rand -base64 32)" \
--variables "PUBLIC_BASE_URL=https://your-app.up.railway.app" \
--variables "STORAGE_DIR=/data/storage" \
--variables "NODE_ENV=production" \
--variables "FREE_TIER_CREDITS=100" \
--variables "PLAYWRIGHT_MAX_CONTEXTS=2" \
--variables "RENDER_TIMEOUT_MS=30000" \
--variables "KEY_GRACE_PERIOD_HOURS=24" \
--variables "MAX_PAGES_PER_RENDER=200" \
--variables "ADMIN_TOKEN=$(openssl rand -base64 36 | tr -d '\n')"
# 3. Attach a volume so PDFs survive container restarts
railway service paperpress
railway volume add --mount-path /data/storage
# 4. Domain (auto-detects the container port)
railway domain --port 3000
# 5. Ship
railway up --detach -cNotes:
The Dockerfile uses
mcr.microsoft.com/playwright:vX.Y-jammyas the base. Keep that version in lockstep with theplaywrightnpm package — a mismatch means the browser binary won't exist and renders fail.The
startCommandinrailway.jsonis intentionally absent: Railway parses it as argv (not shell), so chained&&commands fail. TheCMDinDockerfilewraps insh -cand runs the full start sequence.Emails: until
RESEND_API_KEYis set, registration keys are logged to stdout. Grep for[email:console].
Examples
All of these are checked into samples/ — generated by scripts/preview.ts and scripts/detect.ts, regenerate them yourself with npx tsx scripts/preview.ts / npx tsx scripts/detect.ts <url>.
Same markdown, five themes (clean shown below — full PDF):

Theme | |
| |
| |
| |
|
Brand auto-detected from a live URL (brandFromUrl: "stripe.com" — full PDF):

Source URL | Detected + rendered |
github.com | |
railway.com | |
vercel.com |
Inline brand kits (no URL, fields passed directly in the request) — forest, mono-coral, stripe-colors.
Input markdown used above: sample.md, demo-q4-review.md (the one used by /v1/demo).
Status
Built: markdown → PDF across 5 themes, brand-kit auto-detect from a URL (real font-family stack, not just a serif|sans|mono bucket), 24h detect cache, brandFromUrl one-call shortcut, batch detect (20 URLs in parallel), WCAG luminance guard, email-based key issuance with rotation grace, an MCP server, a read-only admin surface, signed share URLs, pre-rendered docs.
Not built, on purpose: payment processing, npm publish of the MCP package, automated tests, real Prisma migrations. This isn't a live backlog — it's finished as a portfolio piece, not being developed toward a 1.0.
License
MIT — see LICENSE.
This server cannot be installed
Maintenance
Related MCP Servers
- AlicenseAqualityAmaintenanceTurn markdown into designed PDFs with cover page, table of contents, and code blocks that hold across pages. One command from Claude Desktop, Claude Code, Cursor, Cline, Zed, or any MCP-capable client.2381MIT
- AlicenseNot gradedqualityDmaintenanceProvides a standardized interface for interacting with Markitdown's tools and services through a unified API, compatible with MCP-compliant services.MIT

docjet-mcpofficial
AlicenseAqualityAmaintenanceRender branded PDF documents (invoices, reports, certificates) and PNG social images directly from any MCP client using DocJet templates or raw HTML with JSON data.340MIT- AlicenseNot gradedqualityBmaintenanceMCP server that exposes brand identity guidelines (visual look and voice) as markdown, enabling LLMs to produce on-brand content.13MIT
Related MCP Connectors
Render HTML, Markdown, or URLs to images, PDF, or branded artifacts; extract and watch pages.
Turn HTML or Markdown into a clean, styled PDF and get a download link.
Markdown in, any format out. PDFs merged, split, watermarked. Runs on our own doc engines.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/rozetyp/paperpress'
If you have feedback or need assistance with the MCP directory API, please join our Discord server