๐Ÿ’ณ Secure Payment

Full-Service Web & Software Agency ยท Klamath Falls and Redding

Server and client in tandem

Work with Sean

In The tandem harness, the model proposes and typed code decides, inside one request. This lesson carries the outcome across the wire, to the app a customer uses, with a server that keeps no session between requests.

The running example is an invented salon’s booking. A customer asks for a cut on Saturday morning; the server asks back which of two open times suits them, and their answer completes the booking. Endpoints at the boundary carried one request and one response; here one contract carries two rounds of one conversation, in Haskell Servant on the server and RTK Query on the client.

Two rounds, on MCP’s shape

Under MCP’s 2026-07-28 revision, a server answers input_required when a tool call needs something only the person can give, as The AI protocol map shows. The client asks the person, then sends the original request again with the answers, and whatever the server must remember rides in requestState, which the client echoes back untouched.

The Multi Round-Trip Requests page says the pattern needs no shared storage layer across server instances and no stateful load balancing: whichever instance takes the retry works from what the retry carries, apart from anything a server keeps to make a state single-use.

This lesson builds a booking route on that shape. Round one sends the app’s booking: the customer’s words and the slots still open. The server answers input_required, with a question in an elicitation form and a signed requestState. The app asks the customer, who picks a time. Round two sends the same booking, the answer and the state, and the server completes it.

One booking, two rounds Three lanes: the customer, the app and the server. The customer asks for Saturday morning. The app sends the booking, and the server answers input_required, with a question and a signed state. The app asks the customer, 10:30 or 11:30, and the customer answers 11:30. Highlighted, the app sends the booking again with the answer and the state, and the server answers complete. customerappserverSaturday a.m.?bookinginput_required10:30 or 11:30?11:30+ answer, statecomplete
One booking in two rounds, in the lanes of the protocol map’s two-round request. The server keeps no session between rounds: the booking travels again, and the signed state carries what the server offered. The violet round brings the customer’s answer back and completes.

A pure step function

The server’s protocol is one pure function. Each request carries the booking and, on a retry, the customer’s answer, already parsed, and step returns exactly one outcome. It reads no clock, keeps no session and calls nothing, so a recorded run can be fed back through it and watched one step at a time.

Brett Cannon keeps a page of network protocols written sans I/O: libraries that work on bytes or text alone, so any I/O code, synchronous or asynchronous, can drive them. The step function takes that idea up a level. The Servant handler does the I/O, and step only decides, the way the lecture What Is a Function? pushes effects to the edges.

-- server-and-client-in-tandem/Step.hs
-- A salon's booking assistant, invented for the lesson: what each round carries, and the pure step.
module Step where

import Answer (Answer (..), offeredTime)
import Data.Text (Text)
import GHC.Generics (Generic)

-- What the app sends in every round: the customer's words and the slots still open, in the app's own order.
data Booking = Booking {message :: Text, openSlots :: [Text]}
  deriving stock (Generic, Show, Eq)

-- What the customer is asked, and the times they may answer with.
data Question = Question {ask :: Text, choices :: [Text]}
  deriving stock (Generic, Show, Eq)

-- A finished booking: a time, or none, with the words the customer sees.
data Booked = Booked {slot :: Text, say :: Text} | NotBooked {say :: Text}
  deriving stock (Generic, Show, Eq)

-- One round's outcome, in the two result types MCP's core defines: input required, or complete.
data Outcome = InputRequired Question | Complete Booked
  deriving stock (Show, Eq)

-- The times to offer. Here, the first two open; in the tandem harness, the hours its one offer list holds.
toOffer :: Booking -> [Text]
toOffer = take 2 . openSlots

-- One step: the booking, and the customer's answer once there is one, in; exactly one outcome out.
step :: Booking -> Maybe Answer -> Outcome
step b Nothing = case toOffer b of
  [] -> Complete (NotBooked "Nothing is open then, so the salon will call you.")
  times -> InputRequired (Question "Which time suits you?" times)
step _ (Just (Accepted time)) = Complete (Booked (offeredTime time) (offeredTime time <> " is yours."))
step _ (Just Declined) = Complete (NotBooked "No time was chosen, so nothing is booked.")

Which hours are on offer is what one offer list decides in The tandem harness, where a model picks one and its checker accepts or refuses the pick; here toOffer stands in for the list and takes the first two open slots, so the wire is all that’s new. The outcomes are the two result types MCP’s core defines, input required and complete, and nothing else.

Signed, single-use, short-lived request state

The server keeps no session between rounds, so what it must trust in round two leaves with the client in round one, signed. Here that is the times it offered.

toOffer could work them out again from the same booking, but if a model chose the times, as it chooses an hour in The tandem harness, a model asked twice need not choose the same ones. A client that could add a time would book one nobody offered.

MCP asks the same of requestState: treat it as attacker-controlled, protect its integrity with something like an HMAC when it sways access or business logic, bind it to the caller and the originating request with a short expiry.

Where a state must be used once, MCP asks the server to enforce that, since a signature alone never will.

-- server-and-client-in-tandem/RequestState.hs
-- requestState, signed: the times offered, how long it holds, and a digest of the request it answers.
module RequestState (Key, validFor, issue, verify, spend) where

