Get started
How we built an MCP server for PDF generation

How we built an MCP server for PDF generation

Inside PDF4.dev's MCP server: 14 tools, 4 resources, 3 prompts, outputSchema everywhere, structured errors, and the SSOT that keeps llms.txt and the docs site in sync with the code.

10 min read

A user opens Claude Desktop and types "create an invoice for Acme Corp, total $1,500". One second later Claude picks the render_pdf tool, fills in template_id and data, and returns a PDF. No docs were read. No curl command was typed.

That flow looks simple. Under the hood it is a JSON-RPC conversation between the client and a Next.js route that exposes 14 tools, 4 resources, and 3 prompts, all authenticated, all type-checked, all wired to the same database that powers the dashboard.

This article is the engineering walkthrough.

The full server at a glance

Loading diagram…

What MCP gives you

MCP is a JSON-RPC protocol with three capability types:

CapabilityWhat it doesHow agents use it
ToolsFunctions with typed arguments and responsesAgent calls render_pdf with data, gets a PDF back
ResourcesStatic documents the agent can fetch for contextAgent reads pdf4dev://docs/handlebars-helpers before writing a template
PromptsWorkflow templates surfaced in slash-command menusUser types /generate-invoice, fills a form, agent follows the steps

The server is built on Vercel's mcp-handler: Streamable HTTP transport, Next.js App Router adapter, Zod-friendly server.registerTool API. We run it on the Node runtime because we need AsyncLocalStorage, better-sqlite3, and a singleton Playwright browser.

The 14 tools

We did not expose every REST endpoint one-to-one. An agent is not a developer reading OpenAPI. It wants a small set of verbs that match how it already thinks.

GroupToolRead-onlyDestructiveOpen-world
Infoget_infoyesnono
Generaterender_pdfnonoyes
preview_templatenonoyes
Templateslist_templatesyesnono
get_templateyesnono
create_templatenonono
update_templatenonono
delete_templatenoyesno
Componentslist_componentsyesnono
get_componentyesnono
create_componentnonono
update_componentnonono
delete_componentnoyesno
Logslist_logsyesnono

The annotations (readOnlyHint, destructiveHint, openWorldHint) tell clients whether to ask the user for confirmation. delete_template is destructive: Claude will ask before calling it. render_pdf is open-world: it fetches Google Fonts and remote images.

Authentication: two paths, one resolver

The primary auth is an Authorization: Bearer p4_live_... header. But some clients cannot set custom headers on MCP connections, so every tool also accepts an optional api_key argument.

Loading diagram…

The header token is stashed in AsyncLocalStorage at the request edge so tool handlers never have to plumb it through:

const authTokenStorage = new AsyncLocalStorage<string | undefined>();
 
async function resolveAuth(apiKeyParam?: string) {
  const headerToken = authTokenStorage.getStore();
  if (headerToken) {
    const auth = await authenticateApiKey(headerToken);
    if (auth) return auth;
  }
  if (apiKeyParam) return authenticateApiKey(apiKeyParam);
  return null;
}

Permission scopes

Scoperender_pdfTemplates CRUDComponents CRUDLogs
full_accessyesyesyesyes
render_onlyyesnonono

A render_only key can call render_pdf and preview_template, nothing else. A CI pipeline gets a narrow key without the ability to delete templates.

outputSchema: typed data instead of JSON strings

Early versions returned data the obvious way: JSON.stringify(result) inside a text content frame. The agent had to re-parse JSON out of a string every time.

Modern MCP clients support structuredContent. If a tool declares an outputSchema, results ship as typed data the client can validate and render directly.

Before (text-only response):

{
  "content": [{
    "type": "text",
    "text": "{\"id\":\"tmpl_abc\",\"name\":\"Invoice\",\"slug\":\"invoice\"...}"
  }]
}

After (structured response):

{
  "content": [{
    "type": "text",
    "text": "{\"id\":\"tmpl_abc\",\"name\":\"Invoice\"...}"
  }],
  "structuredContent": {
    "id": "tmpl_abc",
    "name": "Invoice",
    "slug": "invoice",
    "html": "<h1>{{company_name}}</h1>...",
    "created_at": "2026-03-15T10:00:00Z",
    "updated_at": "2026-03-15T10:00:00Z"
  }
}

13 of 14 tools use this pattern. (preview_template returns a PNG image instead.) A single helper produces both payloads:

function structuredResult<T>(data: T) {
  return {
    content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
    structuredContent: data as Record<string, unknown>,
  };
}

Shared Zod shapes keep the schemas DRY across tools:

const templateFullShape = {
  id: z.string(),
  name: z.string(),
  slug: z.string(),
  html: z.string(),
  plain_text: z.string().optional(),
  pdf_format: z.unknown().optional(),
  sample_data: z.unknown().optional(),
  header_component_id: z.string().nullable().optional(),
  footer_component_id: z.string().nullable().optional(),
  created_at: z.string(),
  updated_at: z.string(),
};

PDF delivery: base64 vs signed URL

A one-page invoice is 50KB base64. A 40-page report is 4MB. Sending 4MB of base64 into an agent's context window wastes tokens and slows responses.

The render_pdf tool accepts a delivery argument:

ModeResponseBest for
(omitted){ pdf_base64, size_bytes, duration_ms }Small PDFs, agent needs to inspect content
"url"{ url, expires_at, size_bytes, duration_ms }Large PDFs, agent just needs to hand the link to the user

