Get your API key
One Zod schema, three consumers: REST, MCP, and OpenAPI

One Zod schema, three consumers: REST, MCP, and OpenAPI

How PDF4.dev keeps its REST API, MCP server, and OpenAPI spec aligned, what we actually share today, and the drift we found auditing our own code.

10 min read

PDF4.dev exposes the same product through three surfaces: a REST API, an MCP server for AI agents, and an OpenAPI 3.1 spec that generates our docs site. Each one needs a description of the same objects, a template, a component, a render request, a log entry. The obvious move is one Zod schema feeding all three. We did not do that, and when we audited the code for this article we found exactly the drift you would predict.

This is what is actually shared in our repository today, what is not, what that costs, and how to build the unified version if you are starting now.

What are the three consumers of an API schema?

An API schema has three distinct jobs, and they are usually written three times. The first job is runtime validation: rejecting a malformed request body before it reaches business logic. The second is machine-readable tool description: an MCP tool publishes an inputSchema and an optional outputSchema, both JSON Schema, so a model knows what to send and a client knows how to parse what comes back. The third is documentation: an OpenAPI document with reusable components under components/schemas.

At PDF4.dev those three live in three files. Runtime validation is inline in the route handlers under app/api/v1/. Tool description is in app/api/mcp/handler.ts, 1516 lines registering 14 tools with Zod. Documentation is in lib/openapi.ts, 1180 lines of a hand-written literal returned by buildOpenApiSpec(baseUrl).

Nothing enforces agreement between the three. That is the whole problem in one sentence.

What does PDF4.dev actually share today?

Two mechanisms, both real, neither covering REST validation.

The first is a set of shared Zod shapes inside the MCP handler. Five of them: templateSummarySchema, templateFullShape, componentFullSchema and componentFullShape, logEntrySchema, and statsSchema. They exist so that get_info, list_templates, get_template, create_template, and update_template all publish the same template structure instead of five near-identical inline objects.

const templateSummarySchema = z.object({
  id: z.string(),
  name: z.string(),
  slug: z.string(),
  variables: z.array(z.string()),
  pdf_format: z.unknown().optional(),
  sample_data: z.unknown().optional(),
  header_component_id: z.string().nullable().optional(),
  footer_component_id: z.string().nullable().optional(),
  updated_at: z.string(),
});

Two naming conventions coexist there for a mechanical reason. templateFullShape is a plain object of Zod fields, not a z.object(), because the MCP SDK's registerTool takes a raw shape for inputSchema and outputSchema and wraps it itself. templateSummarySchema is a real z.object() because it gets nested inside z.array(...). The suffix tells you which one you are holding.

The second shared mechanism is metadata, not structure. lib/seo/mcp-tools.ts exports MCP_TOOLS, an array of McpToolDef records with a name, a description, a category, and annotation flags. scripts/generate-llms.ts imports it, runs assertMcpToolsSanity() to catch empty fields and duplicate names, and regenerates public/llms.txt, public/.well-known/agents.md, and the block between the mcp-tools-start and mcp-tools-end markers in public/llms-full.txt. It runs in prebuild, so a Railway deploy cannot ship a stale agent manifest.

The docs site has the equivalent for OpenAPI. docs/scripts/generate-openapi.mjs imports buildOpenApiSpec from the app and writes docs/openapi.json; a second script turns that into MDX pages. The spec itself is hand-written, but everything downstream of it is generated.

SurfaceSchema sourceGenerated from itDrift risk
REST validationhand-written if statements in each routenothinghigh
MCP tool I/O5 shared Zod shapes in handler.tsstructuredContent at runtimelow within MCP
MCP tool metadataMCP_TOOLS in lib/seo/mcp-tools.tsllms.txt, agents.md, llms-full.txt, docs, JSON-LDlow
OpenAPI spechand-written literal in lib/openapi.tsopenapi.json, docs MDX, Scalar referencehigh

Why is REST validation still written by hand?

Our project conventions say it plainly: manual validation, no Zod, no middleware. The render endpoint shows why that survived. It accepts either a template_id or a raw html string, never neither, and it resolves a different set of components in each branch. As code that is three if blocks. As a Zod schema it is a union with a refinement whose error message you then have to rewrite anyway, because the default union error is unreadable.

The endpoint also validates a field that no schema sees. The delivery option is read straight off the parsed body and checked after the PDF has already been rendered:

const delivery: "base64" | "url" | undefined = body.delivery;
if (delivery && delivery !== "base64" && delivery !== "url") {
  return badRequest("delivery must be 'base64' or 'url'", "invalid_delivery");
}

