Typed Conversations: Make Illegal Agent Dialogues Unrepresentable
Session types for MCP — from a Rust server to a Flutter client.
One Contract. Three Languages. Zero Illegal Dialogues.
One 56-line contract file describes a booking dialogue: the phases, the edges between them, the form each phase puts in front of the user. Code generation turns it into Rust typestate for the server, Dart sealed classes for a Flutter client, and the JSON Schema the server publishes — 905 lines nobody writes, and nobody maintains, and CI fails if the three ever drift. Next to it sit eight dialogues that must not compile, each with the compiler's refusal committed as a golden file and rendered into this article's figures. Part 2 of the Typed Agents series — Part 1, Stateless Servers, Stateful Payloads, is background, not a prerequisite.
Source Code: github.com/zs-dima/typed-conversations
Written against: MCP 2026-07-28 · rmcp =3.1.1 · Rust 1.97 · Dart 3.12 · conformance 0.2.0-alpha.10
TL;DR (For Skimmers)
- The 2026-07-28 MCP revision types every message harder than ever and the order of a dialogue not at all: the phase now lives inside
requestState— "an opaque string meaningful only to the server" (MRTR Server Requirement 3) — checked at runtime, if the server remembers to check - Here the phase is a type. Answering the same round twice, answering the wrong round, reading the result early, half-handling a resumed dialogue — eight illegal dialogues that do not compile, their refusals committed as compiler output and gated in CI by
trybuild, tests that assert a program fails to build - One contract file generates the Rust typestate, the Dart sealed classes, and the schemas the server publishes;
make contract-checkfails if regeneration is not a no-op - The phase crosses the wire through exactly one runtime check, and it is cryptographic: verify the seal, then one function turns the tag back into a type and returns a sum the caller must match in full
- The limits ship with tests where a test is possible: Dart rejects the unhandled case, not the illegal move, and the seal bounds replay — a kept state forks the dialogue into two valid bookings, and the committed test proves it
- One dialogue survives two processes, two server instances, and a QR code: 144 bytes of opaque state, against the 2,953-byte QR limit
- Servers are increasingly written by agents; a contract the compiler enforces is code review that scales — the model can misorder a round, and the illegal dialogue does not compile
If you read one section, read the gallery — eight dialogues the compiler refuses, its refusals committed as output. If you read two, add the boundary — the one place the types meet the bytes.
The Phase of a Dialogue Is a String in a Blob Now
The official Rust SDK example for MCP's headline feature carries a dialogue bug no schema can catch. examples/servers/src/mrtr.rs writes the phase of its dialogue — "awaiting": "city" — into the requestState blob, opens the blob on the retry, binds it to _state, and never looks at it: the phase is written, carried, and consulted nowhere.
The user's answer is then read without checking action — so a refusal arrives as an answer. An ElicitResult — the user's reply to an elicitation, the server's structured question to the human — carrying action: "decline" and a leftover content is well-formed on the wire, and is treated here as the answer; a clean decline, carrying nothing at all, falls through an unwrap_or into "your area" — an answer nobody gave. Both are dialogue bugs, not message bugs.
An example, not production code, and this article treats it as one — but it is the shape people copy, and it has been since the file's first commit: one commit in its history, byte-identical at every tag from rmcp-v3.0.0 through rmcp-v3.1.1, and on main when this was read on 2026-08-07. Reported upstream as modelcontextprotocol/rust-sdk#1147, open at the time of writing.
The bug is structural, not careless. Under MRTR — multi round-trip requests, the revision's replacement for every server-initiated request — a tools/call that needs your input returns an InputRequiredResult carrying inputRequests and a requestState; the client echoes the state back with its answers, and any replica picks the dialogue up — the retries are "completely independent: the server processing the retry does not need any information beyond what is directly present in the retry request" (Spec 2026-07-28, MRTR Basic Workflow). So everything the dialogue is, including which round it is waiting on, rides in an envelope the spec hands entirely to the server: "this field is an opaque string meaningful only to the server. Servers are free to encode the state in any format (e.g. base64-encoded JSON, encrypted JWT, serialized binary)" (Server Requirement 3).
And the same revision removed the one protocol-level correlation identifier there was, telling servers where to put their own instead. Changelog, verbatim:
"Remove thenotifications/elicitation/completenotification and theelicitationIdfield of URL mode elicitation requests, both introduced in2025-11-25… Servers needing to correlate an elicitation across retries encode their own identifier inrequestState."
Which round the dialogue is in: a string inside a blob. Whether the answers match the round: whatever comparison the server remembered to write.
This is where servers actually fail, and not anecdotally. A Taxonomy of Runtime Faults in Model Context Protocol Servers (arXiv 2606.05339, June 2026) analysed 837 fault threads across 473 MCP server repositories into 11 categories, 27 subcategories, 73 leaf fault types — and survey respondents "reported experiencing an average of 20 of the 27 fault subcategories". Two of those subcategories are State Transition — faults that "affect the correct progression of an active MCP session after initialization" — and Session State, whose reported cases include one where "incorrect or missing session identifiers led to inconsistent request correlation across streaming interactions". The study predates this revision by nearly eight weeks, and MRTR did not retire that fault class — it relocated it, from a session the server tracked to a blob the server now parses by hand. Every message in those failing dialogues can be schema-valid. The schema constrains the shape of each message; nothing in the protocol constrains the order they arrive in.
Part 1 measured where that blob may live — replica memory, a store, or the wire — and what each home costs. It closed on the limitation that sets up this article: a phase can be a type in memory, but types do not survive serialization, and on the wire the phase is a string again. Making it survive is this article. One contract file moves the order of a dialogue into the type system, and answering the wrong round, answering twice, or asking for the result before the dialogue ends stop being errors to catch. They become programs that do not exist.
One of the eight — answering the same round twice, refused at compile time. Rendered from committed compiler output; the gallery below explains the pipeline.
And the audience for that refusal is changing. MCP servers are increasingly written by agents rather than by people — and a model gets a dialogue wrong the way the example above does: not in the shape of a message, but in the order of the rounds and the handling of a refusal, inside generated handlers nobody reads end to end. A contract that makes the illegal dialogue uncompilable is code review that scales: the model can still write the wrong transition, and the program it wrote does not exist. What a human still reads whole is the contract, and only the contract.
Session Types, Without the Greek Letters
The idea is thirty years old: give the conversation itself a type. Session types began with Kohei Honda's work in the 1990s, grew into multiparty protocol descriptions (Scribble), and stayed mostly in research languages and papers while industry typed messages and left the order to prose. In 2026 the nearest neighbours to "session types for agent protocols" look like this:
| Nearest thing found | What it is | Why it is not this |
|---|---|---|
mcpkit (Rust) |
"Typestate builders for compile-time validation of server configuration" | typestate on server configuration (spec 2025-11-25), not on the dialogue |
| arXiv 2604.02369 | a semantic view of agent communication protocols | no types, no implementation |
| The LLMbda Calculus (arXiv 2602.20064v1) | a formal calculus of LLM agent conversations — prompts, responses, information flow | names session types as a possible foundation and defers them — in v1; the v2 revision dropped the subsection |
The implementations exist — ferrite-session and rumpsteak as Rust research crates, NEST compiling session types into network monitors — and the matrix at the end of this article prices all three against this repository.
The LLMbda Calculus — a lambda calculus for LLM agents, with machine-checked noninterference — closes its related work with the whole of what the literature says on the subject: "There may be a connection between our conversations—alternating sequences of prompts and responses—and session types. Exploring session types as a foundation for typing LLM interactions is left as future work" (arXiv 2602.20064v1, §VII.G — citing the same ESOP'98 paper this article's reading list opens its session-types line with). The v2 revision of July 2026 dropped that subsection, so the citation pins v1 (checked 2026-08-07). The field belongs to formalists, agent protocols belong to practitioners, and the bridge between them is a sentence that lasted one revision. This repository is that future work, in the small — a footbridge: not full session types, a finite phase graph with one back edge, but running, cross-language, and honest about the difference.
The mechanism it stands on is typestate: the phase of a value carried in its type, transitions as methods that consume the value and return the next phase. Phantom types exist in Scala, TypeScript, Swift; what Rust adds is move semantics, and that addition changes the kind of guarantee. Elsewhere, using a stale session is a failed check or a branded-type trick a cast can defeat. In Rust, the transition takes self by value — after it, the old session is not invalid, it is gone:
pub struct Session<P: Phase> { /* … */ }
impl Session<AwaitingTrip> {
// Consumes the session. There is no way to keep the old phase.
pub fn respond(self, answers: TripAnswers) -> Session<AwaitingParty> { /* … */ }
}
Nothing checks anything at runtime here. The illegal program is not rejected; it cannot be written. The honest counter-example ships in this same repository — Dart, which has no linear types, gets a different (weaker, still useful) guarantee, and a later section draws that line rather than blurring it.
One File Describes the Dialogue
The booking dialogue from part 1, described once, in contract/booking.dialogue (comments trimmed):
dialogue Booking {
tool "book_trip"
opens BookTrip
phase Idle -> AwaitingTrip
phase AwaitingTrip asks Trip -> AwaitingParty
phase AwaitingParty asks Party -> AwaitingConfirmation
// The only branch in the dialogue, and the only back edge. Declining the
// quote does not end the call: it asks for the trip again.
phase AwaitingConfirmation asks Confirm -> Confirmed { Booked: Done, Amend: AwaitingTrip }
// Terminal: a result, and no requestState.
phase Done yields Booking
ask Trip "Where are you going, and when?" {
destination : string "Destination airport"
dates : string "Outbound and return"
}
ask Party "Who is travelling?" {
travellers : int(1..9) "How many travellers"
cabin : enum(economy, business) "Which cabin"
}
ask Confirm "Shall we book it?" {
confirm : bool "Confirm the quote"
}
message BookTrip {
origin : string
}
message Booking {
booking_ref : string
quote_cents : int
}
}

