Chapter one made the thesis: a form schema is a tool contract, and because the protocol, auth, limits, and audit are identical for every server, only the data and the tool design differ. That is what makes a no-code layer able to emit a correct MCP server rather than a toy.

This chapter is the proof of that thesis at the level of one subsystem: the schema compiler. It takes the visual field model a builder assembled by hand and produces the typed contract an AI agent reads before it ever sends a call. Get this stage right and everything downstream, the runtime, the security plane, the inspector, is operating on a contract that is correct by construction.

What the contract has to contain

The Model Context Protocol fixes the shape of a tool. Every tool a server advertises must carry a name, a human-and-model readable description, and an inputSchema: a JSON Schema object describing the tool's parameters. The agent reads that schema to learn which arguments exist, what types they take, and which are required, then constructs a call it believes is valid before sending it. The schema is the difference between an agent that calls your tool correctly on the first try and one that probes it like a black box.

So the compiler's job is precise: turn the field model into a JSON Schema object the spec will accept, and do it deterministically, the same input producing the same contract every time. AppElixir targets JSON Schema Draft 2020-12, the dialect the MCP specification builds on, so the emitted schema is portable to any compliant client and any compliant validator.

The field-to-schema mapping

The form builder has a closed set of field types. That is what makes the compiler tractable: there is no open-ended expression language to translate, just a finite vocabulary of fields, each with a single canonical JSON Schema form. The core of the compiler is a lookup table.

Form fieldJSON Schema construct
Required text field"type": "string", listed in required
Optional text field"type": "string", omitted from required
Numeric range field"type": "integer" or "number" with minimum and maximum
Dropdown / single choice"type": "string" with an enum array
Checkbox / toggle"type": "boolean"
Date field"type": "string" with "format": "date" (or "date-time")
File / attachmenttyped object reference: { "type": "object", "properties": { "id", "mime_type" } }
Per-field help text"description" on the property, read by the agent

Three of those rows carry most of the value, and they are the three a hand-rolled server most often gets wrong.

Numeric range to a bounded number. A field configured as "quantity, 1 to 100" does not become a bare integer. It becomes an integer with minimum: 1 and maximum: 100. The agent reads the bounds and stops proposing 0 or 5000. The constraint that protected the human form protects the tool call.

Dropdown to enum. A finite choice set becomes an enum. This is the single highest-leverage mapping in the table. Agents respect enums and hallucinate free-text. A field the builder defined as a dropdown of open, pending, closed compiles to an enum, and the agent's tool-choice quality jumps because it is choosing from a list instead of inventing a string.

Per-field description to schema description. The help text a builder wrote for a human ("Customer email, lowercase, no whitespace") is copied verbatim into the JSON Schema description for that property. The agent reads it. A field description is documentation the model uses at call-construction time, not decoration, which is why chapter five treats the description as product.

By hand

Hand-rolling this contract means writing the JSON Schema yourself in the tool registration. You type out the properties object, remember to keep the required array in sync, decide each numeric bound by hand, transcribe the enum values from wherever the real list lives and hope they stay in sync, and write a description for every field if you have the discipline. It is not hard. It is just a second copy of a schema you already expressed somewhere else, and second copies drift. The validation rules in your form and the JSON Schema in your tool definition diverge the first time someone edits one and forgets the other.

With the engine

There is no second copy. The form model is the single source, and the contract is a pure function of it. Edit the field, recompile, and the JSON Schema, the required array, the bounds, the enum, and the descriptions all move together because they were never separate artifacts. The drift class of bug does not exist because there is nothing to drift from.

A before and after

Here is a form definition as the builder holds it internally: a tool to create a support ticket, with a required subject, a bounded priority, a status drawn from a fixed list, and an optional flag.

