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
What MCP gives you
MCP is a JSON-RPC protocol with three capability types:
| Capability | What it does | How agents use it |
|---|---|---|
| Tools | Functions with typed arguments and responses | Agent calls render_pdf with data, gets a PDF back |
| Resources | Static documents the agent can fetch for context | Agent reads pdf4dev://docs/handlebars-helpers before writing a template |
| Prompts | Workflow templates surfaced in slash-command menus | User 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.
| Group | Tool | Read-only | Destructive | Open-world |
|---|---|---|---|---|
| Info | get_info | yes | no | no |
| Generate | render_pdf | no | no | yes |
preview_template | no | no | yes | |
| Templates | list_templates | yes | no | no |
get_template | yes | no | no | |
create_template | no | no | no | |
update_template | no | no | no | |
delete_template | no | yes | no | |
| Components | list_components | yes | no | no |
get_component | yes | no | no | |
create_component | no | no | no | |
update_component | no | no | no | |
delete_component | no | yes | no | |
| Logs | list_logs | yes | no | no |
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.
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
| Scope | render_pdf | Templates CRUD | Components CRUD | Logs |
|---|---|---|---|---|
full_access | yes | yes | yes | yes |
render_only | yes | no | no | no |
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:
| Mode | Response | Best 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.
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.
| URI | Content | When an agent reads it |
|---|---|---|
pdf4dev://docs/quickstart | Happy-path flow: sign up, create key, render first PDF | Before any tool call |
pdf4dev://docs/handlebars-helpers | Every built-in helper with examples (formatCurrency, formatDate, ...) | Before writing template HTML |
pdf4dev://docs/format-presets | Page sizes (A4, Letter, ...) and the full PdfFormat shape | Before setting format options |
pdf4dev://docs/components | How <pdf4-header>, <pdf4-footer>, <pdf4-block> tags work | Before 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.
| Prompt | Arguments | What the agent does |
|---|---|---|
generate-invoice | client_name, amount, invoice_number?, currency? | Picks an invoice template, calls render_pdf with the filled data |
create-template-from-description | description | Turns a plain-English brief into a saved template with HTML and sample data |
debug-render-error | template_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:
| Type | Code example | When |
|---|---|---|
authentication_error | invalid_api_key | No key or invalid key |
permission_error | insufficient_permission | render_only key calling CRUD |
invalid_request_error | missing_template | Bad arguments |
not_found_error | template_not_found | ID does not exist |
api_error | render_failed | Playwright 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.
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
- Implement in
app/api/mcp/handler.tswithserver.registerTool+outputSchema - Add entry in
lib/seo/mcp-tools.ts - Add entry in
MCP_TOOL_DETAILSinapp/ai-integration/page.tsx - Run
npm run generate-llms - From
docs/, runnpm 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.
The full architecture is covered in how we hardened our SSE stream for Railway.
What is next
| Item | Status | Notes |
|---|---|---|
delivery: "url" on render_pdf | Shipped | Signed 24h URLs, pruned storage |
| Live dashboard sync via SSE | Shipped | All MCP writes propagate to dashboard |
| Structured errors on every tool | Shipped | 5 types, same shape as REST API |
| outputSchema on 13/14 tools | Shipped | preview_template returns image |
| OAuth 2.1 | Planned | Replace Bearer keys with account-level delegation |
| Rate-limit headers | Planned | X-RateLimit-Limit, Remaining, Reset |
| MCP registry listings | Planned | mcpservers.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:
Start generating PDFs
Build PDF templates with a visual editor. Render them via API from any language in ~300ms.