A phase reads as an edge, because a contract scanned in an article should look like the graph it describes. Four things the contract never states, because saying them twice is how they get out of step — each is derived:
- the opening phase is the first declared;
- the terminal phase is the one that
yields; - a phase is sealable — carried in a
requestState— exactly when it asks something: the opening phase never is (nothing has called it yet), the terminal one never is (it returns a result instead); - a transition with one target simply returns that phase — the return type is where you land, and only the branch generates a sum.
Two design decisions carry most of what follows:
One phase per round. A single generic AwaitingAnswers phase would be easier to generate — and would prove almost nothing: the "resumed dialogue" sum would have one variant, exhaustive matching would be vacuous, and "answers for the wrong round" would stay a runtime comparison. With a phase per round, each phase accepts only its own answer type, and part 1's ScenarioError::AnswerMismatch — two sorted key lists compared while serving the request — becomes a type error before the program runs. It is also the truthful model of a three-round dialogue.
The compiler behaves like one. Every phase named as a target must be declared; exactly one terminal phase; every phase reachable; no name collides across languages; int(1..9) must contain a value. A violation is a parse-time error naming the line the declaration is on — anything less and the failure would surface as a Rust error inside generated code, pointing at the output instead of the contract. The bounded integer earns its place twice: it becomes minimum/maximum in the published schema and a range check in the generated parser. The schema tells a well-behaved client what to send; the parser decides what the server believes.
The Generated Typestate
cargo build turns the contract into the server's phase algebra (abridged from the generated Rust; the full file is committed as the emitter's golden test):
/// A dialogue whose phase is part of its type.
///
/// The marker is `PhantomData<fn() -> P>` rather than `PhantomData<P>`: a phase
/// is a name, not a value, so the session must not inherit its auto traits,
/// drop-check behaviour or variance.
///
/// Not `Clone`, and that is load-bearing.
pub struct Session<P: Phase> {
state: DialogState,
phase: PhantomData<fn() -> P>,
}
impl Session<Idle> { pub fn call(self, args: BookTrip) -> Session<AwaitingTrip> { /* … */ } }
impl Session<AwaitingTrip> { pub fn respond(self, answers: TripAnswers) -> Session<AwaitingParty> { /* … */ } }
impl Session<AwaitingParty> { pub fn respond(self, answers: PartyAnswers) -> Session<AwaitingConfirmation> { /* … */ } }
impl Session<AwaitingConfirmation> { pub fn respond(self, answers: ConfirmAnswers) -> Confirmed { /* … */ } }
impl Session<Done> { pub fn result(self) -> Booking { /* … */ } }
/// Where the `AwaitingConfirmation` round lands.
pub enum Confirmed {
Booked(Session<Done>),
Amend(Session<AwaitingTrip>),
}
Phase is a sealed trait, so no foreign phase can join from outside the crate. result is defined exactly once, and only for Done — that restraint is what makes the E0599 figure below read as a sentence instead of a candidate list.
And the phases are declared longhand, no macro: rustc reports at the definition site, and a macro_rules! would make every diagnostic in the gallery point at pub struct $name;. The diagnostics are the article's illustrations; readability is a requirement, not a nicety.
What stays hand-written is logic.rs: pure functions over DialogState — record the trip, price the party, honour the agreed quote — returning the next state; the branching round adds which way it went, and the finish returns the Booking itself. None of them names a phase, takes a Session, or knows which round it runs in, and a test gate (crates/dialogue/tests/discipline.rs) keeps it that way. The division of labour is the design in one sentence: a human can write a state update; a human cannot write a transition. The contract holds the monopoly on the phase graph.
The Gallery: Eight Dialogues That Do Not Compile
Every claim in this article about what the compiler catches has a trybuild case behind it, in crates/illegal-dialogues. The compiler's refusals are committed as .stderr files; make ui fails if an illegal dialogue ever compiles or stops failing identically, on a toolchain pinned by rust-toolchain.toml; make figures renders the committed output into the SVGs below, and CI re-renders and diffs them. A rustc release that rewords a diagnostic therefore surfaces as a diff to be read at the pin bump — never as a silently stale picture in a published post. These are not screenshots.
| Illegal dialogue | Error | What it proves |
|---|---|---|
double_respond.rs |
E0382 use of moved value | answering the same round twice is unspeakable |
answers_for_the_wrong_round.rs |
E0308 mismatched types | part 1's AnswerMismatch, moved from runtime to compile time |
result_before_done.rs |
E0599 | the result exists only when the dialogue does |
respond_after_done.rs |
E0599 | messaging a closed dialogue is unspeakable |
resume_without_match.rs |
E0004 non-exhaustive patterns | a phase from the wire must be handled in full |
seal_a_finished_dialogue.rs |
E0277, authored message | a finished dialogue cannot be offered back as a continuation |
clone_a_session.rs |
E0599 | the mechanism itself: a duplicated session answers one round twice, which is the first row |
elicit_without_capability.rs |
E0277, authored message | MRTR Server Requirement 7, in the type system |
Eight files, and not one of them contains a check, a guard, or an error handler. Nothing in this section ever runs.
Answering twice. The transition consumed the session; the second call has nothing left to consume. Nothing checks for this — replaying a round is not forbidden, it is inexpressible:
let session = Session::new().call(BookTrip { origin: "TLL".to_string() });
let _first = session.respond(answers.clone());
let _second = session.respond(answers);
Answering the wrong round. Each phase accepts only its own answers. In part 1 this same mistake was a runtime error on a server that had already accepted the call; here there is nothing to compare:
Asking for the result before the dialogue ends. The error names the phase the session is in and the phase it would have to be in — the message contains the protocol:
That one-line note — "the method was found for Session<dialogue::Done>" — reads cleanly only because result is defined once in the whole crate. Its mirror image is messaging a dialogue that already finished, where the candidate list is the better message, because the candidate list is the protocol — respond exists in exactly the three phases that are waiting for something:
Half-handling a dialogue that came back from the wire. This is the case that makes the boundary honest — everything above is about a dialogue held in memory; this one is about a dialogue reconstituted from an opaque blob a client echoed back. resume returns a sum, so forgetting a round is not a branch that never runs; it is a program that does not compile:
Offering a finished dialogue back as a continuation. A requestState is what a round leaves behind; a booked dialogue has no next round. seal is bounded to the phases a round can end in, and the diagnostic here is authored with #[diagnostic::on_unimplemented] (stable since Rust 1.78), because the message rustc would produce on its own names a trait the reader has no reason to have heard of:
Authoring that diagnostic taught two rules worth stealing. Author the message where the native one would be true but unhelpful — Done is a phase, and still not something a requestState can carry — and let rustc speak where its own words are already the whole explanation. And mind where the bound sits: on the impl, an unsatisfied bound is a method-resolution failure and rustc ignores the attribute entirely; on the method, it is an ordinary obligation and the reader gets your sentence. Session::seal carries where P: Sealable on the method for exactly this reason.
Cloning a session. The case the first one stands on. Move semantics is the entire enforcement mechanism for answering twice, and session.clone().respond(a) followed by session.respond(b) would answer one round twice, in two different ways, and compile. The rest of the gallery stands on a phase's type and would survive a clone; this one would not, and it is the one holding the mechanism up. So Session does not derive Clone — and because an absent derive looks exactly like an oversight some well-meaning patch will fix, there is a trybuild case standing on the absence:
Removing the derive also repaired the main figure: with Clone in place, rustc had been appending "consider cloning the value" to the double-answer error — a workaround hint on a program that must not exist.
The eighth case belongs to the server section below, because the thing it proves is about a peer, not a phase.
The Boundary: One Runtime Check, Cryptographic, Returning a Sum
A Session<AwaitingTrip> held in memory is safe by construction; a requestState on the wire is bytes, which are not typed. So the claim of this repository is not "no runtime checks". The claim is narrower and stronger: there is one such check; it is a cryptographic verification, so it fails closed rather than degrading; and it returns a sum type, so a caller that matches on the phase — with no fallback arm, which is how a reviewer can tell the protocol is being read — has to name every phase the wire can produce.
Sealing is &self — the session is still owned afterwards, which matters for error recovery below — and is bounded to sealable phases:
impl<P: Phase> Session<P> {
pub fn seal(&self, codec: &RequestStateCodec, binding: &Binding, ttl: Duration)
-> Result<RequestState, RequestStateError>
where
P: Sealable,
{ /* … */ }
}
No cryptography is hand-rolled here: the codec is rmcp 3.1.1's own RequestStateCodec (rs1. format — HMAC-SHA256, domain-separated, constant-time tag comparison, expiry checked after the tag). What this repository adds is the binding, composed per Server Requirement 5 — servers "SHOULD include… the authenticated principal… a short expiry (TTL)… an identifier for the originating request, e.g. the method name and a digest of its salient parameters" (Spec 2026-07-28):
pub struct Binding(Vec<u8>);
impl Binding {
/// Composes the binding for one call.
pub fn new(principal: &str, method: &str, tool: &str, arguments: &[u8]) -> Self { /* … */ }
}
The composition is principal ‖ "tools/call" ‖ tool name ‖ sha256(canonical arguments), each field length-prefixed so no two different tuples produce the same bytes. It rides as the codec's associated data — authenticated, never stored — and it works because an MRTR retry repeats the original request parameters, so the digest is stable across every round of one dialogue and different for any other. The TTL is deliberately a server flag with a stated default, not a constant: Requirement 5 wants "a short expiry", while SEP-2322's own rationale concedes an elicitation answer may take "days or months, or maybe even never". No value satisfies both; the tension is stated, not hidden.
The other direction is the single most important function in the repository:
/// Opens an echoed `requestState` and turns its tag back into a type.
///
/// **The only function that reads `phase` out of an envelope.**
pub fn resume(state: &RequestState, codec: &RequestStateCodec, binding: &Binding)
-> Result<Resumed, ResumeError>
{ /* verify the seal, then match the tag */ }
pub enum Resumed {
AwaitingTrip(Session<AwaitingTrip>),
AwaitingParty(Session<AwaitingParty>),
AwaitingConfirmation(Session<AwaitingConfirmation>),
}
Verification comes first; the phase tag is read only from a payload that authenticated. Requirement 4 says why, and says it conditionally: servers "MUST treat requestState as an attacker-controlled input", and "if requestState influences authorization, resource access, or business logic" they "MUST protect its integrity (e.g. HMAC or AEAD) and MUST reject state that fails verification" (Spec 2026-07-28). The escape hatch it offers — integrity "MAY be omitted only when tampering can cause nothing worse than request failure" — is not available here: the blob names the phase and carries the agreed price, so tampering with it books a trip at a number nobody quoted.
Resumed carries only the sealable phases, derived from the contract rather than listed by hand, so it cannot rot when a phase is added. A verified envelope naming idle or done is refused as NotResumable — it authenticated, so it was written with our key, and no round of this dialogue can have written it.
And because resume returns a sum instead of a session, the E0004 case above applies to every caller that reads the phase: a match with no fallback arm has covered the protocol or it has not compiled. A wildcard still opts out — the compiler cannot stop somebody writing _ => {}, in Rust any more than in Dart — but it opts out visibly, in the one function where a reviewer is already looking.
A test gate holds the emitter to that comment: resume is the only function that turns a tag into a type.
The end-to-end claim has its own test file, across_the_wire.rs: a dialogue sealed after every round, dropped, and resumed by a fresh codec — two of those are two servers that merely share a secret — reaches the identical booking as one that never left memory, and the amend cycle survives the wire like any other edge. The helper that makes it honest takes the session by value and destroys it after sealing: that drop is a stateless server finishing a request, not tidiness.
A Server That Cannot Answer Out of Phase
The handler (crates/server/src/handler.rs) is written by hand. Until rmcp 3.1.1 there was no choice: the #[tool] macro had no way to reach request_state and input_responses — the two fields MRTR is made of — and 3.1.1 added extractors for both (PR #1104, published 2026-08-05). The reason that outlives that release is the schema: this tool publishes the inputSchema and outputSchema the contract emitted, and the macro derives them from a Rust type instead. Its whole shape:
resume(requestState) ──▶ match Resumed { … } ──▶ parse answers
│
┌─────────────────────────┴──────────┐
Err(should_reask) Ok(answers)
│ │
re-seal the same phase transition, seal the next
match resumed {
Resumed::AwaitingTrip(session) => match TripAnswers::parse(responses) {
Ok(answers) => self.ask(&session.respond(answers), forms::PARTY, &binding, proof),
Err(error) => self.reask(&session, forms::TRIP, &binding, proof, &error),
},
// AwaitingParty: the same shape. AwaitingConfirmation: the same parse,
// then a match on where Confirmed lands — each arm able to call only
// the transition its phase has.
…
}
Two properties fall out of that order, not out of special cases:
The phase cannot be wrong. Every arm names one phase, and each arm can only call the transition that phase has. There is no branch that could read the wrong round's answers, because there is no way to write one — that is the E0308 figure, wearing a server.
A failed parse does not consume the dialogue. The spec prescribes a recovery: "If the client fails to send all the information requested in a previous InputRequests, and the missing information is necessary for the server to process the request, the server SHOULD respond with a new InputRequiredResult requesting the missing information again, rather than returning an error" (Spec 2026-07-28, MRTR Error Handling). Here that clause costs nothing to obey: parsing happens before the transition, so the session is still owned and can be re-sealed for the same round. The alternative — a session consumed by a parse that failed — is a program this crate cannot write. The recovery falls out of ownership.
One reading in that path is a judgement call, made in the open. The elicitation spec defines the two refusals apart — "Decline (action: "decline"): User explicitly declined the request" versus "Cancel (action: "cancel"): User dismissed without making an explicit choice" (Spec 2026-07-28, Elicitation, Response Actions) — and this server re-asks on a decline (the information is still missing and still necessary) but returns an error on a cancellation.
The spec's own non-normative suggestion — "Handle dismissal (e.g., prompt again later)" — assumes a later that a server keeping no session does not have; inside the one call that is waiting, "later" could only mean asking a person who just closed the dialog to close it again. The error hands the next move back to the caller, which is the closest thing to "later" a stateless server has.
Untyped JSON is quarantined the same way. InputResponses is a BTreeMap<String, Value> that records declines too, and exactly one function on the booking path — accepted_content, in crates/dialogue/src/wire.rs — reads action before anything reads content. The refusal-as-answer bug from the opening is structurally impossible to repeat here, because the generated parsers are that function's only callers.
The eighth gallery case guards the other direction — what the server may send. Requirement 7: servers "MUST NOT send an inputRequests that the client has not declared support for" (Spec 2026-07-28). The form builder demands a proof — a zero-sized Capability<Elicitation> witness whose only constructor reads the capabilities the client declared.
There is no handshake left to read them from: this revision deleted initialize too, so a client declares itself on every request, in _meta.io.modelcontextprotocol/clientCapabilities. A server that skipped the check has nothing to pass:
A client that declared nothing is refused before any dialogue exists, with the revision's own code for exactly this refusal — MissingRequiredClientCapabilityError, -32021 (Spec 2026-07-28) — naming the missing capability.
And because a compile-time witness is worth little if the server it guards misreads the wire, the server runs against the official conformance suite (@modelcontextprotocol/conformance 0.2.0-alpha.10, --spec-version 2026-07-28, over Streamable HTTP): the MRTR elicitation scenarios pass, with everything out of scope named in a committed baseline, and the repository says exactly that rather than more.
The whole thing runs in one command, no docker, under a minute:
$ make demo
── book_trip ────────────────────────────────────────────────────
trip: Where are you going, and when?
trip: {"dates":"2026-09-01/2026-09-08","destination":"LIS"}
party: Who is travelling?
party: {"cabin":"economy","travellers":2}
confirm: Shall we book it?
confirm: {"confirm":true}
booked BK-6CDA93 for 700.00
cargo run -p client -- demo --amend walks the contract's back edge (decline the quote, watch the dialogue return to the trip round); --refuse declines a form once and shows the server asking the same round again.
The Same Contract in Dart, Honestly
The second emitter writes Dart sealed classes for the Flutter client:
sealed class BookingPhase {
const BookingPhase();
String get tag;
}
final class AwaitingTrip extends BookingPhase {
const AwaitingTrip();
AskForm get form => trip; // the generated form for this round
@override
String get tag => 'awaiting_trip';
}
// AwaitingParty, AwaitingConfirmation: the same shape.
final class Done extends BookingPhase {
const Done(this.result);
final Booking result; // the result exists only on Done
@override
String get tag => 'done';
}
What this buys is exhaustiveness. A switch expression over BookingPhase with no fallback arm compiles only while it covers every phase, so a contract that grows a round breaks every incomplete switch in the client at analysis time:
String label(BookingPhase phase) => switch (phase) {
Idle() => 'not started',
AwaitingTrip() => 'where to?',
AwaitingParty() => 'who is travelling?',
AwaitingConfirmation() => 'confirm the quote',
Done(:final result) => 'booked ${result.bookingRef}',
};
That is the language, not a lint profile: non-exhaustive switches over a sealed type are already errors under stock Dart 3.12 with no options file at all; the project's analysis_options.yaml names them anyway, pinning the severity against a future default rather than raising it. One committed test is the same shape — a switch over every phase with no fallback arm; it passes by running, but it compiles only while the switch is exhaustive, which is the point.
Two smaller mirrors of the Rust side. phaseFromTag answers only the sealable phases, so the opening and terminal phases cannot start arriving from the wire unnoticed (a second test stands on that).
And the requestState is an extension type with no implements String — reading it takes a deliberate .value — because "Clients MUST NOT inspect, parse, modify, or make any assumptions about the requestState contents" (Spec 2026-07-28, MRTR Client Requirement 2). The type carries the sentence a comment would.
And here is the line this repository refuses to blur, stated in docs/LIMITS.md and repeated here because averaging the two halves would be the one dishonest sentence in the project: Dart has no linear types. Nothing stops a Dart value being used twice, so the Rust guarantee that an illegal transition does not compile has no Dart equivalent. Rust rejects the illegal move; Dart rejects the unhandled case. Different guarantees, both real, and the contract generates the strongest one each language can keep.
That second guarantee is not Dart's alone. TypeScript — the language of MCP's reference SDK — reaches it with a discriminated union and a switch whose default arm assigns the scrutinee to never: add a phase and every incomplete switch stops compiling. The same guarantee as Dart's, for the same reason — TypeScript has no linear types either, so the illegal move stays out of reach. This repository emits no TypeScript and docs/SCOPE.md names three outputs rather than four, so that emitter is a port somebody else would write; but if you are holding a TypeScript server, the transferable claim of this article is the Dart one.
One more line inside that one, because it is the kind of boundary that erodes when nobody writes it down: Dart's exhaustiveness is over phases. The contract's branch generates Confirmed and ConfirmedDecision in Rust and generates nothing in Dart, so adding a phase breaks every incomplete switch in the client and moving an edge breaks none of them. docs/LIMITS.md carries that one too.
Two build-hygiene decisions are load-bearing enough to name. The generated Dart is committed (a Dart checkout must build without a Rust toolchain), and make contract-check fails CI if regeneration is not a no-op — that gate is what makes "one contract, three languages" a fact rather than a claim. And the generated library is deliberately not excluded from analysis, inverting the usual convention: with it excluded, nothing anywhere had ever compiled the emitter's Dart output. The analyzer, under strict-casts, strict-raw-types and strict-inference, is the gate on the Dart emitter. A generated file nobody analyses is a generated file nobody has compiled.
The transport is mcp_dart 2.4.0 — and transport only. The SDK can drive the whole MRTR retry loop itself; the app deliberately does not let it, because holding the requestState between rounds is what "Save & continue elsewhere" writes into the QR code. Each round is one explicit tools/call, built the way the SDK builds its own: original arguments again, plus the answers and the echoed state.
Reattach: A Dialogue Carried by a QR Code
The scene that ties both articles together, from the committed target (make scene-reattach), verbatim:
── reattach ─────────────────────────────────────────────────────
key TC_STATE_KEY (or the in-process demo default)
handover target/reattach-states/handover.txt
[process A] opened the dialogue, saved requestState, exiting
carried 144 bytes of opaque requestState
fits a QR code 20 times over (byte-mode limit: 2953)
[process B] finished it: BK-6CDA93 for 700.00
The second process built its own server, which had never seen
this dialogue, and finished it from the string alone.
Two OS processes, two BookingServer instances, and nothing shared but the signing key and a file with a string in it. No store, no affinity, no coordination — the pattern working exactly as specified, since the retry is "completely independent" of the request that preceded it. The scene asserts the booking and asserts the second instance never saw the first round; it runs anywhere Rust does, and CI runs it on every push.
Between devices the string travels by QR code — a relay server would reintroduce the server-side state this whole series is about removing. QR byte mode tops out at 2,953 bytes (version 40, level L); this dialogue's mid-flight state is 144 bytes, twenty times under the limit. The headroom is real but not free: part 1 measured continuations reaching 923–1,590 bytes by round ten across its rigs and encodings, so a chattier dialogue starts crowding the code — part 1's growth curves price the budget.
The Flutter client makes the handoff physical: Save & continue elsewhere parks the dialogue as a QR on screen; a phone reads it with the camera; on desktop the second window reads it off the first window's screen (a Win32 BitBlt capture feeding a platform-free decoder). make video records the whole scene with no hands in it — a scripted autopilot drives both windows, reports through log markers, and the target fails if the second window never reports a booking, so the recording is a test, not a performance.
The app counts its own payload, and counts a different one: it parks at the confirmation round rather than the opening one, and puts the call's origin in the code beside the state, so the figure under its QR is 334 bytes — eight times under the limit rather than twenty, and measured the same way.