That is a TypeScript annotation asserting a shape nobody checked, followed by a runtime check placed after the expensive work. A Zod parse at the top of the handler would have rejected it in microseconds instead of after a Chromium render. This is the concrete cost of manual validation, and it is not hypothetical.

What drift did we find auditing our own code?

Four real divergences, found by reading the three files side by side.

The delivery option is missing from the OpenAPI spec. The render route supports it, the MCP render_pdf tool declares it as z.enum(["base64", "url"]), and the docs describe it. The RenderRequest component in lib/openapi.ts has exactly four properties: template_id, html, data, format. No delivery. The documented 200 response is only application/pdf binary, so the two JSON response bodies the endpoint can return are undocumented. Optional fields drift silently because nothing breaks when they are absent.

The permission error has two different identities. lib/api-error.ts defines four error types and forbidden() returns HTTP 403 with type authentication_error and code insufficient_permissions. The MCP handler defines five types, including a permission_error the REST layer does not have, and its permissionError() helper returns code insufficient_permission, singular. Same condition, a render_only key reaching a full_access tool, two different codes. An agent writing a retry branch on the code string gets it wrong on one of the two surfaces.

HTTP 403 appears zero times in the OpenAPI spec. The templates and components endpoints reject render_only keys, and not one path documents that response.

The MCP surface is a deliberate subset, and nothing records that. REST accepts a full PdfFormat object with 15 optional fields. MCP render_pdf accepts format_preset, a five-value enum that omits custom. That is a reasonable product decision, agents do not need to set component_gap, but it lives only in the shape of the code. Nothing states the subset is intentional, so the next person to touch it cannot tell a choice from an oversight.

There is a fifth, smaller one worth noting because it shows the limit of a shared shape. get_info and list_templates both declare templateSummarySchema, but only list_templates actually populates pdf_format. The field is optional in the shape, so both tools validate. A shared schema guarantees a common vocabulary, not common content.

What does the unified pattern look like?

One source schema, three derivations. Write the Zod object once, then project it.

// 1. The source
export const renderRequest = z.object({
  template_id: z.string().optional(),
  html: z.string().optional(),
  data: z.record(z.string(), z.any()).optional(),
  delivery: z.enum(["base64", "url"]).optional(),
});
 
// 2. Runtime validation in the REST route
const parsed = renderRequest.safeParse(await request.json());
if (!parsed.success) return badRequest(formatIssues(parsed.error));
 
// 3. MCP tool input, the same object's shape
server.registerTool("render_pdf", { inputSchema: renderRequest.shape });
 
// 4. OpenAPI component
const RenderRequest = z.toJSONSchema(renderRequest, { target: "openapi-3.0" });

Zod 4 makes step four a one-liner. z.toJSONSchema() is built in and its target option accepts draft-04, draft-07, draft-2020-12, which is the default, and openapi-3.0. We already run Zod 4.3.6 in this repository for the MCP server, so the capability is sitting there unused.

For assembling a whole OpenAPI document rather than a single component, @asteasolutions/zod-to-openapi registers schemas and paths and emits the finished document. It ships three generators, OpenApiGeneratorV3, OpenApiGeneratorV31, and OpenApiGeneratorV32, and it supports Zod 4 directly; Zod 3 users are pinned to its 7.3.4 release.

Step three needs no conversion at all. The MCP specification defines inputSchema as "JSON Schema defining expected parameters" and outputSchema as an optional JSON Schema for the result, and the TypeScript SDK accepts Zod shapes and converts them for you. That is why MCP is the easiest of the three surfaces to unify with Zod, and why our MCP layer is the one that already has shared schemas.

What breaks when you unify, and how do you avoid it?

Four traps, in rough order of how often they bite.

Types that exist in Zod but not in JSON Schema. z.date(), z.bigint(), z.transform(), and z.custom() have no JSON Schema equivalent, and z.toJSONSchema() throws on them by default. You can set unrepresentable: "any" to emit an empty schema, which is JSON Schema's way of saying unknown, or pass a function to decide per case. Setting it globally to "any" is tempting and wrong: it silently degrades your published contract. Keep the default throw, and change the schema instead. Dates in a public API should be ISO strings anyway, which is z.string().

Input and output types diverge. Any schema with a transform or a default has one shape going in and another coming out. z.toJSONSchema() has an io option for this; io: "input" extracts the input type for schemas like ZodPipe. A request body is an input schema, a response body is an output schema, and publishing the wrong one is a contract bug that type checking will not catch.

