In The agent loop and its tools, the bakery’s tools lived in the same program as the loop. Put a tool behind an open agreement instead, and any agent that speaks the agreement can call it, whoever made the agent, with no adapter written for the pair. This lesson maps those agreements, one join at a time, and builds the one at the center by hand.
The running example is an invented coffee cart: a tool server an assistant can order through, the cart’s Servant API turned into a tool, an Agent Card for the bakery that supplies its buns, and an llms.txt for the salon from Writing an agentic skill. Where a protocol has a version or a revision, the page names the one it teaches.
One join, one agreement
An AI system crosses several joins, and each has an open agreement of its own. Learn the joins, and any new protocol finds its place on the map: ask which two things it joins, what crosses, and what checks it.
- MCP, the Model Context Protocol, joins an AI application to tools and data. A2A’s own docs call it vertical: it deepens one agent.
- A2A, the Agent2Agent protocol, joins one agent to another across a team or company line, which the same docs call horizontal.
- ACP, the Agent Client Protocol, joins a code editor to a coding agent; its current stable protocol version is 1.
- WebMCP, a community-group draft, lets a web page offer tools to an agent in the browser.
- OpenAPI joins a client to an HTTP API, as it does in Module 4.
- AGENTS.md and Agent Skills are file formats, not wire protocols: a repository’s standing instructions, and know-how an agent loads when a task calls for it. llms.txt is a proposal for pointing agents at a site’s content.
The wire protocols share a lot. MCP and ACP speak JSON-RPC 2.0 and publish JSON Schema for their messages; A2A defines its data model once, in Protocol Buffers, and binds it to JSON-RPC, gRPC and plain HTTP. MCP’s specification names its inspiration: the Language Server Protocol, which gave editors one standard joint for every language in place of an adapter for each pair.
Governance, in brief: Anthropic open-sourced MCP on November 25, 2024, and on December 9, 2025 donated it to the Agentic AI Foundation, a directed fund under the Linux Foundation co-founded by Anthropic, Block and OpenAI, with AGENTS.md and goose as fellow founding projects.
Google contributed A2A to the Linux Foundation, and in August 2026 it joined MCP at the Agentic AI Foundation as a Growth Stage project. OpenAPI is stewarded by the OpenAPI Initiative, a Linux Foundation Collaborative Project, and Agent Skills is an open standard Anthropic first developed.
Hosts, clients and servers, and who decides
MCP names three roles. The host is the AI application, the one that talks to the model and the person. Inside it, each client holds a connection to exactly one server. A server offers context and capabilities; the cart’s server is one.
A server offers three kinds of thing, and the specification sorts them by who decides when each is used. Tools are model-controlled: the model calls them. Resources are application-controlled: the host decides what to attach, such as a file the person picks. Prompts are user-controlled: templates a person chooses, often as slash commands.
Model-controlled never means unsupervised. The tools page says a person should always be able to deny a tool call, and that a client treats tool annotations, hints such as read-only, as untrusted unless the server is trusted. That’s the approval gate from Automating a business process with a person in the loop, now on the host’s side of the wire.
To try it, sort ten things the cart’s ordering app could offer by who decides when each is used: today’s menu, adding an item, a template for the week’s special, the cart’s hours, cancelling an order, the allergen sheet, a draft reply to a catering inquiry, a loyalty balance, yesterday’s sales and a refund.
One sorting: the menu, the hours, the allergen sheet and yesterday’s sales are resources; the special and the catering reply are prompts; the rest are tools, and a refund is a tool a person approves every time it’s called.
Stateless as of 2026-07-28
Revision 2026-07-28 changed MCP’s shape. There is no initialize handshake and no protocol session. Every request carries its protocol version and the client’s capabilities in _meta, under the io.modelcontextprotocol/ prefix, and the server answers each request on its own. Every server implements server/discover, so a client can ask what it speaks first (changelog).
The specification calls 2026-07-28 and later modern, and 2025-11-25 and earlier legacy. Tutorials from the legacy era teach the handshake, session IDs and the old HTTP+SSE transport, so check the revision at the top of anything you learn from. One server may speak both eras; the cart’s speaks the modern one and says so when a legacy client knocks (versioning).
Here is the cart server’s pure core. It takes the store and one message, and gives back the store and a reply:
// src/the-ai-protocol-map/server.ts
// The pure core of the cart's MCP server: the store and one message in, the store and a reply out.
import { todaysMenu, tools } from './cart.ts'
import { callTool, type Context, type Store } from './orders.ts'
import { error, META, record, result, VERSION, type Body, type Id, type Reply } from './wire.ts'
export type Step = { readonly store: Store; readonly reply?: Reply }
type Request = { readonly jsonrpc: '2.0'; readonly id?: Id; readonly method: string; readonly params?: Body }
// Invented freshness hints: lists keep five minutes, the menu one, since items sell out.
const listCache = { ttlMs: 300_000, cacheScope: 'public' }
const menuCache = { ttlMs: 60_000, cacheScope: 'public' }
const menu = { uri: 'menu://today', name: 'menu', title: "Today's menu", mimeType: 'application/json' }
const menuText = { uri: menu.uri, mimeType: menu.mimeType, text: JSON.stringify(todaysMenu) }
const asRequest = (message: unknown): Request | undefined => {
const m = record(message)
if (m?.jsonrpc !== '2.0' || typeof m.method !== 'string') return undefined
if ('id' in m && typeof m.id !== 'string' && typeof m.id !== 'number') return undefined
if ('params' in m && record(m.params) === undefined) return undefined
return m as Request
}
export const handle = (store: Store, message: unknown, ctx: Context): Step => {
const request = asRequest(message)
if (request === undefined) return { store, reply: error(undefined, -32600, 'Invalid Request') }
const { id, method, params } = request
if (id === undefined) return { store } // a notification is never answered
const refuse = (code: number, message: string, data?: unknown): Step => ({ store, reply: error(id, code, message, data) })
// The legacy era opened with a handshake. Answer it with the version this server speaks.
if (method === 'initialize')
return refuse(-32601, `initialize is the legacy handshake; this server speaks ${VERSION}`, { supported: [VERSION] })
// No session: every request says which version it speaks and what its client can do.
const meta = record(params?._meta)
const version = meta?.[`${META}protocolVersion`]
const capabilities = record(meta?.[`${META}clientCapabilities`])
if (typeof version !== 'string' || capabilities === undefined)
return refuse(-32602, `_meta needs ${META}protocolVersion and ${META}clientCapabilities`)
if (version !== VERSION)
return refuse(-32022, 'Unsupported protocol version', { supported: [VERSION], requested: version })
const answer = (body: Body): Step => ({ store, reply: result(id, 'complete', body) })
switch (method) {
case 'server/discover':
return answer({ supportedVersions: [VERSION], capabilities: { tools: {}, resources: {} }, ...listCache })
case 'tools/list':
return answer({ tools, ...listCache })
case 'resources/list':
return answer({ resources: [menu], ...listCache })
case 'resources/read':
if (params?.uri !== menu.uri) return refuse(-32602, 'Resource not found', { uri: params?.uri })
return answer({ contents: [menuText], ...menuCache })
case 'tools/call':
return callTool(store, id, params ?? {}, capabilities, ctx)
default:
return refuse(-32601, `Method not found: ${method}`)
}
}
A request in a version the server doesn’t speak gets error -32022, with the versions it does speak in data.supported, and the client retries in one of them. A notification has no id and never gets a reply. handle awaits nothing and reads no clock: the shell hands it the caller, the time and a fresh random handle, so every test holds the server’s whole memory as a value.
State that must last lives behind a handle. create_order mints one, the model carries it as an ordinary argument, and the server checks it on every call, keyed to the caller, so a guessed or leaked handle opens nothing. The security guide names the attack, state handle hijacking, and that defense.
When a tool needs something only the person can give, the server doesn’t call the client back: servers no longer send requests at all. It answers input_required, with its questions in inputRequests, and the client asks the person and retries the original request under a new id, with the answers in inputResponses. The specification calls this Multi Round-Trip Requests.
input_required, and the server keeps nothing. The violet second round is a new request, id 3, with the same arguments and the customer’s answer, and it completes. Server and client in tandem draws its booking in the same three-lane form.The cart’s retry carries all the server needs: the same arguments and the answer. A server that must remember something between rounds puts it in requestState, which the client echoes back untouched, and treats it as attacker-controlled: if it sways access or business logic, it carries an integrity check, such as an HMAC.
A server leans only on capabilities the client declares. One that can’t go on without an undeclared one answers error -32021, MissingRequiredClientCapability, listing what’s missing in data.requiredCapabilities. The cart can go on: a client that doesn’t declare elicitation, the capability for asking the person, gets a tool execution error telling the model to ask which size, then call again with it.
Roots, Sampling and Logging are deprecated in this revision, with at least twelve months before any removal. A stdio server logs to stderr instead, as the cart’s shell does below.
Tools: schemas, structured results and two kinds of failure
A tool is a name, a description written for the model, and JSON Schema for what goes in and, if it likes, what comes out. With no $schema of its own, a tool’s schema is JSON Schema 2020-12. Here are the cart’s menu and its two tools; the items, the prices and the thirty-minute expiry are invented for the lesson:
// src/the-ai-protocol-map/cart.ts
// A coffee cart's menu and tools, invented for the lesson: the items, prices and expiry are made up.
export type Size = 'small' | 'medium' | 'large'
export const sizes: readonly Size[] = ['small', 'medium', 'large']
// One price, or one for each size.
export type MenuItem = {
readonly name: string
readonly cents: number | Readonly<Record<Size, number>>
readonly soldOut: boolean
}
export const todaysMenu: readonly MenuItem[] = [
{ name: 'latte', cents: { small: 400, medium: 475, large: 550 }, soldOut: false },
{ name: 'drip coffee', cents: { small: 250, medium: 300, large: 350 }, soldOut: false },
{ name: 'morning bun', cents: 350, soldOut: false },
{ name: 'cardamom bun', cents: 375, soldOut: true },
]
export const ORDER_MINUTES = 30
const line = {
type: 'object',
properties: { item: { type: 'string' }, size: { enum: [...sizes, null] }, cents: { type: 'integer' } },
required: ['item', 'size', 'cents'],
}
// What tools/list returns: a name, a description and JSON Schema 2020-12 in and out.
export const tools = [
{
name: 'create_order',
title: 'Start a pickup order',
description: `Start a pickup order and get its handle for add_item. It expires after ${ORDER_MINUTES} minutes.`,
inputSchema: { type: 'object', additionalProperties: false },
outputSchema: { type: 'object', properties: { order: { type: 'string' } }, required: ['order'] },
},
{
name: 'add_item',
title: 'Add an item to an order',
description: "Add one item from today's menu (menu://today). A drink needs a size; without one, the customer is asked.",
inputSchema: {
type: 'object',
properties: {
order: { type: 'string', description: 'The handle create_order returned.' },
item: { type: 'string', description: "An item on today's menu, by name." },
size: { type: 'string', enum: sizes },
},
required: ['order', 'item'],
additionalProperties: false,
},
outputSchema: {
type: 'object',
properties: {
order: { type: 'string' },
lines: { type: 'array', items: line },
totalCents: { type: 'integer' },
},
required: ['order', 'lines', 'totalCents'],
},
},
] as const
create_order’s description says how long an order lasts, so the model knows before it starts one, as the tools page advises. Its input schema accepts an empty object and nothing else, the form the page recommends for a tool with no parameters.
Every result says what kind it is; the core defines complete and input_required. A tool that returns structuredContent holds it to its outputSchema, and sends the same JSON as text for clients that read only text.
Failure comes in two kinds. A protocol error means the request itself is wrong, such as an unknown tool, and goes back as a JSON-RPC error a model can rarely fix. A tool execution error is the tool’s own answer, such as a sold-out bun, and goes back as a result with isError: true and a reason the model can act on:
// src/the-ai-protocol-map/wire.ts
// JSON-RPC 2.0 as MCP 2026-07-28 uses it: every result says what kind of result it is.
export const VERSION = '2026-07-28'
export const META = 'io.modelcontextprotocol/'
export type Id = string | number
export type Body = Readonly<Record<string, unknown>>
type Failure = { readonly code: number; readonly message: string; readonly data?: unknown }
export type Reply =
| { readonly jsonrpc: '2.0'; readonly id: Id; readonly result: Body }
| { readonly jsonrpc: '2.0'; readonly id?: Id; readonly error: Failure }
const serverInfo = { name: 'coffee-cart', version: '0.1.0' }
export const result = (id: Id, resultType: 'complete' | 'input_required', body: Body): Reply => ({
jsonrpc: '2.0',
id,
result: { resultType, ...body, _meta: { [`${META}serverInfo`]: serverInfo } },
})
// A protocol error: the request itself is wrong, and a model can rarely fix that.
export const error = (id: Id | undefined, code: number, message: string, data?: unknown): Reply => ({
jsonrpc: '2.0',
...(id === undefined ? {} : { id }),
error: data === undefined ? { code, message } : { code, message, data },
})
// A tool's own outcome is a result: structured content, or an error a model can act on.
export const toolResult = (structured: Body): Body => ({
content: [{ type: 'text', text: JSON.stringify(structured) }],
structuredContent: structured,
isError: false,
})
export const toolError = (text: string): Body => ({ content: [{ type: 'text', text }], isError: true })
export const record = (value: unknown): Body | undefined =>
typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Body) : undefined
The specification’s examples put input validation on both sides of that line. The cart follows the schema’s own example and sends arguments that break the input schema back as -32602, and every rule of the cart as a result. In Module 5’s loop an unknown tool came back as a result the model read; across MCP it’s a protocol error, and the host decides what the model sees.
// src/the-ai-protocol-map/orders.ts
// The cart's two tools. An order lives behind a handle the server mints, checked on every call.
import { ORDER_MINUTES, sizes, todaysMenu, type MenuItem, type Size } from './cart.ts'
import { error, record, result, toolError, toolResult, type Body, type Id, type Reply } from './wire.ts'
type Line = { readonly item: string; readonly size: Size | null; readonly cents: number }
type Order = { readonly startedAt: number; readonly lines: readonly Line[] }
// Orders are kept under the caller and the handle together, as one JSON key, so a handle alone opens nothing.
export type Store = Readonly<Record<string, Order>>
// What the shell knows and the core never fetches: who is calling, the time, a fresh random handle.
export type Context = { readonly caller: string; readonly now: number; readonly freshHandle: string }
type Step = { readonly store: Store; readonly reply: Reply }
const keyOf = (caller: string, handle: string): string => JSON.stringify([caller, handle])
type AddItem = { readonly order: string; readonly item: string; readonly size?: Size }
const decode = (args: Body): AddItem | string => {
const { order, item, size, ...rest } = args
if (typeof order !== 'string') return 'order must be the handle create_order returned'
if (typeof item !== 'string') return "item must be a name on today's menu"
const known = sizes.find((s) => s === size)
if (size !== undefined && known === undefined) return `size must be one of ${sizes.join(', ')}`
if (Object.keys(rest).length > 0) return `unknown arguments: ${Object.keys(rest).join(', ')}`
return known === undefined ? { order, item } : { order, item, size: known }
}
// A size from the arguments, or from the customer's answer when the call is retried.
const chosenSize = (input: AddItem, params: Body): Size | 'declined' | undefined => {
if (input.size !== undefined) return input.size
const answer = record(record(params.inputResponses)?.size)
if (answer === undefined) return undefined
if (answer.action !== 'accept') return 'declined'
return sizes.find((s) => s === record(answer.content)?.size)
}
type Pricing =
| { readonly kind: 'priced'; readonly size: Size | null; readonly cents: number }
| { readonly kind: 'ask' }
| { readonly kind: 'refuse'; readonly reason: string }
// A drink needs a size: from the call, from the customer if the client can ask, or a reason to stop.
const pricing = (item: MenuItem, input: AddItem, params: Body, capabilities: Body): Pricing => {
if (typeof item.cents === 'number') return { kind: 'priced', size: null, cents: item.cents }
const chosen = chosenSize(input, params)
if (chosen === 'declined') return { kind: 'refuse', reason: 'The customer chose no size, so nothing was added.' }
if (chosen !== undefined) return { kind: 'priced', size: chosen, cents: item.cents[chosen] }
const form = record(capabilities.elicitation)
if (form !== undefined && (Object.keys(form).length === 0 || 'form' in form)) return { kind: 'ask' }
const choices = `${sizes.slice(0, -1).join(', ')} or ${sizes.at(-1)}`
return { kind: 'refuse', reason: `A ${item.name} comes in ${choices}. Ask which, then call add_item with size.` }
}
const askSize = (item: string): Body => ({
inputRequests: {
size: {
method: 'elicitation/create',
params: {
mode: 'form',
message: `Which size of ${item}?`,
requestedSchema: { type: 'object', properties: { size: { type: 'string', enum: sizes } }, required: ['size'] },
},
},
},
})
export const callTool = (store: Store, id: Id, params: Body, capabilities: Body, ctx: Context): Step => {
const done = (body: Body): Step => ({ store, reply: result(id, 'complete', body) })
const refuse = (message: string): Step => ({ store, reply: error(id, -32602, message) })
const args = params.arguments === undefined ? {} : record(params.arguments)
if (args === undefined) return refuse('arguments must be an object')
if (params.name === 'create_order') {
if (Object.keys(args).length > 0) return refuse('create_order takes no arguments')
const opened = { ...store, [keyOf(ctx.caller, ctx.freshHandle)]: { startedAt: ctx.now, lines: [] } }
return { store: opened, reply: result(id, 'complete', toolResult({ order: ctx.freshHandle })) }
}
if (params.name !== 'add_item') return refuse(`Unknown tool: ${String(params.name)}`)
const input = decode(args)
if (typeof input === 'string') return refuse(`Invalid arguments for add_item: ${input}`)
const key = keyOf(ctx.caller, input.order)
const order = store[key]
if (order === undefined) return done(toolError(`There is no order ${input.order}. Start one with create_order.`))
if (ctx.now - order.startedAt > ORDER_MINUTES * 60_000)
return done(toolError(`Order ${input.order} expired after ${ORDER_MINUTES} minutes. Start a new one.`))
const onMenu = todaysMenu.find((i) => i.name === input.item)
const available = todaysMenu.filter((i) => !i.soldOut).map((i) => i.name).join(', ')
if (onMenu === undefined) return done(toolError(`Today's menu has no ${input.item}. It has ${available}.`))
if (onMenu.soldOut) return done(toolError(`The ${onMenu.name} is sold out today. Still available: ${available}.`))
const priced = pricing(onMenu, input, params, capabilities)
if (priced.kind === 'ask') return { store, reply: result(id, 'input_required', askSize(onMenu.name)) }
if (priced.kind === 'refuse') return done(toolError(priced.reason))
const lines = [...order.lines, { item: onMenu.name, size: priced.size, cents: priced.cents }]
const totalCents = lines.reduce((sum, l) => sum + l.cents, 0)
const reply = result(id, 'complete', toolResult({ order: input.order, lines, totalCents }))
return { store: { ...store, [key]: { ...order, lines } }, reply }
}
Each failure that belongs to the cart says what to do next: start a new order, pick from what’s still available, ask which size. That’s expected failure as plain data, as the lecture Practical Applications of Functional Programming teaches it. A client decodes each result at the boundary before trusting it, as Endpoints at the boundary decodes an RTK Query response.
The scenarios live in cart.feature, in Gherkin, and each test carries its scenario’s name; a last test fails if the two lists drift apart:
$ npx vitest run src/the-ai-protocol-map/server.test.ts --reporter=tree | grep -E 'โ|Tests'
โ src/the-ai-protocol-map/server.test.ts (14 tests) 13ms
โ Feature: The coffee cart's tool server (14)
โ Scenario: A client discovers what the server speaks 4ms
โ Scenario: A request in another version is refused with the versions the server speaks 1ms
โ Scenario: A legacy handshake is refused by name 1ms
โ Scenario: The agent starts an order and adds a drink 1ms
โ Scenario: A missing size asks the customer, and the retry completes the call 1ms
โ Scenario: A client that can't ask the customer reads a reason instead 0ms
โ Scenario: An expired order handle is an execution error 1ms
โ Scenario: An order handle opens nothing for another caller 0ms
โ Scenario: A handle can't borrow the end of another caller's name 1ms
โ Scenario: A sold-out item is an execution error the model can act on 0ms
โ Scenario: An unknown tool is a protocol error 0ms
โ Scenario: The menu is a resource the application reads 0ms
โ Scenario: A notification gets no reply 0ms
โ names every scenario in cart.feature 0ms
Tests 14 passed (14)
Transports and trust
A transport is a binding: it carries messages and never changes what they mean. On stdio, the client launches the server as a subprocess and they trade JSON-RPC one message per line. The server writes nothing to stdout that isn’t a protocol message, and logs, if anywhere, to stderr. Streamable HTTP uses one endpoint that takes a POST for each message and answers with JSON or a stream scoped to that request.
// src/the-ai-protocol-map/stdio.ts
// The stdio shell: one JSON-RPC message per line in, one per line out, and logs only on stderr.
import { randomUUID } from 'node:crypto'
import { realpathSync } from 'node:fs'
import { createInterface } from 'node:readline'
import type { Readable, Writable } from 'node:stream'
import { pathToFileURL } from 'node:url'
import type { Store } from './orders.ts'
import { handle } from './server.ts'
import { record } from './wire.ts'
const parseError = { jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' } }
export const serve = async (input: Readable, output: Writable, log: Writable, caller: string) => {
let store: Store = {}
for await (const line of createInterface({ input, crlfDelay: Infinity })) {
if (line.trim() === '') continue
let message: unknown
try {
message = JSON.parse(line)
} catch {
output.write(`${JSON.stringify(parseError)}\n`)
continue
}
const step = handle(store, message, { caller, now: Date.now(), freshHandle: `ord_${randomUUID()}` })
store = step.store
if (step.reply !== undefined) output.write(`${JSON.stringify(step.reply)}\n`)
const outcome = step.reply === undefined ? 'no reply' : 'error' in step.reply ? 'error' : 'result'
log.write(`${String(record(message)?.method)} -> ${outcome}\n`)
}
}
// Run as a program, the caller comes from the environment that launched it.
const script = process.argv[1]
if (script !== undefined && import.meta.url === pathToFileURL(realpathSync(script)).href) {
await serve(process.stdin, process.stdout, process.stderr, process.env.CART_CALLER ?? 'local')
}
The shell does the three things the core never does: it reads the clock, mints a random handle and keeps the store from one line to the next. Here it answers a legacy handshake and a modern discovery request, with stderr sent to a file:
$ node src/the-ai-protocol-map/stdio.ts < src/the-ai-protocol-map/first-contact.jsonl 2> stderr.log
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"initialize is the legacy handshake; this server speaks 2026-07-28","data":{"supported":["2026-07-28"]}}}
{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","supportedVersions":["2026-07-28"],"capabilities":{"tools":{},"resources":{}},"ttlMs":300000,"cacheScope":"public","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"coffee-cart","version":"0.1.0"}}}}
$ cat stderr.log
initialize -> error
server/discover -> result
Two replies out, two lines logged, nothing mixed. On Streamable HTTP the tool definitions stay the same; only the binding changes, and where the credentials come from.
Authorization is optional. A server on HTTP that uses it acts as an OAuth 2.1 resource server; a server on stdio takes its credentials from the environment, the way the shell takes its caller.
Two attacks from the security guide are the ones to learn first. Token passthrough is a server accepting a token issued for something else and forwarding it downstream; the rule against it is short: a server accepts only tokens issued to itself.
The confused deputy is a proxy server that signs in to a third party under one static client ID while letting MCP clients register themselves. A consent cookie from the person’s earlier sign-in skips the consent screen, so an attacker’s link, carrying a freshly registered client and its own redirect URI, collects an authorization code. The defense: the proxy asks the person’s consent for each client before it forwards anything.
From a Servant operation to a tool
In Module 4, one Servant type is the contract: it describes itself as OpenAPI, and RTK Query’s generator turns the document into the client’s endpoints (The API: Haskell Servant and Nile). A tool list is one more consumer of the same document.
Here is the cart’s order endpoint, written fresh for this lesson, with a summary and a description for whoever calls it. Mind the note: OpenAPI 3.0 has no null type, so a field that may be null says nullable: true.
The Haskell in this module compiles under the course’s cabal file, which turns on GHC2021 for every module, plus DataKinds, DeriveGeneric, DerivingStrategies, LambdaCase, OverloadedStrings and TypeOperators. A module copied into a fresh project needs the same settings, or LANGUAGE pragmas of its own.
-- the-ai-protocol-map/CoffeeCart.hs
-- A coffee cart's ordering API, invented for the lesson.
module CoffeeCart (CartAPI, NewOrder (..), Size (..), Note (..), cartOpenApi) where
import Data.Aeson (FromJSON (..), ToJSON (..), genericParseJSON, genericToJSON)
import qualified Data.Aeson as Aeson
import Data.Char (toLower)
import Data.Function ((&))
import Data.Functor.Identity (Identity (..))
import Data.OpenApi
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import GHC.Generics (Generic)
import Servant.API
import Servant.OpenApi (toOpenApi)
data Size = Small | Medium | Large
deriving stock (Generic, Show, Eq)
-- On the wire a size is "small", "medium" or "large", in the JSON and the schema alike.
lower :: Aeson.Options
lower = Aeson.defaultOptions {Aeson.constructorTagModifier = map toLower}
instance ToJSON Size where toJSON = genericToJSON lower
instance FromJSON Size where parseJSON = genericParseJSON lower
instance ToSchema Size where declareNamedSchema = genericDeclareNamedSchema (fromAesonOptions lower)
-- A note the customer may send as null: OpenAPI 3.0 says so with nullable.
newtype Note = Note Text
deriving newtype (Show, Eq, ToJSON, FromJSON)
instance ToSchema Note where
declareNamedSchema _ =
pure . NamedSchema Nothing $
mempty
{ _schemaType = Just OpenApiString
, _schemaNullable = Just True
, _schemaExample = Just "extra hot"
}
data NewOrder = NewOrder {item :: Text, size :: Size, note :: Maybe Note}
deriving stock (Generic, Show, Eq)
instance ToJSON NewOrder
instance FromJSON NewOrder
instance ToSchema NewOrder
type CartAPI =
"orders"
:> Summary "Place a pickup order at the coffee cart."
:> Description "Use it once the customer has chosen an item and a size. It answers with the order number."
:> ReqBody '[JSON] NewOrder
:> PostCreated '[JSON] Text
-- lens's set, from base alone: a setter is a traversal run in Identity.
set :: ((a -> Identity b) -> s -> Identity t) -> b -> s -> t
set l b = runIdentity . l (const (Identity b))
-- The one contract: Servant's type, described as OpenAPI 3.0, with its operation named.
cartOpenApi :: OpenApi
cartOpenApi =
toOpenApi (Proxy :: Proxy CartAPI)
& set (paths . traverse . post . traverse . operationId) (Just "placeOrder")
set is lens’s own, written from base in two lines, since a setter is a traversal run in Identity; the traversal names the one operation placeOrder. The document is committed beside the type, and a spec fails when the two drift apart, naming every place they differ:
-- the-ai-protocol-map/Spec.hs
module Main (main) where
import CoffeeCart (cartOpenApi)
import Control.Monad (when)
import Data.Aeson (Value (..), eitherDecodeFileStrict, encode, encodeFile, object, toJSON, (.=))
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KeyMap
import qualified Data.ByteString.Lazy.Char8 as BL
import Data.Foldable (toList)
import Data.Maybe (isJust)
import qualified Data.Set as Set
import System.Environment (lookupEnv)
import Test.Hspec
committedPath :: FilePath
committedPath = "the-ai-protocol-map/openapi.json"
-- Every place two JSON documents differ, as a path from the root.
drift :: Value -> Value -> [String]
drift = go ""
where
go at (Object a) (Object b) =
concat [field at k (KeyMap.lookup k a) (KeyMap.lookup k b) | k <- keysOf a b]
go at (Array a) (Array b)
| length a == length b = concat (zipWith3 go (indexes at) (toList a) (toList b))
go at a b = [at <> ": committed " <> json a <> ", generated " <> json b | a /= b]
field at k (Just a) (Just b) = go (at <> "/" <> Key.toString k) a b
field at k Nothing _ = [at <> "/" <> Key.toString k <> ": only in the generated document"]
field at k _ Nothing = [at <> "/" <> Key.toString k <> ": only in the committed document"]
json = BL.unpack . encode
keysOf a b = Set.toList (Set.fromList (KeyMap.keys a <> KeyMap.keys b))
indexes at = [at <> "/" <> show i | i <- [0 :: Int ..]]
main :: IO ()
main = do
-- ACCEPT_OPENAPI=1 rewrites the committed document; a person reviews the diff.
accept <- lookupEnv "ACCEPT_OPENAPI"
when (isJust accept) $ encodeFile committedPath cartOpenApi
hspec $ describe "Feature: One contract for the coffee cart" $ do
it "Scenario: The drift check names every place two documents differ" $
drift (object ["a" .= (1 :: Int), "b" .= True]) (object ["a" .= (2 :: Int), "c" .= True])
`shouldBe` [ "/a: committed 1, generated 2"
, "/b: only in the committed document"
, "/c: only in the generated document"
]
it "Scenario: The committed OpenAPI document is the one the type generates" $ do
committed <- either fail pure =<< eitherDecodeFileStrict committedPath
drift committed (toJSON cartOpenApi) `shouldBe` []
Add a field to the order, forget to regenerate, and the spec says exactly what moved. ACCEPT_OPENAPI=1 rewrites the file, and the diff goes to review like any other. It’s in the spirit of the Functional Composition lecture’s section on testing the contract: what the document promises is tested, never trusted.
The runs on this page come from a UTF-8 terminal; under the POSIX locale, Hspec marks a pass [v] where they show ✔.
$ cabal test the-ai-protocol-map --test-show-details=direct | grep -E 'โ|โ|examples'
Scenario: The drift check names every place two documents differ [โ]
Scenario: The committed OpenAPI document is the one the type generates [โ]
2 examples, 0 failures
$ sed -i.bak 's/note :: Maybe Note}/note :: Maybe Note, pickupName :: Text}/' the-ai-protocol-map/CoffeeCart.hs
$ cabal test the-ai-protocol-map --test-show-details=direct | grep -E 'โ|but got|examples'
Scenario: The committed OpenAPI document is the one the type generates [โ]
but got: ["/components/schemas/NewOrder/properties/pickupName: only in the generated document","/components/schemas/NewOrder/required: committed [\"item\",\"size\"], generated [\"item\",\"size\",\"pickupName\"]"]
2 examples, 1 failure
Now the dialect. servant-openapi3 builds on openapi3, an OpenAPI 3.0 data model, and the document says "openapi": "3.0.0". OpenAPI 3.1 made its Schema Object a superset of JSON Schema 2020-12, but 3.0’s schemas speak an older dialect. So a tool’s input schema is converted, never copied:
nullable: truebecomes a type union,["string", "null"];examplebecomesexamples, a list;- a boolean
exclusiveMinimumbesideminimumbecomes the bound itself; - a reference into
componentsmoves into$defs, so the tool’s schema stands alone.
// src/the-ai-protocol-map/operation-to-tool.ts
// An OpenAPI 3.0 operation, as servant-openapi3 writes it, to an MCP tool in JSON Schema 2020-12.
type Schema = Readonly<Record<string, unknown>>
type Named = Readonly<Record<string, Schema>>
type Media = Readonly<Record<string, { readonly schema?: Schema }>>
export type Operation = {
readonly operationId?: string
readonly summary?: string
readonly description?: string
readonly parameters?: readonly unknown[]
readonly requestBody?: { readonly content: Media }
}
export type OpenApi = {
readonly paths: Readonly<Record<string, Readonly<Record<string, Operation>>>>
readonly components?: { readonly schemas?: Named }
}
export type Tool = { readonly name: string; readonly description: string; readonly inputSchema: Schema }
export type Converted = { readonly ok: true; readonly tool: Tool } | { readonly ok: false; readonly reason: string }
const FROM = '#/components/schemas/'
const isSchema = (v: unknown): v is Schema => typeof v === 'object' && v !== null && !Array.isArray(v)
const each = (named: Named): Named =>
Object.fromEntries(Object.entries(named).map(([key, schema]) => [key, toJsonSchema(schema)]))
// One schema, from OpenAPI 3.0's dialect to JSON Schema 2020-12, all the way down.
export const toJsonSchema = (schema: Schema): Schema => {
const out: Record<string, unknown> = {}
for (const [key, value] of Object.entries(schema)) {
if (['nullable', 'exclusiveMinimum', 'exclusiveMaximum'].includes(key)) continue
else if (key === 'example') out.examples = [value]
else if (key === '$ref' && typeof value === 'string') out.$ref = value.replace(FROM, '#/$defs/')
else if (key === 'properties' && isSchema(value)) out.properties = each(value as Named)
else if (['items', 'additionalProperties', 'not'].includes(key) && isSchema(value)) out[key] = toJsonSchema(value)
else if (['allOf', 'anyOf', 'oneOf'].includes(key)) out[key] = (value as Schema[]).map(toJsonSchema)
else out[key] = value
}
// 3.0 adds null with a flag beside the type; 2020-12 lists null in the type.
if (schema.nullable === true && typeof schema.type === 'string') out.type = [schema.type, 'null']
// 3.0 marks a bound exclusive with a boolean; 2020-12 makes the bound itself exclusive.
if (schema.exclusiveMinimum === true) {
out.exclusiveMinimum = schema.minimum
delete out.minimum
}
if (schema.exclusiveMaximum === true) {
out.exclusiveMaximum = schema.maximum
delete out.maximum
}
return out
}
// The named schemas a schema reaches, following every reference.
const reached = (schema: Schema, named: Named, found = new Set<string>()): Set<string> => {
for (const [, name = ''] of JSON.stringify(schema).matchAll(/"\$ref":"#\/components\/schemas\/([^"]+)"/g)) {
const target = named[name]
if (target === undefined || found.has(name)) continue
found.add(name)
reached(target, named, found)
}
return found
}
export const findOperation = (doc: OpenApi, id: string): Operation | undefined =>
Object.values(doc.paths).flatMap((item) => Object.values(item)).find((op) => op.operationId === id)
export const operationToTool = (doc: OpenApi, op: Operation): Converted => {
const fail = (reason: string): Converted => ({ ok: false, reason })
const name = op.operationId
if (name === undefined || !/^[A-Za-z0-9_.-]{1,128}$/.test(name)) return fail('it needs an operationId fit to name a tool')
if (op.summary === undefined || op.description === undefined)
return fail(`${name} needs a summary and a description written for an agent: what it does and when to use it`)
if ((op.parameters ?? []).length > 0)
return fail(`${name} takes path or query parameters: map each by hand, as some come from the credential`)
const named = doc.components?.schemas ?? {}
const json = Object.entries(op.requestBody?.content ?? {}).find(([type]) => type.startsWith('application/json'))
const body = json?.[1].schema ?? { type: 'object', additionalProperties: false }
const top = typeof body.$ref === 'string' ? named[body.$ref.replace(FROM, '')] : body
if (top?.type !== 'object') return fail(`${name} takes a body that is not a JSON object`)
const defs = [...reached(top, named)]
const $defs = Object.fromEntries(defs.map((d) => [d, toJsonSchema(named[d] ?? {})]))
const inputSchema = defs.length === 0 ? toJsonSchema(top) : { ...toJsonSchema(top), $defs }
return { ok: true, tool: { name, description: `${op.summary} ${op.description}`, inputSchema } }
}
Each rule has a test named for its scenario: Given a nullable field, When the operation becomes a tool, Then null is allowed by a type union. One more converts placeOrder from the committed document, the one file both languages read, since the TypeScript folder’s openapi.json is a link to the one the Haskell spec commits:
$ node src/the-ai-protocol-map/show-tool.ts placeOrder
{
"name": "placeOrder",
"description": "Place a pickup order at the coffee cart. Use it once the customer has chosen an item and a size. It answers with the order number.",
"inputSchema": {
"properties": {
"item": {
"type": "string"
},
"note": {
"examples": ["extra hot"],
"type": ["string", "null"]
},
"size": {
"$ref": "#/$defs/Size"
}
},
"required": ["item", "size"],
"type": "object",
"$defs": {
"Size": {
"enum": ["small", "medium", "large"],
"type": "string"
}
}
}
}
The converter refuses what it can’t do safely. An operation needs a summary and a description written for an agent, saying what it does and when to use it. And one with path or query parameters is mapped by hand, since some of those, such as a tenant or a signed-in user, come from the credential and never from the model.
Some operations become tools that never run without a person’s yes. Placing an order commits the cart to a drink and the customer to paying for it, so the host asks before every call, as the approval gate does.
Agents as peers, files as know-how, pages that offer tools
A2A treats other agents as peers that stay opaque: they work together without sharing their memory, their logic or their tools. An agent publishes an Agent Card, conventionally at /.well-known/agent-card.json, naming its skills, its endpoints and how to authenticate. Work happens in Tasks with a lifecycle (submitted, working, input required, completed and more), Messages carry Parts such as text, files or data, and results come back as Artifacts.
Here is a card at A2A 1.0 for the bakery that supplies the cart’s morning buns, src/the-ai-protocol-map/bakery.agent-card.json. The cart’s agent can ask for a quote without ever seeing how the bakery prices one:
{
"name": "Bakery wholesale agent",
"description": "Quotes wholesale bakes and schedules deliveries for cafes and coffee carts that resell them. An invented bakery, for the lesson.",
"supportedInterfaces": [{ "url": "https://bakery.example/a2a/v1", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" }],
"provider": { "organization": "An invented bakery", "url": "https://bakery.example" },
"version": "1.0.0",
"capabilities": { "streaming": false, "pushNotifications": true },
"securitySchemes": { "bearer": { "httpAuthSecurityScheme": { "scheme": "Bearer" } } },
"securityRequirements": [{ "schemes": { "bearer": { "list": [] } } }],
"defaultInputModes": ["text/plain", "application/json"],
"defaultOutputModes": ["application/json"],
"skills": [
{
"id": "quote-wholesale",
"name": "Quote a wholesale order",
"description": "Prices a standing or one-off wholesale order of the bakery's buns and loaves.",
"tags": ["wholesale", "pricing"],
"examples": ["Two dozen morning buns every Saturday for a month"]
},
{
"id": "schedule-delivery",
"name": "Schedule a delivery",
"description": "Books a delivery window for an accepted quote.",
"tags": ["delivery", "scheduling"]
}
]
}
A type guard in src/the-ai-protocol-map/peers.ts checks the fields the 1.0 data model requires of the card and of each part it names, from its provider and interfaces to its skills, security schemes and signatures, and reports each fault it finds. Its tests read this card and the salon’s file below, and both pass.
Two file formats carry know-how. AGENTS.md is a README for coding agents: plain Markdown with no required fields, where the closest file to the one being edited wins and the person’s own instructions override everything. An Agent Skill is a folder an agent loads a little at a time, by progressive disclosure; Writing an agentic skill builds one.
Two more meet a webmaster at the page itself, and both are emerging. WebMCP, a draft of the W3C’s Web Machine Learning Community Group with editors from Microsoft and Google, lets a page register a tool with document.modelContext.registerTool() or offer a form as one. Its status page lists origin trials in Chrome and Edge.
A tool registered that way runs the page’s own code, so on the cart’s order page it would dispatch the same action the Add button does, and the agent and the person share one state.
And llms.txt, Jeremy Howard’s proposal, revised as v2 in August 2026, is a Markdown file at a site’s root, or under any path, that sums up the site and links to clean Markdown versions of its pages. Here is the salon’s, src/the-ai-protocol-map/salon-llms.txt:
# The salon
> An invented salon, for the lesson: cuts, color and blowouts, Tuesday to Saturday. Bookings are by email, and a person confirms every one.
Open Tuesday, Wednesday and Friday 9 to 5, Thursday 9 to 7 and Saturday 9 to 3. Closed Sunday and Monday.
## Services
- [Cuts](https://salon.example/services/cut.md): 45 minutes
- [Color](https://salon.example/services/color.md): two hours
- [Blowouts](https://salon.example/services/blowout.md): 30 minutes
## Booking
- [How to book](https://salon.example/booking.md): the service, and two times that suit
## Optional
- [The stylists](https://salon.example/stylists.md)
The principle under them all: give agents the same honest, structured path you give people. The card says what the bakery’s agent will do, the file says what the salon offers, and the page’s tool does exactly what its button does.
The joins are mapped. Geometric reasoning as data turns to what crosses them: the coffee cart’s possible answers, declared as points in one design file that the TypeScript and Python lessons both read.