Two different guarantees meet in that transcript, and they must not be averaged. That the state which came back is ours, unmodified, in date, from the same principal and the same call is the seal — the one runtime check, cryptographic, fail-closed. That the dialogue it carries can only continue legally is the types — resume hands back a sum, and every path out of it is a generated transition. The scene is the picture; the compiler is the argument.
Two SDKs, Three Sentences of Specification
The contract's third output — the JSON Schema the server publishes — exists in two dialects, because the spec has two. A tool's inputSchema/outputSchema are full JSON Schema 2020-12 (SEP-2106 lifted them there). An elicitation's requestedSchema is not: "The requestedSchema parameter allows servers to define the structure of the expected response using a restricted subset of JSON Schema. To simplify client user experience, form mode elicitation schemas are limited to flat objects with primitive properties only" (Spec 2026-07-28, Elicitation, Requested Schema). The spec's examples show the top level as type, properties, required — and never enumerate the forbidden keys.
The two SDKs fill that silence differently, in both directions. rmcp 3.1.1 models the subset as type, title, properties, required, description — unchanged from 3.0.1, where this was found. mcp_dart 2.4.0 allows $schema, type, properties, required — and throws on anything else. Each permits a key the other does not, and they disagree about what to do with the rest: the strict one refuses the request, the permissive one drops the key and carries on. Neither violates anything, because a subset described by what it permits has no closed list to violate.
The emitter originally shipped one dialect for all three outputs, and the strict client found the bug on the first connection:
ElicitRequest.requestedSchema contains unsupported fields: title
The fix — emit the intersection both SDKs accept, exactly type, properties, required, with description, enum, minimum, maximum staying legal inside a property, which is what keeps int(1..9) worth declaring — is less interesting than what the investigation turned up. Of the three stray top-level keys the emitter wrote, only title had ever reached a client. The other two were being silently deleted in transit: the server parses the emitted string into rmcp's typed ElicitationSchema, which has no deny_unknown_fields, so serde dropped what the model did not know and re-serialised the rest. A committed file describing a request the server never sends, guarded by a type that ignores what it does not understand. The gate that now exists round-trips every form through exactly the parse the server performs and compares the result against the committed string — it would have failed the day the drift began.
Two SDKs, disagreeing about the same three sentences of specification, and the strict one found the bug. The sentence to keep from that afternoon: a type that ignores what it does not understand is not a validator, and code generation must not lean on one as if it were.
What the Types Do Not Catch
docs/LIMITS.md was written early, on purpose: a demonstration that only lists its wins is not a demonstration. Nine limits; five name the test that checks them, four say plainly that nothing does and why. The ones that matter most:
A state can be presented more than once. The seal binds principal, method, tool, arguments digest, and stamps a TTL — everything Requirement 5 asks for. The spec then says plainly what those measures do not buy, in the warning box that part 1 identified as the section's only MUST:
"Note that these measures bound the replay window and prevent cross-user and cross-request reuse, but do not by themselves guarantee single-use. Servers for which a given requestState must be consumed at most once (e.g., one-time redemptions) MUST enforce that invariant server-side." — Spec 2026-07-28, MRTR Server Requirement 5, WarningThis demo does not enforce it — that would smuggle back the session store part 1 measured — and the committed test an_earlier_round_presented_again_forks_the_dialogue drives the consequence end to end: keep the state issued after round one, present it twice, answer differently, and one dialogue forks into two valid bookings at two prices (700.00 economy, 1,150.00 business, references differing):