URL mode writes the PDF to data/renders/<id>.pdf, signs a 24-hour HMAC-SHA256 token keyed on BETTER_AUTH_SECRET, and returns a link. The URL is self-verifying: no database lookup at serve time.

Loading diagram…

Renders are pruned opportunistically: expired files are cleaned up on the next saveRender() call, with a soft cap of 2,000 files.

Resources: context the agent pulls on demand

Resources are embedded markdown documents under a pdf4dev:// URI scheme. No auth, no database lookup: the handler reads a string constant and returns it.

URIContentWhen an agent reads it
pdf4dev://docs/quickstartHappy-path flow: sign up, create key, render first PDFBefore any tool call
pdf4dev://docs/handlebars-helpersEvery built-in helper with examples (formatCurrency, formatDate, ...)Before writing template HTML
pdf4dev://docs/format-presetsPage sizes (A4, Letter, ...) and the full PdfFormat shapeBefore setting format options
pdf4dev://docs/componentsHow <pdf4-header>, <pdf4-footer>, <pdf4-block> tags workBefore creating components

When an agent is about to call create_template, it can fetch pdf4dev://docs/handlebars-helpers first and discover that formatCurrency exists before writing HTML that reinvents it.

Prompts: guided workflows for first-time users

Prompts are workflow templates with a Zod argsSchema. Clients like Claude Desktop surface them in their slash-command menu.

PromptArgumentsWhat the agent does
generate-invoiceclient_name, amount, invoice_number?, currency?Picks an invoice template, calls render_pdf with the filled data
create-template-from-descriptiondescriptionTurns a plain-English brief into a saved template with HTML and sample data
debug-render-errortemplate_id?Pulls recent error logs and proposes a fix
server.registerPrompt("generate-invoice", {
  title: "Generate an invoice PDF",
  description:
    "Walks the agent through picking an invoice template "
    + "and calling render_pdf with the right variables.",
  argsSchema: {
    client_name: z.string().describe("Billed client or company name"),
    amount: z.string().describe("Total amount, e.g. $1,500.00"),
    invoice_number: z.string().optional(),
    currency: z.string().optional(),
  },
}, ({ client_name, amount, invoice_number, currency }) => ({
  messages: [{
    role: "user",
    content: {
      type: "text",
      text: `1. Call list_templates to find an invoice template.\n`
          + `2. Call render_pdf with template_id, data: { client_name: "${client_name}", ... }.\n`
          + `3. Return the PDF to me.`,
    },
  }],
}));

Errors that agents can act on

An agent that gets a 500 and a stack trace is a stuck agent. Every tool returns the same envelope on failure:

{
  "error": {
    "type": "authentication_error",
    "code": "invalid_api_key",
    "message": "No valid API key found. Sign up at pdf4.dev/auth/signup..."
  },
  "isError": true
}

Five error types, five shared helpers:

TypeCode exampleWhen
authentication_errorinvalid_api_keyNo key or invalid key
permission_errorinsufficient_permissionrender_only key calling CRUD
invalid_request_errormissing_templateBad arguments
not_found_errortemplate_not_foundID does not exist
api_errorrender_failedPlaywright crash, timeout

The auth error message is written for the agent, not a human. It walks through the fix: sign up, create a key, pass it in the header. When Claude hits this error, it has enough context to tell the user exactly what to do next.

The single source of truth

In the first month, the landing page listed 11 tools. The docs site listed 10. llms.txt listed 12. The actual server had 13. Every new tool meant four files to update and one to forget.

The fix: lib/seo/mcp-tools.ts contains every tool's name, description, category, and annotations. Two generator scripts read it and rewrite everything downstream.

Loading diagram…

Both scripts run automatically during prebuild. A runtime assertMcpToolsSanity() check refuses to generate if a tool is missing a field or duplicated. One npm run generate-llms regenerates every discovery file that agents look at.

Adding a new tool: the checklist

  1. Implement in app/api/mcp/handler.ts with server.registerTool + outputSchema
  2. Add entry in lib/seo/mcp-tools.ts
  3. Add entry in MCP_TOOL_DETAILS in app/ai-integration/page.tsx
  4. Run npm run generate-llms
  5. From docs/, run npm run generate-mcp-tools

TypeScript errors at compile time if step 3 is skipped. The generator refuses to run if step 2 is skipped. Forgetting is hard by design.

Live sync: MCP writes update the dashboard instantly

When an agent creates a template through MCP, the dashboard tab the user has open updates without a refresh. The MCP tool handler calls notifyTemplateCreated() after the database write, which publishes an event on the in-process EventEmitter bus. The SSE route picks it up and pushes it to every connected dashboard tab.

Loading diagram…

The full architecture is covered in how we hardened our SSE stream for Railway.

What is next

ItemStatusNotes
delivery: "url" on render_pdfShippedSigned 24h URLs, pruned storage
Live dashboard sync via SSEShippedAll MCP writes propagate to dashboard
Structured errors on every toolShipped5 types, same shape as REST API
outputSchema on 13/14 toolsShippedpreview_template returns image
OAuth 2.1PlannedReplace Bearer keys with account-level delegation
Rate-limit headersPlannedX-RateLimit-Limit, Remaining, Reset
MCP registry listingsPlannedmcpservers.org, smithery.ai, glama.ai, pulsemcp.com

The engineering rule of thumb: when an agent fails against your MCP server, it is almost never a protocol problem. It is a schema that did not match reality, a description that lied, or an error message written for a human instead of a model. Fix those and the rest takes care of itself.

Free tools mentioned:

Html To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.