Writing your first MCP server is not hard. It is a few hundred lines, and you can have one running before lunch. I know, because I wrote one.

The problem is the fifth one.

The shape of the work

Here is what actually happens when a company decides its capabilities should be reachable by AI agents.

You write an MCP server. It works. Then ChatGPT wants the same capability shaped slightly differently. Then Gemini. Then whatever ships next quarter, because something always ships next quarter. Each one is a separate integration project with its own SDK, its own idea of what a tool definition looks like, and its own release cadence.

And underneath all of them, your own API keeps changing — a field gets renamed, a response becomes nullable, an endpoint moves. Now you have four hand-written integrations that are each independently wrong, and no build step that will tell you.

The first server is an afternoon. The fifth is a team.

So the question I got stuck on was not how do I write an MCP server. It was: what is the artifact that survives when the protocol changes?

One definition, many emitters

That question has a boring, well-understood answer, and it is the same answer compilers have been giving since the 1970s: put an intermediate representation in the middle.

capabilities.yaml  →  semantic model  →  IR  →  emitter  →  MCP tools
                                          ↘  emitter  →  SDK / REST / GraphQL / …

Enter fullscreen mode Exit fullscreen mode

You describe a capability once, in business terms — what it does, what it needs, what it returns, and whether calling it is safe to retry. No HTTP, no JSON Schema, no SDK. That lowers to a target-agnostic IR, and emitters consume the IR.

The whole tourism.search capability in the demo is twelve lines:

capability:
  id: tourism.search
  description: Find places to stay matching a traveler's intent.
  effect: read

  input:
    destination: { type: location }
    dates:       { type: date-range }
    travelers:   { type: party }
    budget:      { type: money, required: false }

  output:
    stays: { collection: Stay }

  provider: booking-engine

Enter fullscreen mode Exit fullscreen mode

Change the protocol and you regenerate. Change the backend and the definition does not move at all — only the binding does, which is a separate file that never appears in the business description.

The evidence that the IR is the product, not MCP

This is the part I would want to see if someone else were making the argument, so here it is.

The system that most depends on Archstone in production does not speak MCP at all.

ArtVinci is a custom-framing workshop. Its site runs an assistant that answers customer questions — real catalog, real prices computed live by its own backend. It does not run an MCP server process. It loads the compiled IR directly:

const result = await archstone.execute("framing.estimate-frame-price", input);

Enter fullscreen mode Exit fullscreen mode

Same IR, same validation, same fail-closed response mapping as the MCP path — just no separate process to deploy. MCP is one emitter. If it had been the product, that deployment would have been impossible.

The part that is actually hard

Generating a tool definition is the easy half. The half that decides whether an agent gives your customer a wrong answer is the response mapping.

A binding says where each field of a declared resource comes from in the provider's payload, and which of them are required. When a required field is missing, the call fails closed — a structured error, never a silent pass-through of whatever the backend happened to return. A missing optional field degrades instead. Those two words, DEGRADED and VIOLATION, are the difference between an agent saying "I don't have that" and an agent inventing a price.

And because a provider will eventually change its payload without telling you, each binding carries a recorded fixture and a fingerprint. archstone verify replays the recorded request against the live backend on demand and reports a health status per binding, so contract drift surfaces as a red line in your CI rather than as a customer complaint.

"But I could just paste my OpenAPI spec into Claude"

Yes. You could, it would take ninety seconds, and it would cost you nothing. This was the sharpest objection anyone raised while I was building the onboarding command, and I think it is correct as far as it goes.

So archstone init is deliberately not a code generator. It is a loop:

  1. It reads your OpenAPI document and proposes candidates.
  2. It asks you the questions no document can answer — chiefly whether a call is read, write or irreversible, which is the difference between "look up a price" and "charge a card."
  3. It runs the real compiler. If the manifest it drafted does not compile, it writes nothing at all. There is no "mostly works, fix the errors yourself" mode.
  4. Optionally, it calls your real backend, once, read-only, and records a genuine fixture — then replays it through the shipped mapper to check that its own mapping actually holds.

A language model can write plausible YAML. It cannot run your compiler or call your API. That is the entire difference, and it is why every unit of effort went into the loop rather than into nicer field names.

An example of it being wrong, because that is more useful

The first time I pointed init at a real spec, it produced a price-estimation capability whose entire output was a list of advisory warnings. No price.

The rule was working exactly as designed: find the array of objects in the response, make it the resource. In that response the only array of objects was warnings. The payload — the price, the currency, the geometry — sat in scalar fields beside it and got dropped.

It compiled. It passed validation. It wrote files. An agent calling that tool would have received a list of warnings and no price, and nothing anywhere would have flagged it.

The fix was not a cleverer heuristic. Distinguishing "the array is the payload and the scalars are pagination" from "the array is diagnostics and the scalars are the payload" cannot be done from the document — the two shapes are structurally identical. So init now enumerates the candidates and asks, once, with the field names shown. One candidate, no question.

That is the general posture, and it is the only thing keeping a tool like this honest:

An ambiguity is a question or a refusal, never a guess.

What it costs you

You write YAML describing your business. That is the trade. If your capabilities are genuinely one endpoint and you never expect a second protocol, hand-writing an MCP server is the right call and you should do that instead.

The compiler earns its keep at the point where you have several capabilities, more than one consumer, and a backend that changes — which is to say, at the point where the hand-written version stops being an afternoon.


Archstone is Apache-2.0: github.com/Archstone-Romania/archstone. A capability compiled by it is live — paste https://demo.archstone.dev/mcp into Claude's custom connectors and ask about a trip.