By the end of chapter three the engine has everything it needs but a way to speak. There is a set of tool contracts, each a name plus an inputSchema plus an output type, and behind each contract a bound collection operation: read a row, list with a cursor, write a record. None of that is reachable yet. A contract that no agent can call is a document, not a server.
This chapter is about the thing that makes it reachable: the runtime the compiler emits. Its job is narrow and the same for every server AppElixir produces. Speak the Model Context Protocol over a transport, advertise the tools, accept calls, validate them, run the bound operation, and return a result the agent can reason about. The entire value of a no-code layer rests on this part being correct once and reused everywhere, so it is worth seeing exactly what "speak the protocol" means.
Two transports, one set of handlers
MCP defines how a client and a server exchange JSON-RPC messages, and it defines two ways to move those messages. The first is stdio: the client launches the server as a subprocess and talks to it over standard input and output. This is how desktop clients like Claude Desktop run a local server. The second is streamable HTTP, with Server-Sent Events for the server-to-client channel, which is how a hosted server answers a remote agent behind a URL and a token.
The temptation when you write a server by hand is to treat these as two servers. They are not. The messages are the same JSON-RPC objects; only the framing around them differs. The engine builds the message handlers once and wraps them in a transport chosen at launch.
By hand
You wire a stdio loop for local use, then later add an HTTP route with an SSE response and a session header, then discover the two paths have drifted: the HTTP path handles a cancellation the stdio path forgot, the stdio path logs in a format the HTTP path does not. You now maintain two front doors to one set of logic.
With the engine
The compiled artifact reads its launch context. Started as a subprocess with no port, it binds stdio. Started with a bind address, it serves streamable HTTP and opens an SSE channel for notifications. The tool-call logic underneath never knows which transport carried the request. One code path, two doorways, no drift. The MCP specification and the reference SDKs treat transports this way on purpose, which is why the engine can lean on the same model: see the lifecycle and transport sections of the MCP specification.
The handshake: initialize and capability negotiation
An MCP session does not start with a tool call. It starts with the client sending initialize, declaring the protocol version it speaks and the capabilities it supports. The server answers with the version it agrees to and the capabilities it offers. Only after the client acknowledges does normal traffic begin.
The generated runtime answers initialize from what the compiler already knows. A server with at least one tool advertises the tools capability. If any contract is long-running, it advertises support for progress notifications so the client knows streaming is on the table. It does not claim capabilities it cannot honor, because a false claim is a contract the agent will try to use and the server will then break.
// client -> server
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": { "sampling": {} },
"clientInfo": { "name": "claude-desktop", "version": "1.x" }
}
}
// server -> client
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": { "tools": { "listChanged": true } },
"serverInfo": { "name": "customers-lookup", "version": "3" }
}
}
The version in serverInfo is the contract version from chapter two. When a form changes and the compiler bumps the contract, the runtime reports the new number here, and a client that cached the old tool list sees a listChanged signal and re-fetches. The handshake is where versioning becomes visible to the agent.
tools/list returns the compiled contracts
Once the session is live, the client asks tools/list. This is the moment the compiler's work from chapter two becomes an agent-facing fact. The runtime returns each tool exactly as it was compiled: the name, the description an LLM will read to decide whether to call it, and the inputSchema as JSON Schema.
// server -> client, in response to tools/list
{
"tools": [
{
"name": "lookup_customer",
"description": "Return the customer record for a given email address.",
"inputSchema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"description": "Customer email, lowercase, no whitespace."
}
},
"required": ["email"]
}
}
]
}
Nothing here is generated at request time. The contract was produced once when the form was compiled, so tools/list is a read, not a computation. That matters under load and it matters for correctness: the schema the agent reads is byte-for-byte the schema the runtime will validate against on the next step. There is no second source of truth to fall out of sync.
tools/call: validate, bind, run, return
This is the hot path. The client sends tools/call with a tool name and an arguments object. The runtime does four things in order.
- Validate the arguments against the tool's
inputSchema. The reference SDKs lean on a JSON Schema validator for this, and so does the engine; a bad argument never reaches your data. - Bind the call to the collection operation the source binding declared in chapter three.
lookup_customerresolves to "read one row from the customers collection, keyed by email". - Run the operation against the data source: the spreadsheet read, the SQL select, the REST GET.
- Return a structured result envelope, or a structured error if any step failed.
// client -> server
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "lookup_customer",
"arguments": { "email": "dana@acme.test" }
}
}
// server -> client, success
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [
{ "type": "text", "text": "Found customer dana@acme.test on the Emerald plan." }
],
"structuredContent": {
"email": "dana@acme.test",
"plan": "emerald",
"signup_date": "2026-02-11"
},
"isError": false
}
}
Two things ship together in the result. The content array is a human and model-readable rendering, and structuredContent is the typed record shaped by the output type the compiler derived from the collection. The agent can read prose or parse fields, and the typed half matches the output schema the contract advertised, so a downstream tool can consume it without guessing.
Structured errors, not stack traces
The failure path is where a hand-rolled server usually leaks. An unvalidated argument throws, the exception serializes to a string, and the agent receives a stack trace it cannot reason about. It retries the same call, fails the same way, and burns the turn.
The generated runtime never returns a raw exception. A failure is a result with isError set and a stable, machine-readable shape: a code, a short message, and enough context for the agent to correct course. A validation failure tells the agent which field was wrong and why, so the next attempt can fix it rather than repeat it.
// server -> client, structured error
{
"jsonrpc": "2.0",
"id": 8,
"result": {
"content": [
{ "type": "text", "text": "Invalid arguments: 'email' is required." }
],
"structuredContent": {
"code": "invalid_arguments",
"field": "email",
"reason": "missing_required"
},
"isError": true
}
}
The distinction the spec draws is the one that matters: a protocol-level problem (an unknown method, a malformed request) is a JSON-RPC error object, while a tool that ran and failed is a normal result with isError: true. The engine respects that line. The agent learns to read isError and the code, and a failed call becomes information instead of a dead end. The same structured shape is how the security plane in chapter six returns a rate-limit refusal: a reasoned rate_limited code, not a bare 429.
Streaming partial results
Some bound operations are slow: a list over a large collection, a REST call that fans out. On stdio the call simply takes longer. On streamable HTTP the runtime uses the SSE channel to emit progress notifications while the work is in flight, then sends the final result on the same logical response. The agent sees the operation advancing rather than a silent socket, and the closing envelope is the same structured result a fast call returns. Streaming changes the pacing, not the shape.
One artifact, every client
Here is the payoff, and the reason the runtime is a single subsystem rather than one per integration. The protocol is client-agnostic. The handshake, the tool list, the call shape, and the result envelope are identical no matter who is on the other end. So the one server the engine compiled answers all of them with no per-client code.
- Claude Desktop launches it over stdio from a config entry.
- Cursor points its
mcp.jsonat the same artifact. - ChatGPT, Goose, Cline, and n8n connect over the hosted HTTP transport with a URL and a token.
Each client supplies its own transport and auth wiring. The server does not learn, and does not need to learn, which client it is talking to. That is the whole bet of building on a standard: the reference TypeScript SDK and Python SDK implement these same primitives, and because the engine emits a server that honors them faithfully, "works with one MCP client" and "works with every MCP client" are the same sentence.
The contract from chapter two and the binding from chapter three were inert until now. The runtime is what gives them a voice, and it is the same voice for every server the engine ships. Next, chapter five turns to the part the runtime serves but does not author: the model-facing contract itself, and how to write a tool an LLM will actually choose to call.