{
  "tool": "create_ticket",
  "fields": [
    { "name": "subject",  "type": "text",     "required": true,
      "help": "Short summary of the issue, one line." },
    { "name": "priority", "type": "number",   "required": true,
      "min": 1, "max": 5,
      "help": "1 is lowest, 5 is highest." },
    { "name": "status",   "type": "dropdown", "required": true,
      "choices": ["open", "pending", "closed"] },
    { "name": "notify",   "type": "checkbox", "required": false,
      "help": "Email the requester when the ticket is created." }
  ]
}

The compiler emits the MCP inputSchema for that tool. This is what the agent receives in the tools/list response, and what it reads before it ever calls create_ticket.

{
  "type": "object",
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "properties": {
    "subject": {
      "type": "string",
      "description": "Short summary of the issue, one line."
    },
    "priority": {
      "type": "integer",
      "minimum": 1,
      "maximum": 5,
      "description": "1 is lowest, 5 is highest."
    },
    "status": {
      "type": "string",
      "enum": ["open", "pending", "closed"]
    },
    "notify": {
      "type": "boolean",
      "description": "Email the requester when the ticket is created."
    }
  },
  "required": ["subject", "priority", "status"],
  "additionalProperties": false
}

Two details are doing quiet work. The required array is derived, not declared twice, so it cannot fall out of sync with the per-field flags. And additionalProperties is false: the contract is closed, so an agent that invents a parameter gets a clean rejection instead of having a stray argument silently ignored.

The output side: collections become tool outputs

Inputs come from the form. Outputs come from the other half of the model, the target collection. A collection already declares the shape of the records it holds, the chapter-three abstraction that makes a spreadsheet, a SQL table, and a REST endpoint look identical. The compiler reads that record shape and derives the tool's output type from it, so you do not describe the output by hand any more than you describe the input.

The shape of the result follows the verb of the tool. A lookup tool returns one record typed as the collection's record schema. A list tool returns an array of that record type. A write tool, create-ticket, send-email, returns the resulting record plus a status field, so the agent gets back the thing it just made and a signal that it worked. The pattern is constant across every tool the engine emits, which is exactly why an agent generalizes after a couple of calls instead of relearning each tool's quirks.

Validate the contract, do not just publish it

Publishing the schema tells the agent how to call. It does not guarantee the call that arrives is well-formed, because an agent can still send a malformed payload. So the compiled JSON Schema is used twice: once as the published contract in tools/list, and again as the validation gate on every incoming tools/call. AppElixir validates arguments against the same compiled schema with Ajv, a fast Draft 2020-12 validator, before a single byte reaches the data source. A bad call bounces at the boundary and the agent receives a structured validation error it can read and retry against, rather than a stack trace or a half-written row. The same artifact is the contract and the guard. The security plane in chapter six leans on exactly this property.

Contract versioning: when the form changes

A form is never final. Someone adds a field, tightens a bound, renames a status. The risk is that an edit which looks harmless in the builder is a breaking change to an agent already calling the tool. The compiler's last job is to know the difference and to act on it.

On every recompile, the engine diffs the new contract against the currently published one and classifies the change:

When the diff is breaking, the compiler bumps the contract version and gates the change rather than overwriting the live contract underneath running agents. The builder is shown exactly which fields broke and why, and chooses to publish the new version deliberately. The plumbing for this is undramatic, a comparison of two JSON Schema objects against a small ruleset, but it is the difference between a contract you can evolve and one that quietly betrays the agents depending on it. The compiler treats "added an optional field" and "deleted a required field" as categorically different events, because to every agent on the other side of the protocol, they are.

Why this stage carries the thesis

Every later chapter assumes a correct contract. The runtime serves it, the inspector tests it, the security plane validates against it, the description linter polishes it. The schema compiler is where "a form is a tool contract" stops being a slogan and becomes a deterministic function: field model in, MCP inputSchema plus a collection-derived output type out, with a diff gate guarding every change. That is the part you would otherwise write by hand for every server and get subtly wrong on the third one. Compiling it once, from the model you already built, is the whole reason the no-code path produces a correct server instead of a fragile one.