import Control.Concurrent.STM (TVar, atomically, modifyTVar', readTVar)
import Control.Monad (unless, when)
import Crypto.Hash (Digest, SHA3_256, digestFromByteString, hash)
import Crypto.MAC.HMAC (HMAC (..), hmac)
import Data.Aeson (FromJSON, ToJSON, decodeStrict, encode)
import Data.ByteArray.Encoding (Base (Base16), convertFromBase)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy as BL
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Data.Time (UTCTime)
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
import GHC.Generics (Generic)
import Step (Booking (..))

type Key = ByteString

-- What the state says, under its MAC.
data Claims = Claims {offered :: [Text], goodUntil :: Int, request :: Text}
  deriving stock (Generic)

instance ToJSON Claims
instance FromJSON Claims

validFor :: Int
validFor = 120 -- seconds, invented for the lesson

seconds :: UTCTime -> Int
seconds = floor . utcTimeToPOSIXSeconds

-- A keyed MAC: HMAC over SHA3-256, written out in hex.
signed :: Key -> Text -> HMAC SHA3_256
signed key payload = hmac key (encodeUtf8 payload)

mac :: Key -> Text -> Text
mac key = T.pack . show . hmacGetDigest . signed key

-- A digest of the request's salient parameters: the salon, the words and the open slots in order.
digest :: Text -> Booking -> Text
digest salon b = T.pack (show (hash (BL.toStrict (encode (salon, message b, openSlots b))) :: Digest SHA3_256))

issue :: Key -> UTCTime -> Text -> Booking -> [Text] -> Text
issue key now salon b times = payload <> "." <> mac key payload
  where
    payload = decodeUtf8 (BL.toStrict (encode (Claims times (seconds now + validFor) (digest salon b))))

-- The given MAC, read back from hex into an HMAC. crypton compares two HMACs in constant time.
macMatches :: Key -> Text -> Text -> Bool
macMatches key payload given = case convertFromBase Base16 (encodeUtf8 given) of
  Right bytes | Just d <- digestFromByteString (bytes :: ByteString) -> HMAC d == signed key payload
  _ -> False

-- Every check that needs no memory, in one pure function. A good state gives back the offered times,
-- and its MAC in issue's own lowercase hex, so a replay with its MAC in capitals is still caught.
verify :: Key -> UTCTime -> Text -> Booking -> Text -> Either Text ([Text], Text)
verify key now salon b state = do
  let (dotted, given) = T.breakOnEnd "." state
      payload = T.dropEnd 1 dotted
  unless (macMatches key payload given) (Left "requestState doesn't verify")
  claims <- maybe (Left "requestState doesn't verify") Right (decodeStrict (encodeUtf8 payload))
  when (request claims /= digest salon b) (Left "requestState belongs to another request")
  when (seconds now > goodUntil claims) (Left "requestState has expired")
  pure (offered claims, mac key payload)

-- One transaction reads the used states, checks this one and records it, so a state completes once.
spend :: TVar (Set Text) -> Text -> IO (Either Text ())
spend used given = atomically $ do
  seen <- readTVar used
  if Set.member given seen
    then pure (Left "requestState was already used")
    else Right <$> modifyTVar' used (Set.insert given)

verify holds every check that needs no memory, in one pure function, so a later edit to the handler can’t skip one. spend is the one that needs memory: it reads the used states, checks this one and records it in the same STM transaction, so two requests racing with one state can’t both complete.

macMatches reads the given MAC back into crypton’s HMAC type, whose equality compares in constant time, as Python’s hmac.compare_digest does.

The digest covers the request’s salient parameters, as MCP’s page puts it: the salon, the customer’s words and the open slots in the app’s order. JSON escapes every separator, so two different requests never share an encoding, and a slot added or moved between rounds no longer matches. The MAC is HMAC over SHA3-256 from the crypton package, and the two-minute life is invented for the lesson.

Here is round one’s reply from the real step and signer:

$ cabal repl test:server-and-client-in-tandem -v0 < server-and-client-in-tandem/round-one.ghci
{"inputRequests":{"time":{"method":"elicitation/create","params":{"message":"Which time suits you?","mode":"form","requestedSchema":{"properties":{"time":{"enum":["sat-10:30","sat-11:30"],"type":"string"}},"required":["time"],"type":"object"}}}},"requestState":"{\"goodUntil\":1791018120,\"offered\":[\"sat-10:30\",\"sat-11:30\"],\"request\":\"cc46a46dac1e042f51cfa1cc7df214ad18fdfc6b4390c08d49d952f34fb9dfe1\"}.b5dcf7e456b83e34e0d820bc8763e613e5a0a7fe072f537effdf1f85b7b2c914","resultType":"input_required"}

The state is readable JSON and a MAC: signed, not encrypted. Nothing in it is secret, and the client never parses it.

A request state’s life Six steps down a column: issued and signed, carried unopened, verified for its MAC, its request and its time, then, along a highlighted path, the answer parsed into an offered time, the state spent once, atomically, and, highlighted, the step completing the booking. Beside the steps, the refusals: a state that is forged or for another request is refused at verify, and an expired one is refused there for good; a time never offered is refused; a state used before is refused when spent. Every refusal before the spend leaves the state unused, and only an expired or used state is final. issuedsignedcarriedopaqueverifiedMAC, request, timeanswer parsedan offered timespentonce, atomicallystep completesthe bookingforged, other request:refusedexpired: refused, finalnever offered:refusedused before:refused, final
A request state passes once. The violet path is a state that verifies, carries an answer that parses, and is spent in the same transaction that checks it. Any refusal before the spend leaves the state unused, so a corrected request can follow on it; only an expired or used state is final.

Parse what the client sends

The handler runs its checks in one order: the request state, then the answer, then the step. A refusal is a reason the client can act on, and it answers 400. A server deployed without its signing key answers 503 instead, a fault of the server’s own, with nothing about its configuration in the body, so a half-configured deploy never completes a booking.

-- server-and-client-in-tandem/Server.hs
-- The booking route: the request state, then the answer, then the step.
{-# LANGUAGE OverloadedRecordDot #-}

module Server (BookingAPI, Env (..), Refusal (..), bookFor, app, bookOpenApi) where

import Answer (parseAnswer)
import Control.Concurrent.STM (TVar)
import Control.Monad.IO.Class (liftIO)
import Data.Aeson (encode)
import Data.OpenApi (OpenApi)
import Data.Set (Set)
import Data.Text (Text)
import Data.Time (UTCTime)
import RequestState (Key, issue, spend, verify)
import Servant
import Servant.OpenApi (toOpenApi)
import Step
import Wire

type BookingAPI =
  "salons" :> Capture "salon" Text :> "book" :> ReqBody '[JSON] BookRequest :> Post '[JSON] BookResult

data Env = Env
  { signingKey :: Maybe Key -- with none, the route books nothing
  , clock :: IO UTCTime
  , used :: TVar (Set Text) -- used request states: all the server keeps between requests
  }

-- Why a request gets no result: the server can't serve it, or the request is refused for a reason it can fix.
data Refusal = Unavailable | Refused Text
  deriving stock (Show, Eq)

-- A refusal is never a result: nothing about it reads as a booking.
bookFor :: Env -> Text -> BookRequest -> IO (Either Refusal BookResult)
bookFor env salon req = case env.signingKey of
  Nothing -> pure (Left Unavailable)
  Just key -> do
    now <- env.clock
    let result outcome = case outcome of
          InputRequired q -> InputRequiredResult (askTime q) (issue key now salon req.arguments (choices q))
          Complete booked -> CompleteResult booked
    case (req.inputResponses, req.requestState) of
      (Nothing, Nothing) -> pure (Right (result (step req.arguments Nothing)))
      (Just (Raw answers), Just state) ->
        case verify key now salon req.arguments state of
          Left why -> pure (Left (Refused why))
          Right (offered, given) -> case parseAnswer offered answers of
            Left why -> pure (Left (Refused why)) -- a bad answer leaves the state unused, so a corrected one can follow
            Right answer -> either (Left . Refused) (const (Right (result (step req.arguments (Just answer))))) <$> spend env.used given
      _ -> pure (Left (Refused "answers and their requestState travel together"))

app :: Env -> Application
app env = serve (Proxy @BookingAPI) $ \salon req ->
  liftIO (bookFor env salon req) >>= \case
    Right result -> pure result
    Left Unavailable -> throwError err503 {errBody = encode ("the booking service is unavailable" :: Text)}
    Left (Refused why) -> throwError err400 {errBody = encode why}

bookOpenApi :: OpenApi
bookOpenApi = toOpenApi (Proxy @BookingAPI)

BookRequest has no field for a result, so one slipped into a request has nowhere to land; MCP’s page likewise asks a server to ignore what it doesn’t recognize in a client’s answers. In production, clock reads the system clock, and the specs pass a clock they move.

The answers stay raw JSON until the state verifies. Then parseAnswer reads them into an Answer, and an accepted time only as an Offered, a type whose one constructor the module keeps to itself and builds only from the times the state carried. Alexis King’s Parse, don’t validate names the rule: get data into its most precise form at the boundary, before any of it is acted on.

-- server-and-client-in-tandem/Answer.hs
-- The customer's answer, parsed into a time the server offered, or refused. Nothing else gets through.
module Answer (Offered, offeredTime, Answer (..), parseAnswer) where

import qualified Data.Aeson.KeyMap as KeyMap
import Data.Aeson.Types (Value (..))
import Data.Text (Text)

-- A time this booking offered. Only parseAnswer makes one, and only from the offered list.
newtype Offered = Offered Text
  deriving stock (Show, Eq)

offeredTime :: Offered -> Text
offeredTime (Offered t) = t

-- MCP's elicitation answers: accept with content, decline, or cancel. Decline and cancel book nothing.
data Answer = Accepted Offered | Declined
  deriving stock (Show, Eq)

parseAnswer :: [Text] -> Value -> Either Text Answer
parseAnswer offered responses = case field "time" responses of
  Just answer -> case (field "action" answer, field "time" =<< field "content" answer) of
    (Just "accept", Just (String t))
      | t `elem` offered -> Right (Accepted (Offered t))
      | otherwise -> Left (t <> " was never offered")
    (Just "decline", _) -> Right Declined
    (Just "cancel", _) -> Right Declined
    _ -> Left "the answer is not an accept, a decline or a cancel"
  Nothing -> Left "no answer to the question asked"
  where
    field k = \case
      Object o -> KeyMap.lookup k o
      _ -> Nothing

The state is spent only after the answer parses, so a slip costs the customer nothing: a time never offered is refused, and the same state completes a corrected answer.

Wire.hs, beside these files, derives the JSON and the OpenAPI schema from the same aeson options, so the derived types can’t disagree; Raw, the JSON kept raw (the answers until the state verifies, and the form’s schema), declares its schema by hand. Each refusal has a scenario; here are five of them:

-- server-and-client-in-tandem/Spec.hs: the request state's checks, each refused before any step runs.
describe "Feature: A request state completes once, for its own request, before it expires" $ do
  it "Scenario: Given a signed state, When the app adds a slot or edits the state before round two, Then the server refuses it" $ do
    w <- world (Just key)
    state <- stateFrom =<< roundOne w booking
    roundTwo w booking {openSlots = "sat-08:00" : openSlots booking} (answers "accept" "sat-10:30") state
      `shouldReturn` Left (Refused "requestState belongs to another request")
    roundTwo w booking (answers "accept" "sat-10:30") (T.replace "sat-11:30" "sat-13:00" state)
      `shouldReturn` Left (Refused "requestState doesn't verify")
  it "Scenario: Given a signed state, When the app reorders its slots before round two, Then the server refuses it" $ do
    w <- world (Just key)
    state <- stateFrom =<< roundOne w booking
    roundTwo w booking {openSlots = reverse (openSlots booking)} (answers "accept" "sat-10:30") state
      `shouldReturn` Left (Refused "requestState belongs to another request")
  it "Scenario: Given a used state, When it is sent again, however its MAC is written, Then the server refuses it" $ do
    w <- world (Just key)
    state <- stateFrom =<< roundOne w booking
    let (dotted, given) = T.breakOnEnd "." state
    roundTwo w booking (answers "accept" "sat-10:30") state `shouldReturn` booked "sat-10:30"
    roundTwo w booking (answers "accept" "sat-10:30") state `shouldReturn` Left (Refused "requestState was already used")
    roundTwo w booking (answers "accept" "sat-10:30") (dotted <> T.toUpper given) `shouldReturn` Left (Refused "requestState was already used")
  it "Scenario: Given one state, When two requests race with it, Then exactly one completes" $ do
    w <- world (Just key)
    state <- stateFrom =<< roundOne w booking
    boxes <- replicateM 2 newEmptyMVar
    mapM_ (\box -> forkIO (roundTwo w booking (answers "accept" "sat-10:30") state >>= putMVar box)) boxes
    results <- mapM takeMVar boxes
    length (filter isRight results) `shouldBe` 1
  it "Scenario: Given a signed state, When round two comes after it expires, Then the server refuses it" $ do
    w <- world (Just key)
    state <- stateFrom =<< roundOne w booking
    atomically (modifyTVar' w.now (addUTCTime (fromIntegral validFor + 1)))
    roundTwo w booking (answers "accept" "sat-10:30") state `shouldReturn` Left (Refused "requestState has expired")
$ cabal test server-and-client-in-tandem --test-show-details=direct | grep -E 'โœ”|โœ˜|examples'
  Scenario: Round one asks the customer to choose between two open times [โœ”]
  Scenario: Round two, with the customer's choice and the request state, completes the booking [โœ”]
  Scenario: A customer who declines, or cancels, is booked nothing [โœ”]
  Scenario: With nothing open, round one completes without a question [โœ”]
  Scenario: A field the server doesn't read is ignored [โœ”]
  Scenario: Given a signed state, When the app adds a slot or edits the state before round two, Then the server refuses it [โœ”]
  Scenario: Given a signed state, When the app reorders its slots before round two, Then the server refuses it [โœ”]
  Scenario: Given a used state, When it is sent again, however its MAC is written, Then the server refuses it [โœ”]
  Scenario: Given one state, When two requests race with it, Then exactly one completes [โœ”]
  Scenario: Given a signed state, When round two comes after it expires, Then the server refuses it [โœ”]
  Scenario: Given an answer with a time never offered, Then the server refuses it, and the state still completes a corrected answer [โœ”]
  Scenario: Given an answer that is not an accept, a decline or a cancel, Then the server refuses it [โœ”]
  Scenario: Given answers to a question the server never asked, Then the server refuses them [โœ”]
  Scenario: Given answers with no request state, or a request state with no answers, Then the server refuses them [โœ”]
  Scenario: Given no signing key is configured, When a request arrives, Then nothing is booked [โœ”]
  Scenario: The committed OpenAPI document is the one the type generates [โœ”]
  Scenario: The route answers a first round over HTTP [โœ”]
  Scenario: A request the decoder can't read is refused before any step [โœ”]
  Scenario: A refused retry answers 400, with its reason [โœ”]
  Scenario: A server with no signing key answers 503, and says nothing of why [โœ”]
20 examples, 0 failures

In the race, two threads send round two with one state at once, and the STM transaction lets exactly one complete. The runs here come from a UTF-8 terminal; under the POSIX locale, Hspec marks a pass [v] where they show ✔.

The client asks, then retries

On the client, a slice holds where the booking stands: idle, asking the customer, done or failed. Two thunks drive it, one per round, because the customer answers in between, in their own time, on the screen. The lecture Modern Redux Architecture Patterns picks a thunk for work an app starts from a call site (Side-effect architecture).

// src/server-and-client-in-tandem/wire.ts
// The booking route as the client holds it once decoded. The salon and its times are invented.
export const RESULT_TYPES = ['input_required', 'complete'] as const // the two result types MCP's core defines, listed once
export type ResultType = (typeof RESULT_TYPES)[number]

// Reached only by a value the types say can't exist, and then it throws.
export const unhandled = (value: never): never => {
  throw new Error(`no handler for ${JSON.stringify(value)}`)
}

// What the app sends in every round: the customer's words and the slots still open, in its own order.
export type Booking = { readonly message: string; readonly openSlots: readonly string[] }
export type Question = { readonly message: string; readonly choices: readonly string[] }
export type Booked =
  | { readonly status: 'booked'; readonly slot: string; readonly say: string }
  | { readonly status: 'not_booked'; readonly say: string }
export type Result =
  | { readonly resultType: 'input_required'; readonly question: Question; readonly requestState: string }
  | { readonly resultType: 'complete'; readonly booked: Booked }
export type Decoded = { readonly ok: true; readonly result: Result } | { readonly ok: false; readonly problem: string }

// The customer's answer, in MCP's elicitation terms.
export type Answer = { readonly action: 'accept'; readonly time: string } | { readonly action: 'decline' }
export type BookRequest = {
  readonly salon: string
  readonly arguments: Booking
  readonly inputResponses?: { readonly time: { readonly action: string; readonly content?: { readonly time: string } } }
  readonly requestState?: string
}
// src/server-and-client-in-tandem/booking.ts
// The client's side of two rounds: ask, let the customer answer, retry with the answer and the state untouched.
import { createAsyncThunk, createSlice, type ThunkDispatch, type UnknownAction } from '@reduxjs/toolkit'
import { bookingApi } from './bookingApi.ts'
import { unhandled, type Answer, type Booked, type Booking, type BookRequest, type Question, type Result } from './wire.ts'

export type BookingState =
  | { readonly phase: 'idle' }
  | { readonly phase: 'asking'; readonly salon: string; readonly booking: Booking; readonly question: Question; readonly requestState: string }
  | { readonly phase: 'done'; readonly booked: Booked }
  | { readonly phase: 'failed'; readonly problem: string }

type Thunk = { state: { booking: BookingState }; rejectValue: string }

type Dispatch = ThunkDispatch<unknown, unknown, UnknownAction>

// A reply, a refusal with its status, a reply it can't read, or no answer at all, each told apart.
const send = async (dispatch: Dispatch, request: BookRequest, round: string): Promise<Result | string> => {
  const sent = dispatch(bookingApi.endpoints.book.initiate(request))
  const reply = await sent.unwrap().catch((error: { status?: unknown }) => ({ failed: error.status }))
  sent.reset() // the thunk holds the reply now, so the store lets the request go
  if (!('failed' in reply)) return reply.ok ? reply.result : `${round}: ${reply.problem}`
  if (typeof reply.failed === 'number') return `${round}: the server refused it (${reply.failed})`
  return reply.failed === 'PARSING_ERROR' ? `${round}: the reply can't be read` : `${round}: the server gave no reply`
}

// Round one: the booking alone. A question waits in state for the customer; a completed booking ends here.
export const start = createAsyncThunk<Result, { salon: string; booking: Booking }, Thunk>(
  'booking/start',
  async ({ salon, booking }, { dispatch, rejectWithValue }) => {
    const result = await send(dispatch, { salon, arguments: booking }, 'round one')
    return typeof result === 'string' ? rejectWithValue(result) : result
  },
)

// Round two: the same booking again, the customer's answer, and the request state exactly as it came.
export const answer = createAsyncThunk<Booked, Answer, Thunk>('booking/answer', async (choice, { dispatch, getState, rejectWithValue }) => {
  const asked = getState().booking
  if (asked.phase !== 'asking') return rejectWithValue('nothing was asked')
  const time = choice.action === 'accept' ? { action: 'accept', content: { time: choice.time } } : { action: 'decline' }
  const request = { salon: asked.salon, arguments: asked.booking, inputResponses: { time }, requestState: asked.requestState }
  const result = await send(dispatch, request, 'round two')
  if (typeof result === 'string') return rejectWithValue(result)
  return result.resultType === 'complete' ? result.booked : rejectWithValue('round two: the server asked again')
})

export const bookingSlice = createSlice({
  name: 'booking',
  initialState: { phase: 'idle' } as BookingState,
  reducers: {},
  extraReducers: (builder) =>
    builder
      .addCase(start.fulfilled, (_, { payload, meta }): BookingState => {
        switch (payload.resultType) {
          case 'input_required':
            return { phase: 'asking', salon: meta.arg.salon, booking: meta.arg.booking, question: payload.question, requestState: payload.requestState }
          case 'complete':
            return { phase: 'done', booked: payload.booked }
          default:
            return unhandled(payload)
        }
      })
      .addCase(answer.fulfilled, (_, { payload }): BookingState => ({ phase: 'done', booked: payload }))
      .addCase(start.rejected, (_, { payload }): BookingState => ({ phase: 'failed', problem: payload ?? 'round one failed' }))
      .addCase(answer.rejected, (_, { payload }): BookingState => ({ phase: 'failed', problem: payload ?? 'round two failed' })),
})

Round two sends the booking from round one again, the answer, and the request state exactly as it came. The client never reads the state; the slice holds it as an opaque string. A server that asks a second time ends the run with a clear error, since this booking has one question and one answer.

MCP’s page asks a server to ask again when an answer is missing, rather than returning an error, and lets it ask more than once. This route refuses instead, since its one client always sends the answer it was asked for, and that client stops on a second question rather than guess.

The app then books only the slot a completed booking names, and only while that slot is still open. A completed booking’s say is shown to the customer and never read for a time, so free text never moves anything:

// src/server-and-client-in-tandem/app.ts
// The app books only the slot a completed booking names, and only while that slot is still open.
import type { Booked } from './wire.ts'

export type AppAction = { readonly kind: 'book'; readonly slot: string } | { readonly kind: 'show'; readonly say: string }

export const actOn = (booked: Booked, openNow: ReadonlySet<string>): AppAction => {
  if (booked.status === 'not_booked') return { kind: 'show', say: booked.say }
  if (!openNow.has(booked.slot)) return { kind: 'show', say: `${booked.slot} was taken meanwhile. Pick another time?` }
  return { kind: 'book', slot: booked.slot } // say is shown to the customer, never read for a time
}

The client’s tests run against a fake server, a pure function from the salon and a request body to a reply body, behind a stubbed fetch: the controlled request boundary the lecture Redux Toolkit and RTK Query Best Practices tests endpoints against (Testing). Every request and reply still crosses as JSON text:

$ npx vitest run src/server-and-client-in-tandem/booking.test.ts src/server-and-client-in-tandem/app.test.ts --reporter=tree | grep -E 'โœ“|Tests'
 โœ“ src/server-and-client-in-tandem/app.test.ts (2 tests) 4ms
   โœ“ Feature: The app books only the slot a booking names (2)
     โœ“ Scenario: Given words that name another time, When the app acts, Then it books the slot the booking names 2ms
     โœ“ Scenario: Given the slot was taken meanwhile, When the app acts, Then it books nothing and says so 1ms
 โœ“ src/server-and-client-in-tandem/booking.test.ts (9 tests) 64ms
   โœ“ Feature: The client asks the customer, then retries (9)
     โœ“ Scenario: Given a server that asks, When the customer picks a time, Then the retry carries the same booking, the answer and the state untouched 44ms
     โœ“ Scenario: Given the customer declines, When the retry completes, Then nothing is booked 5ms
     โœ“ Scenario: Given a server that asks again in round two, When the customer answers, Then the run stops with a clear error 3ms
     โœ“ Scenario: Given a server that is down, When the booking starts, Then the run stops and books nothing 1ms
     โœ“ Scenario: Given a server that refuses round two, When the customer answers, Then the run names the refusal, not a missing reply 3ms
     โœ“ Scenario: Given a server that refuses with a plain-text body, When the booking starts, Then the run names the refusal (400) 1ms
     โœ“ Scenario: Given a reply labelled JSON that does not parse, When it arrives, Then the run says it can't be read, not that none came 2ms
     โœ“ Scenario: Given a server that refuses with a body, When the booking starts, Then no reply body reaches the store 2ms
     โœ“ Scenario: Given a reply of a result type the client has never heard of, When it arrives, Then the run stops and names it 2ms
      Tests  11 passed (11)

Decode once, and strictly

The booking endpoint decodes in transformResponse, which RTK Query runs before it stores what a request returned, and transformErrorResponse keeps only a failed reply’s status, so no reply body reaches the store raw.

The base query reads each body as the type it declares. Servant’s own 400 for a request it can’t decode is plain text, so it arrives as a refusal with its status, not as a body that failed to parse. A reply that claims to be JSON and doesn’t parse is named as unreadable, never as no reply.

In a project the endpoint comes from the generator, as the lecture Redux Toolkit and RTK Query Best Practices does it (Generate endpoints from OpenAPI), and the decoder is attached with enhanceEndpoints in a reviewed file beside it, so the generated file is never edited by hand. Here the endpoint is written out to keep the decoder in view.

// src/server-and-client-in-tandem/bookingApi.ts
// The booking route. Both transforms run before anything is stored, so no reply body reaches the store raw.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'
import { decodeResult } from './decode.ts'
import type { BookRequest, Decoded } from './wire.ts'

export const bookingApi = createApi({
  reducerPath: 'bookingApi',
  // Each body is read as the type it declares, so a plain-text refusal, such as Servant's own 400 for a request
  // it can't decode, keeps its status instead of failing to parse as JSON.
  baseQuery: fetchBaseQuery({ baseUrl: 'https://booking.example/', responseHandler: 'content-type' }),
  endpoints: (build) => ({
    book: build.mutation<Decoded, BookRequest>({
      query: ({ salon, ...body }) => ({ url: `salons/${encodeURIComponent(salon)}/book`, method: 'POST', body }),
      transformResponse: (raw: unknown) => decodeResult(raw), // a success is decoded
      transformErrorResponse: (error) => ({ status: error.status }), // a failure keeps its status, never its body
    }),
  }),
})

Endpoints at the boundary decoded in the base query and turned a failure into an error for a view to show. Here a failure is a plain value the thunk reads, because the thunk, not a view, decides what happens next.

Every field the client reads decodes strictly. The result types come from the one RESULT_TYPES list, and a value the switch doesn’t name goes to unhandled, whose argument has type never, so the compiler proves no result type is left over:

// src/server-and-client-in-tandem/decode.ts
// Decode once, at the boundary, and strictly: every field the client reads, or nothing.
import { RESULT_TYPES, unhandled, type Booked, type Decoded, type ResultType } from './wire.ts'

type Obj = Readonly<Record<string, unknown>>
const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v)
const isText = (v: unknown): v is string => typeof v === 'string'
const isResultType = (v: unknown): v is ResultType => RESULT_TYPES.some((t) => t === v)
const at = (v: unknown, ...path: readonly string[]): unknown => path.reduce((o, key) => (isObj(o) ? o[key] : undefined), v)
const no = (problem: string): Decoded => ({ ok: false, problem })

const booked = (v: unknown): Booked | string => {
  if (!isObj(v) || !isText(v.say)) return 'structuredContent is not a booking'
  if (v.status === 'not_booked') return { status: 'not_booked', say: v.say }
  if (v.status !== 'booked') return `structuredContent has unknown status ${JSON.stringify(v.status)}`
  return isText(v.slot) ? { status: 'booked', slot: v.slot, say: v.say } : 'a booking needs its slot'
}

export const decodeResult = (raw: unknown): Decoded => {
  const resultType = at(raw, 'resultType')
  if (!isResultType(resultType)) return no(`unknown resultType ${JSON.stringify(resultType)}`)
  switch (resultType) {
    case 'input_required': {
      const form = at(raw, 'inputRequests', 'time')
      const message = at(form, 'params', 'message')
      const choices = at(form, 'params', 'requestedSchema', 'properties', 'time', 'enum')
      const requestState = at(raw, 'requestState')
      if (at(form, 'method') !== 'elicitation/create' || !isText(message)) return no('the question is not an elicitation form')
      if (!Array.isArray(choices) || choices.length === 0 || !choices.every(isText)) return no('the question offers no times')
      if (!isText(requestState)) return no('a question needs its requestState')
      return { ok: true, result: { resultType, question: { message, choices }, requestState } }
    }
    case 'complete': {
      const b = booked(at(raw, 'structuredContent'))
      return typeof b === 'string' ? no(b) : { ok: true, result: { resultType, booked: b } }
    }
    default:
      return unhandled(resultType)
  }
}

That is stricter than MCP asks of a general client. This client talks only to its own route, which always sends resultType and a state with every question, so it refuses a reply missing either. A general MCP client reads a missing resultType as complete, as the specification requires for servers on earlier revisions, and takes a question that carries no state.

RFC 9413, Maintaining Robust Protocols, makes the case for that strictness: a fatal error for a condition the specification doesn’t cover gets the fault attention, where tolerating it conceals the problem.

The malformed shapes run as a Scenario Outline in decode.feature, once per row of its examples, each row a whole reply through the real decoder:

# src/server-and-client-in-tandem/decode.feature
Feature: Every reply decodes strictly, or not at all

  Each row's JSON is a whole reply from the server, run through the real decoder.

  Scenario Outline: A reply that is <shape>
    Given the server's reply <json>
    When the client decodes it
    Then the problem is <problem>

    Examples:
      | shape                     | json                                                                   | problem                                  |
      | of no known result type   | {"resultType":"maybe"}                                                 | unknown resultType "maybe"               |
      | a question with no state  | {"resultType":"input_required","inputRequests":{"time":{"method":"elicitation/create","params":{"message":"Which time?","requestedSchema":{"properties":{"time":{"enum":["sat-10:30"]}}}}}}} | a question needs its requestState |
      | a question with no times  | {"resultType":"input_required","requestState":"s","inputRequests":{"time":{"method":"elicitation/create","params":{"message":"Which time?"}}}} | the question offers no times |
      | a booking with no slot    | {"resultType":"complete","structuredContent":{"status":"booked","say":"Booked."}} | a booking needs its slot      |
      | a booking of a new status | {"resultType":"complete","structuredContent":{"status":"held","say":"Held."}}     | structuredContent has unknown status "held" |
$ npx vitest run src/server-and-client-in-tandem/decode.test.ts --reporter=tree | grep -E 'โœ“|Tests'
 โœ“ src/server-and-client-in-tandem/decode.test.ts (5 tests) 5ms
   โœ“ Feature: Every reply decodes strictly, or not at all (5)
     โœ“ Scenario Outline: A reply that is of no known result type 2ms
     โœ“ Scenario Outline: A reply that is a question with no state 0ms
     โœ“ Scenario Outline: A reply that is a question with no times 0ms
     โœ“ Scenario Outline: A reply that is a booking with no slot 0ms
     โœ“ Scenario Outline: A reply that is a booking of a new status 0ms
      Tests  5 passed (5)

One contract, checked from both sides

The Servant type is the contract. servant-openapi3’s toOpenApi describes it as an OpenAPI 3.0 document, which the Haskell spec keeps committed and compares on every run, and which RTK Query’s code generator turns into endpoints, as in Endpoints at the boundary. The type holds the server to it; the document lets the client check against it too.

In Ian Robinson’s consumer-driven contracts, a service evolution pattern, each consumer keeps a consumer contract, its own statement of what it relies on, and the provider is held to all of them.

The booking client’s contract copies the parts of the provider’s document it uses: for each round, the route it calls, the fields it sends, and for each typed field it reads, a type or every value it handles. contractDiff is a pure function from the provider’s document and that copy to readable differences:

// src/server-and-client-in-tandem/contract.ts
// A consumer-driven contract check: the provider's OpenAPI document against one consumer's copy, as readable lines.
type Schema = Readonly<Record<string, unknown>>
export type Expectation = {
  readonly round: string // which round of the conversation relies on it
  readonly method: string
  readonly path: string
  readonly sends: readonly string[] // request fields the consumer sends
  readonly reads: Readonly<Record<string, string | readonly string[]>> // a type, or every value the consumer handles
}
export type ConsumerCopy = { readonly consumer: string; readonly expectations: readonly Expectation[] }

const isSchema = (v: unknown): v is Schema => typeof v === 'object' && v !== null && !Array.isArray(v)
const at = (v: unknown, key: string): unknown => (isSchema(v) ? v[key] : undefined)
const shape = (path: string) => path.replace(/\{[^}]*\}/g, '{}')

// Every schema reachable along a path, through $ref, oneOf and items ("[]").
const reach = (doc: Schema, schema: unknown, path: readonly string[]): Schema[] => {
  const ref = at(schema, '$ref')
  if (typeof ref === 'string') return reach(doc, at(at(at(doc, 'components'), 'schemas'), ref.split('/').pop() ?? ''), path)
  const branches = at(schema, 'oneOf')
  if (Array.isArray(branches)) return branches.flatMap((branch) => reach(doc, branch, path))
  if (!isSchema(schema)) return []
  const [head, ...rest] = path
  if (head === undefined) return [schema]
  return reach(doc, head === '[]' ? schema.items : at(schema.properties, head), rest)
}

const body = (holder: unknown): unknown => {
  const content = at(holder, 'content')
  return isSchema(content) ? at(Object.values(content)[0], 'schema') : undefined
}
const quoted = (values: readonly unknown[]) => values.map((v) => JSON.stringify(v)).join(', ')

export const contractDiff = (doc: Schema, copy: ConsumerCopy): string[] =>
  copy.expectations.flatMap((e) => {
    const where = `${e.round}: ${e.method.toUpperCase()} ${e.path}`
    const operation = at(at(at(doc, 'paths'), e.path), e.method)
    if (operation === undefined) {
      const paths = at(doc, 'paths')
      const near = isSchema(paths) ? Object.keys(paths).filter((p) => shape(p) === shape(e.path)) : []
      return [`${where} is not in the provider${near.length ? `; it has ${quoted(near)}` : ''}`]
    }
    const request = body(at(operation, 'requestBody'))
    const reply = body(at(at(operation, 'responses'), '200'))
    const sent = e.sends.filter((f) => reach(doc, request, [f]).length === 0).map((f) => `${where} sends ${f}, which the provider doesn't read`)
    const read = Object.entries(e.reads).flatMap(([field, expected]) => {
      const found = reach(doc, reply, field.replaceAll('[]', '.[]').split('.'))
      if (found.length === 0) return [`${where} reads ${field}, which the provider never sends`]
      if (typeof expected === 'string') {
        const types = [...new Set(found.map((s) => s.type ?? 'object'))]
        return types.every((t) => t === expected) ? [] : [`${where} reads ${field} as ${expected}; the provider sends ${quoted(types)}`]
      }
      const sends = found.flatMap((s) => (Array.isArray(s.enum) ? s.enum : []))
      return [
        ...sends.filter((v) => !expected.includes(v)).map((v) => `${where} reads ${field}; the provider can send ${JSON.stringify(v)}, which this consumer doesn't handle`),
        ...expected.filter((v) => !sends.includes(v)).map((v) => `${where} handles ${field} = ${JSON.stringify(v)}, which the provider never sends`),
      ]
    })
    return [...sent, ...read]
  })

It runs with the tests, and from the command line. To try it, copy consumer.contract.json to planted.contract.json beside it. In round one, rename the route’s {salon} parameter to {salonId}; in round two, change the statuses it handles to booked and cancelled. Then run the report from the TypeScript course’s root, on Node.js 22.18 or later on the 22 line, or 24.3 or later, which run TypeScript directly:

$ node src/server-and-client-in-tandem/report.ts src/server-and-client-in-tandem/planted.contract.json
round one: POST /salons/{salonId}/book is not in the provider; it has "/salons/{salon}/book"
round two: POST /salons/{salon}/book reads structuredContent.status; the provider can send "not_booked", which this consumer doesn't handle
round two: POST /salons/{salon}/book handles structuredContent.status = "cancelled", which the provider never sends

Each line names its fix. The provider calls the parameter salon, so the copy goes back to {salon}. The provider can send not_booked, which the client must handle, and never sends cancelled. Run it on the consumer’s own copy, and it reads clean:

$ node src/server-and-client-in-tandem/report.ts src/server-and-client-in-tandem/consumer.contract.json
the booking client: no differences

The offered times are the one thing the copy can’t pin. They sit inside the form’s own schema, which the document types only as an object, so the decoder from the section before holds them, not the contract.

Extending the harness shows where both kinds of harness grow, the one a webmaster works in and the one shipped in an app, without forking either.

Copyright Sean Paul Payne Dinwiddie
All Rights Reserved