The types have nothing to say about this, and that is the boundary drawn exactly: both branches are legal dialogues, and each is legal in the same way. Session types constrain the shape of a conversation, not how many times it happens. Part 1's time-travel fork, revisited with the compiler watching — and shrugging, correctly.
And mind who carries the envelope — part 1's question, sharper here. The spec's client-side warning is about tampering: "Because requestState passes through the client, malicious or compromised clients could attempt to modify it to alter server behavior, bypass authorization checks, or corrupt server logic" (Spec 2026-07-28, MRTR Security Considerations) — and tampering is what the seal answers. What no seal answers is which valid envelope comes back, and on the other end of an agent dialogue the courier is a model: it holds the state between rounds and decides what to echo on the retry. It does not have to be compromised; it has to be persuaded to hand back the earlier one — the fork above, arriving through the courier instead of through a kept file.
The types do not check semantics. TripAnswers { destination: "LIS", … } type-checks whether or not the traveller ever said Lisbon, whether the dates are in the past, whether the route exists. The elicitation schema constrains shape, the phase constrains order, and the truth of the content is somebody else's problem. This boundary does not move.
The phase graph is finite and known in advance. Enough for a dialogue whose shape is decided before it starts — which is what most tool calls are. Not enough for one whose shape is decided while it runs: a server asking a model-chosen number of follow-ups is expressible only as a self-loop with a counter, which type-checks and proves almost nothing. Full session types also cover multiparty protocols, participant-driven branching, and delegation; this is a single client and a single server, and the matrix below says so rather than gesturing past it.
A witness proves what it proves. Capability<Elicitation> is a type-level proof of Requirement 7's sentence and no more: elicitation has modes (form, URL), and a client that declared only URL mode would satisfy the type and still be sent a form. And the witness guards a builder, not a process — AskForm::request is the only way to build the request from the contract, but rmcp's constructors are public, and this repository's own conformance fixtures reach past the witness on purpose. A capability witness makes the checked path the convenient one, not the only one; what it buys is that the unchecked path has to be written on purpose, where a reviewer can see it.
The Matrix: What Is Caught, and When
Seven ways to keep an agent dialogue on the rails. The two research crates are assessed from their documentation, not from running them, and this table says so.
| Approach | What is caught | When | Cross-language | A failure reads as | Cost |
|---|---|---|---|---|---|
| JSON Schema 2020-12 (MCP baseline, SEP-2106) | the shape of each message | runtime, per message — if someone validates | ✅ language-neutral | a validation error | none — and the order of messages is never checked |
| Runtime phase checks in the handler (the official example's shape) | whatever the author remembered | runtime, mid-request, after the call was accepted | ❌ re-written per server | a protocol error — or silence, as in the action case |
low to write, unbounded to audit: every read site is a check site |
| AG-UI typed events | the event vocabulary (28 documented event types, counted 2026-08-07) | compile time, per event payload | TypeScript-first | a type error on one event | none — but which event is allowed now stays unexpressed |
| NEST (ECOOP 2026) | session conformance of the traffic itself | runtime, in the network (P4 monitors) | ✅ language-independent by placement | flagged/dropped traffic | a programmable data plane |
ferrite-session |
full session types, duality included | compile time | ❌ Rust only | type errors over research-grade generics | the steepest learning curve on this list |
rumpsteak |
multiparty session types | compile time | ❌ Rust only (generated from protocol descriptions) | type errors | research-grade (PPoPP'22); not actively developed |
| This repository | a finite phase graph: order, per-round answer types, a session that cannot be used twice, sealability, capability | compile time in Rust; analysis time in Dart; schema for everyone else | ✅ generated into each language's strongest guarantee | an authored compiler error that names the protocol | one contract file, one code generator |
The AG-UI row deserves its receipt, because it is the thesis in a second protocol. The vocabulary is real, and counting it means naming a basis: the events page documents 28 types plus one draft, the SDKs' EventType enum ships 33 of which five are deprecated, and the project's own README still advertises "~16 standard event types" — the size of the typed vocabulary, drifting in prose. Every one of those events is typed; the run's legal shape is a sentence: "A typical agent run follows a predictable pattern: it begins with a RunStarted event … and concludes with either a RunFinished event (success) or a RunError event (failure)" (AG-UI events documentation). A sentence, not a type.
The last row is not the most powerful one — ferrite proves more, NEST moves the check out of the endpoints entirely. It is the most transferable one: ordinary typestate, ordinary sealed classes, errors a reviewer reads without a formal-methods background, and a code generator answering the boilerplate objection that kills hand-rolled typestate at exactly the moment a dialogue grows its fourth phase. Where your dialogue's shape is fixed and its participants are two, this row buys most of the theorem for none of the Greek.
That row's cost cell needs itemizing, because "one contract file, one code generator" is the seller's version. The bill: a code generator in the build — build.rs on the Rust side, committed outputs for Dart and the schemas; a contract in a language nobody else knows — a hand-written parser behind it, a grammar pinned in docs/CONTRACT.md, and no editor support anywhere; and a regeneration discipline that has to hold across three languages — make contract, and a CI gate that fails when regenerating is not a no-op.
The fan-out is the part that surprises people, so here it is measured rather than described. Adding a fourth asking phase to this dialogue — one round, one form, one field — is six lines of contract. Downstream it is 54 more lines of generated Rust, 42 of generated Dart, one new schema file, and one compiler error: "cannot find function on_seats in module crate::logic", which is the single thing a human then writes.
Three of the eight committed .stderr files move with it, because their diagnostics enumerate the phases and there is now another one to enumerate — so make ui-accept, and read those three diffs. Six files to review, one function to write, for a round that did not exist. None of it is exotic; it is the discipline every protobuf shop already runs, with the same gates and the same regeneration commit. But it is a real bill, and what it buys out of is two rows up: low to write, unbounded to audit.
Whether This Applies to You
Which leaves the question the bill is really for. Four dialogues, and what this repository would tell each of them:
| Your dialogue | Worth it? | Why |
|---|---|---|
| Fixed shape, three or more rounds, something irreversible at the end — a booking, a payment, a provisioning call | Yes — this is the shape the whole thing is for | The rounds are where the mistakes live, and the last one commits something you cannot take back. Both are compile-time facts here, and the phase-per-round tax is the part you were already paying in runtime checks — 56 lines of contract carrying the 905 the three languages need |
| Two rounds, nothing irreversible | No — the ceremony costs more than it catches | One comparison in one handler is the honest tool for one edge. A generator, a DSL and three regenerated outputs are not worth a mistake that fails safe |
| The shape is chosen while the dialogue runs — a model deciding how many questions to ask next | No — the phase graph cannot express it | A finite graph fits a dialogue decided before it starts. A variable one is a self-loop with a counter, which type-checks and proves almost nothing; docs/LIMITS.md says so in the same words |
| One language, one team, a shape that is not moving | Typestate by hand — skip the generator | This repository was hand-written typestate before it was a compiler, and seven of the eight gallery cases already stood there. The generator answers boilerplate and drift across languages; with one language and a stable graph you have neither problem to buy out of |
The North Star: An IDL for Dialogues
Step back from the booking and look at the asymmetry. MCP now types messages harder than it ever has, and the order of a dialogue is expressed nowhere: the 2026-07-28 revision moved even dialogue identity into the opaque blob. The survey literature already has a name for the missing axis: a June 2026 taxonomy of agent communication protocols carries interaction state as one of its five dimensions, and classifies MCP in one line — "the core MCP protocol and agents.json define no explicit mechanism for session statefulness and are therefore stateless by design" (arXiv 2606.19135) — while its schema-flexibility dimension is scoped, explicitly, to "the structure of the exchanged content". The dimension is named; what no protocol has is a way to declare where it stands on it. Messages have an interface definition language. Conversations do not.
contract/booking.dialogue is 56 lines, comments included, and it compiles into 905 lines of Rust, Dart and JSON Schema that nobody maintains. It is a working prototype of the missing artefact: one file naming the phase graph, compiled into whatever each side of the wire can enforce — typestate where there are moves, sealed hierarchies where there is exhaustiveness, schemas where there is neither — with the wire representation and the correlation identifier falling out of the same source. A .proto for dialogues, in other words: servers already publish their message schemas in tools/list and declare what they can do in capability negotiation; a phase graph would ride the second — declared as a capability, published as a contract — and a client generator could hold the client to it.
The revision that moved the phase into a blob also shipped the slot such a declaration could ride in. Changelog again, verbatim: "Add extensions field to ClientCapabilities and ServerCapabilities to support optional extensions beyond the core protocol." There is a framework behind that field — SEP-2133: extensions named {vendor-prefix}/{extension-name}, servers advertising theirs under capabilities.extensions, clients in the same per-request _meta that already carries their capabilities — and a first official passenger, since this revision moved tasks out of the core and into io.modelcontextprotocol/tasks (SEP-2663).
The size of that slot, stated in the same breath. The extensions overview lives in the project's documentation, outside the versioned specification tree. SEP-2133's own Not Specified list opens with "we do not specify a mechanism for extensions to advertise how they modify the schema" — so the framework carries names and negotiation, and stops exactly where a phase graph would begin. A name for this server speaks dialogue contracts is now expressible, and the phase graph behind the name still is not — which is the shape a proposal would have to take.
This repository is a demonstration, not that library. But the shape is now checkable rather than hypothetical, and the arguments that matter — what a dialogue language must express before it is useful (participant branching? recursion beyond a back edge? timeouts?), and what each target language can actually hold — can now be had in public, over running code. If you maintain an agent SDK and this itch is familiar, the issues tab is open.
There is a third question standing behind this one, and this repository already has a piece of it in the tree. Part 1 asked where a dialogue's state may live. This part asked what constrains its order. Capability<Elicitation> — the zero-sized witness that a server cannot build a form without — is a first, small answer to the one after that: what a peer is allowed to do, carried in a type rather than checked in a branch. It proves one sentence of one requirement today, and docs/LIMITS.md is careful about how little that is. Authority is the harder of the three, and it is where this series goes next.
Conclusion: The Conversation Is a Type
Part 1 ended with sessions didn't disappear — they moved into the payload. This part started from what that payload is: a serialized continuation, whose phase now travels as a string inside a blob the protocol declares opaque. Every property this article demonstrated follows from refusing to leave it there. The phase became a type, so the dialogue's order is checked where its messages already were — before the program runs. The transitions consume their sessions, so the classic session bugs — double answer, wrong round, late message, early result — are not caught but unrepresentable, eight refusals committed as compiler output. The one place the type meets the wire is one function behind a cryptographic seal, returning a sum the compiler forces callers to finish. And the same contract that generates the Rust generates the Dart and the published schemas: the server compiles in the very strings the contract emitted, and make contract-check fails CI on the rest, so agreement between the three is a build fact rather than a review outcome.
Key Takeaways
- MCP types messages; nothing types the order they arrive in — the 2026-07-28 revision moved even dialogue identity into
requestState, and the fault-taxonomy data says this is where real servers really fail. - A finite phase graph is enough to make the illegal dialogue unrepresentable. Typestate plus move semantics turns protocol violations into programs that do not compile; the eight committed refusals are the evidence.
- One runtime check stands between the wire and the phase, and its narrowness is the design: cryptographic, fail-closed, in one function, returning a sum the compiler makes callers finish.
- The guarantees are named per language, not averaged: Rust rejects the illegal move; Dart rejects the unhandled case; the schema constrains everyone else — and the types are orthogonal to replay: a kept state legally forks the dialogue, and single-use stays the session store's job.
Next Steps
🚀 Try It
- Clone: github.com/zs-dima/typed-conversations — Rust 1.97 and nothing else
make demo— one full dialogue over MCP MRTR, server and client, no docker, under a minutemake ui— the eight dialogues that must not compile, and don'tmake scene-reattach— a dialogue continued by another process, on another server instancemake contract-check— regenerate all three outputs from the contract; the diff must be empty
📚 Further Reading
- MCP Specification 2026-07-28: the MRTR pattern and the changelog · SEP-2322
- A Taxonomy of Runtime Faults in MCP Servers — the 837-thread study behind the hook
- A Technical Taxonomy of LLM Agent Communication Protocols — interaction state as one of five dimensions; core MCP "stateless by design"
- rmcp — the official Rust SDK · mcp_dart
- Session types: Honda, Vasconcelos, Kubo (ESOP 1998); Honda, Yoshida, Carbone — multiparty asynchronous session types (POPL 2008); Scribble ·
ferrite-session·rumpsteak· NEST · AG-UI · The LLMbda Calculus — the deferral this repository picks up; cite v1, since v2 dropped the session-types subsection - In the repo: CONTRACT — the language and what each output guarantees · LIMITS — what the types do not catch, each limit naming its test or its reason · DEVIATIONS — where the build departed from the plan, recorded the day it happened
- Part 1: Stateless Servers, Stateful Payloads — where state can live, measured; this article is where its phase goes
- The arc that led here: Go → Rust → API-First — one contract as the source of truth, now for the shape of a conversation
Typed Agents is three questions, one per part: where the state lives, what constrains the order, and what a peer is allowed to do. Two are answered. Star the repository or follow along below for the third.
Questions? A dialogue your types didn't save you from?
🤝 Let’s Connect
I’m a software engineer building high-performance systems. If you have questions or want to clarify details, feel free to reach out.
Blog · GitHub · LinkedIn · X / Twitter · Email
with ❤️ Dmitrii Zusmanovich