Error messages drift even when schemas do not. This is the failure our own audit surfaced, and unification does not fix it for free. A Zod issue array and an MCP tool error and an HTTP error response are three different envelopes. The fix is a single formatter function that all three call, so a Zod issue becomes the same type, code, and message triple everywhere. We already have that envelope on both sides, { error: { type, code, message } }, and we still managed to ship two codes for one condition, because the strings were typed twice.

Versioning. Once the OpenAPI component is generated from the Zod schema, loosening the schema instantly loosens the published contract. Adding an optional field is safe. Widening an enum is safe for requests and breaking for responses, because a client validating your outputSchema will reject a value it has never seen. The MCP spec is explicit here: servers MUST return structured results conforming to a declared outputSchema, and clients SHOULD validate them. A declared schema you do not honor is a runtime failure at the client, not a stale doc.

What we are changing at PDF4.dev

We are not rewriting the REST layer to Zod wholesale. The honest reading of our audit is that the expensive drift was never in the validation logic, it was in the descriptions: an option missing from a spec, an error code typed twice, a 403 nobody documented.

So the order is: generate the RenderRequest and PdfFormat OpenAPI components from Zod objects that the render route also parses, since that is the endpoint with the most surface and the only one where an unvalidated field reaches Chromium; move the error catalogue into one module both lib/api-error.ts and the MCP handler import, so a code exists once; and add a check to prebuild, next to assertMcpToolsSanity(), that fails the build when a tool name exists in one place and not the other.

That last one is the real lesson. assertMcpToolsSanity() has kept our tool metadata honest across 14 tools and three generated files, and it is about twenty lines of code. Generated artifacts stay in sync when a build step regenerates them. Hand-written ones stay in sync when a build step fails on them. Everything else is a promise you make to yourself in a code review and forget within a quarter.

If you are building an API with an MCP server alongside it, start with the MCP layer sharing Zod shapes, add z.toJSONSchema() for the spec, and write the assertion that fails your build before you write the second surface. The unification is cheap at the start and expensive to retrofit, which is precisely why we are writing this from the retrofit side.

Frequently asked questions

Can one Zod schema really serve a REST API, an MCP server, and an OpenAPI spec?
Technically yes. Zod 4 ships z.toJSONSchema(), which emits draft-2020-12 or openapi-3.0 JSON Schema from a Zod object, and MCP tool inputSchema and outputSchema are JSON Schema. So one Zod object can validate a REST body, describe an MCP tool, and become an OpenAPI component. In practice the three surfaces want different shapes, so full unification costs more than it saves for a small API.
What does PDF4.dev share today between its REST API and its MCP server?
Two things. First, five Zod shapes inside app/api/mcp/handler.ts are reused across the 14 MCP tools so every tool returns the same template, component, log, and stats structure. Second, lib/seo/mcp-tools.ts is the single source of truth for tool names and metadata, and scripts/generate-llms.ts regenerates llms.txt, agents.md, and part of llms-full.txt from it. REST request validation is hand-written and shares nothing with either.
Why not validate REST requests with Zod too?
PDF4.dev has a small number of endpoints with shallow request bodies, and the render endpoint accepts either template_id or html, a constraint that reads more clearly as two if statements than as a Zod union with a refinement. The cost of manual validation is that error wording and error codes drift between surfaces, which is exactly what our audit found.
What is the most common way schemas drift across API surfaces?
Optional fields added to one surface only. We added a delivery option to the render endpoint and to the MCP render_pdf tool, but the RenderRequest component in our OpenAPI spec never got it, so the generated docs still describe only the binary response. Optional fields drift silently because nothing breaks when they are missing.
Which Zod types cannot be expressed in JSON Schema?
Zod 4 lists z.date(), z.bigint(), z.transform(), and z.custom() among types with no JSON Schema equivalent. z.toJSONSchema() throws on them by default. The unrepresentable option can be set to any, which emits an empty schema object, or to a function that decides case by case.
Do MCP clients validate a tool's outputSchema?
The MCP specification says servers MUST return structured results that conform to a declared outputSchema, and clients SHOULD validate against it. Declaring an outputSchema that your handler does not actually satisfy is therefore a real bug, not just a documentation mistake.
What library converts Zod schemas into an OpenAPI document?
Zod 4 has a built-in z.toJSONSchema() with an openapi-3.0 target for individual schemas. For assembling a whole document with paths, responses, and registered components, @asteasolutions/zod-to-openapi provides generators for OpenAPI 3.0, 3.1, and 3.2.

Start generating PDFs

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