← Blog

September 16, 2026 · Steve Johnson

Why We Rewrote Our API Manifest Around a Single Schema

At Letterstory, our machine-facing API had grown three surfaces over time — an MCP server, a REST layer, and a CLI — and each one had its own hand-maintained description of what a given tool accepted and returned. They'd started in sync. By the time I looked closely, they'd drifted: a parameter renamed in one schema definition but not the other two, a new optional field that existed in the handler and nowhere in the documentation any of the three surfaces generated. Nothing was catastrophically broken. Everything was quietly, slowly wrong in a different way in each place.

Three copies of the truth is zero copies of the truth

The drift wasn't caused by carelessness — it was caused by the schemas being genuinely separate artifacts that happened to describe the same thing. Every time a tool's inputs changed, someone had to remember to update the hand-written JSON Schema for one surface and the Zod validator for the handler and, if they thought of it, the OpenAPI-adjacent docs for a third. "Remember to update it in three places" is a process that works until it doesn't, and by the time I got involved it had already stopped working more than once.

The fix wasn't better documentation discipline. It was making the drift structurally impossible by deleting two of the three copies.

One manifest, generated outward

The redesign centers on a single source: one manifest where each tool entry carries its name, description, input schema, required capability scope, the handler function, and an optional REST binding, all defined once using Zod. Everything else derives from that one definition instead of being maintained alongside it. The MCP tool list's JSON Schema comes from Zod's own schema-to-JSON-Schema conversion. The REST layer's OpenAPI 3.1 document is generated at request time from the same manifest. We picked OpenAPI 3.1 specifically because it's the first version of that spec whose schema object is JSON Schema 2020-12 — the same dialect Zod already emits — so there's no lossy translation step where a schema has to be down-converted or approximated to fit an older spec's more limited type system.

The capability scope lives on the same manifest entry too, and the auth check runs before the handler is ever reached, using that declared scope — an under-scoped API key never gets far enough to see what arguments the tool wanted, let alone execute it.

What "can't drift" actually buys you

The honest case for this isn't elegance — it's that a whole category of bug became structurally unreachable. You can't have a REST endpoint accept a field the MCP tool rejects, because both are reading the same Zod schema. You can't have documentation claim a parameter is optional when the handler requires it, because the documentation is generated from the same object the handler validates against. The three surfaces can still each have surface-specific bugs, but they can no longer silently disagree with each other about what a tool takes as input, which was the actual failure mode that cost real debugging time before the rewrite.

The lesson generalizes past API manifests: any time the same fact needs to be true in more than one place — a schema, a set of enum values, a permission list — the question worth asking isn't "how do we keep these in sync." It's "can we make there be only one of them, with everything else derived." Sync discipline degrades under real deadline pressure. A single source of truth doesn't have that failure mode, because there's nothing left to get out of sync.