An MCP Tool Call, Deconstructed
Last month I joined the first cohort of ambassadors for the Agentic AI Foundation, the Linux Foundation home of MCP, goose, agentgateway, and AGENTS.md. Ambassadors commit to one public contribution per month, and I want mine to be the thing I find most useful when I read protocol specs: explanations where you can see the machinery move. So this post is the first of several “deconstructions” of agentic processes.

140 ambassadors, 41 countries, and exactly one pin over Brazil. Hi.
Full disclosure before we start: I work at Arcade, where I spend my days on MCP infrastructure and navigating the idiosyncrasies of the different MCP clients and harnesses and how they impact real-world agentic work. That makes me biased and it makes me the kind of person who reads specs and SEPs for fun. I’ll let you decide how those cancel out.
The first subject is super obvious. MCP is about to ship its next release, and
the draft revision,
dated 2026-07-28, is the most impactful rewrite the protocol has had since it
appeared. While it’s listed second in the key changes, the core change is that
MCP is now stateless. The initialize handshake is
gone. Sessions are gone. Servers no longer send requests to clients.
Every one of those sentences changes the shape and lifecycle of a tool call, so
I think the best way
to understand the release is to follow real calls through the whole stack and
watch what happens under the hood.
The dissection below has five timelines. Two of them are setup, and they run rarely: the day you add a server to your harness, and the moment a chat conversation starts (yes, a session, as it’s tradition to overload the same words in every layer of the agentic stack 🥲). The other three are storylines, and they run on every prompt. I picked three because “a tool call” behaves very differently depending on who has to prove what to whom: a tool with no auth at all, a tool guarded by OAuth between the client and the server, and a tool where the server needs your permission to reach a third service. These are just three of many shapes a call can take, but they cover the auth boundaries you’ll hit most often.
I’m aware that mermaid diagrams are not for everyone, so I made these ones interactive, you can expand each event to see what happens at that point. For those used to the current version of the protocol, you only need to open the green events, which represent a new or changed part of the protocol.
The cast
Six pieces of software and one human appear in these stories, and the spec is picky about their names. The picky distinctions really matter, so here they are up front. Note that they don’t always appear in every storyline.
- You. The human. You appear more often than you might expect.
- The harness. The spec calls this the host: the application you actually use, whether that’s Claude Code in a terminal, an IDE, or a chat app. It owns the conversation, decides what the model sees, and enforces consent. I’ll use harness throughout because that’s the word everyone uses.
- The model. The LLM behind an inference API. Importantly: the model never speaks MCP. It emits a structured “I want to call this tool” block in the provider’s format, and that is the end of its involvement.
- The MCP client. A component inside the harness that manages the relationship to the MCP server. It speaks the protocol, holds the tokens, and does the retrying. When people say “the client is the app”, they’re flattening a boundary deliberately established in the spec.
- The MCP server. The thing with the tools. The one in this blog post is fictional: Pinehill
Cabins, which rents cabins to teams and exposes
check_availabilityandbook_cabinathttps://cabins.example.com/mcp. - The authorization server. The OAuth 2.1 service that issues tokens for Pinehill. It is a separate role from the MCP server, even if one company operates both. Appears in the first-contact timeline, then hides behind a refresh token forever (or until the refresh token expires).
- CalConnect. A fictional third-party calendar service that Pinehill’s server integrates with. Appears in what is in my opinion the most interesting storyline, where it triggers one of the most interesting dances in the whole release.
The rules of this dissection
A note before you start clicking, so you know what I simplified on purpose:
- Tools only. MCP also has resources, prompts, subscriptions, and extensions that ship interactive UI into conversations, handle other OAuth flows, or long running tasks (with more being actively worked on). Each of those deserve their own post. But not today.
- Setup runs once, storylines run per prompt. The two setup timelines cover meeting a server and starting a session. The three storylines assume that work already happened, which is exactly how it happens in practice. Nuanced scenarios such as caching server information and/or reconnecting are not part of the storylines. (Tell me if you’d like me to cover them!)
- Remote servers over Streamable HTTP only. I don’t cover local MCP servers (the ones your harness launches as a subprocess and talks to over stdio). Every storyline here is a remote server reached over the Streamable HTTP transport, which is where the OAuth and cross-service machinery matters.
- One registration mechanism. The spec offers three ways for a client to
get a
client_id: pre-registered credentials, Client ID Metadata Documents, and Dynamic Client Registration, which is now deprecated. I show only CIMD, because it is the recommended path when a client and a server have no prior relationship, and that is the normal case in MCP. - Payloads are real, names are fake. Every message follows the 2026-07-28 draft; Pinehill and CalConnect do not exist (AFAIK, if you work at Pinehill or CalConnect and you’re mad, let me know and also… WTF??).
First contact: the day you add the server
Every relationship in these stories starts the same way: you paste one URL
into your harness. Everything else gets discovered from it, and the
discovery chain is where this release flexes its OAuth muscles. The pieces
are standard building blocks: protected resource metadata
to find the authorization server, PKCE and resource indicators to protect
the exchange, the new iss check to know who is answering, and Client ID
Metadata Documents so that registration becomes publishing a JSON file
instead of filling a form on someone else’s server.
Two things to watch for in this timeline. First, server/discover, a new member
of the protocol introduced in this draft: every server
must implement it, and it doubles as a compatibility probe for older
servers. Second, notice what the client does when it wants to authenticate:
it fetches metadata documents and opens your browser. Nothing here is
required to happen at add-time; a client may lazily wait for its first 401,
which points at the same metadata. Harnesses front-load it because a sign-in
dialog is friendlier the day you add a server than in the middle of a task.
Also notice the model has no lane here. Setup is none of its business.
1 you add the server to your MCP config
You add Pinehill to your harness 🛠️ happens at You + Harness
At this moment, Pinehill is one URL in a config file. That is all a server ever starts as. No spec governs this file; it is pure harness territory. Everything else about the server, its protocol version, its capabilities, and the authorization server guarding it, gets discovered from that URL.
// ~/.config/agentapp/mcp.json
{
"mcpServers": {
"pinehill": {
"url": "https://cabins.example.com/mcp"
}
}
} 2 server/discover
Asking the server what it speaks 🔌 MCP client ⇄ ⚙️ MCP server
The one discovery RPC in the protocol, and it is new: every
server must implement server/discover, and a client may
call it before anything else to learn the supported protocol versions
and capabilities up front. It also doubles as a backward-compatibility
probe: a server that chokes on it is speaking an older,
handshake-era version of MCP.
POST /mcp HTTP/1.1
Host: cabins.example.com
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: server/discover
{
"jsonrpc": "2.0",
"id": 1,
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "AgentApp",
"version": "3.2.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} },
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
} {
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"serverInfo": { "name": "pinehill-cabins", "version": "2.1.0" },
"protocolVersions": ["2026-07-28"],
"capabilities": {
"tools": { "listChanged": true },
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
} 3 protected resource metadata
Is this server guarded? 🔌 MCP client ⇄ ⚙️ MCP server
The client fetches the protected resource metadata document
(RFC 9728) from its well-known location. It names the authorization
server that can issue tokens for this MCP server, and the scopes on
offer. The MCP server declares who it trusts; it does not issue tokens
itself. A lazier client could skip this and wait for its first 401,
whose WWW-Authenticate header points at this same document.
Harnesses front-load it because a sign-in dialog is friendlier the day
you add a server than in the middle of a task.
GET /.well-known/oauth-protected-resource HTTP/1.1
Host: cabins.example.com {
"resource": "https://cabins.example.com/mcp",
"authorization_servers": ["https://auth.pinehill.example"],
"scopes_supported": ["bookings:read", "bookings:write"]
} 4 AS metadata discovery
Reading the auth server’s capabilities 🔌 MCP client ⇄ 🔑 Auth server
Next stop is the authorization server's own metadata (RFC 8414).
Two flags matter for what happens next:
client_id_metadata_document_supported means the client will
not need to register at all, and
authorization_response_iss_parameter_supported announces
that responses will carry an issuer identifier the client must verify.
The client records the issuer value now; step 9 depends on
it being authentic.
GET /.well-known/oauth-authorization-server HTTP/1.1
Host: auth.pinehill.example {
"issuer": "https://auth.pinehill.example",
"authorization_endpoint": "https://auth.pinehill.example/authorize",
"token_endpoint": "https://auth.pinehill.example/token",
"client_id_metadata_document_supported": true,
"authorization_response_iss_parameter_supported": true,
"code_challenge_methods_supported": ["S256"]
} 5 the client_id is a URL (CIMD)
Registration without registering 🪪 happens at MCP client
Here is one of my favorite changes in this release. The client
never registers. Its client_id is an HTTPS URL pointing at
a JSON document the client hosts about itself: a Client ID Metadata
Document. The spec has two other registration mechanisms, and this
timeline shows neither: pre-registered credentials (for clients and
servers with an existing relationship) and Dynamic Client Registration,
the old POST-to-register dance, which is now deprecated and kept only
for backwards compatibility. CIMD is the recommended path when a client
and server have never met, which is the normal case in MCP. A CIMD
identity is also portable: if Pinehill ever switches auth servers,
nothing needs to be re-registered.
// hosted by the client at
// https://agentapp.example/oauth/client-metadata.json
{
"client_id": "https://agentapp.example/oauth/client-metadata.json",
"client_name": "AgentApp",
"redirect_uris": ["http://127.0.0.1:33418/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
} 6 authorization request (PKCE)
Your browser opens 🔌 MCP client → 🧑 You
The client builds the authorization URL and hands it to your
browser. Three protections travel with it: a PKCE challenge (so an
intercepted code is useless), a resource parameter naming
the exact MCP server this token is for (RFC 8707, so the token cannot
be replayed elsewhere), and the scopes. With no 401 challenge to guide
it yet, the client asks for the scopes_supported it read
in step 3, exactly as the spec's scope selection strategy prescribes.
https://auth.pinehill.example/authorize
?response_type=code
&client_id=https%3A%2F%2Fagentapp.example%2Foauth%2Fclient-metadata.json
&redirect_uri=http%3A%2F%2F127.0.0.1%3A33418%2Fcallback
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&resource=https%3A%2F%2Fcabins.example.com%2Fmcp
&scope=bookings%3Aread%20bookings%3Awrite
&state=af0ifjsldkj 7 AS fetches client-metadata.json
The auth server reads your metadata 🔑 Auth server ⇄ 🔌 MCP client
The authorization server sees a URL-shaped client_id
in the request and fetches the document behind it. It checks that the
client_id inside matches the URL exactly and that the
redirect URI from the authorization request is on the document's list.
This fetch is the entire "registration": publish a JSON file, and every
authorization server in the world can look you up.
GET /oauth/client-metadata.json HTTP/1.1
Host: agentapp.example {
"client_id": "https://agentapp.example/oauth/client-metadata.json",
"client_name": "AgentApp",
"client_uri": "https://agentapp.example",
"redirect_uris": ["http://127.0.0.1:33418/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"scope": "mcp"
}
// the AS validates:
// 1. document is valid JSON with required fields
// 2. client_id inside === the URL it fetched
// 3. redirect_uri from the request is in redirect_uris 8 user consents at AS
You approve, once 🧑 You → 🔑 Auth server
The consent page shows the client name from the metadata document fetched in step 7. You log in and approve. This is the only appearance your Pinehill credentials make in any of these timelines, and the harness never sees them; that is the entire point of the OAuth arrangement.
9 callback + iss check
The code comes back, with proof of origin 🔑 Auth server → 🔌 MCP client
The redirect lands on the client's local callback with the
authorization code and an iss parameter. Before redeeming
the code anywhere, the client compares iss against the
issuer it recorded in step 4, byte for byte. This closes a mix-up
attack where a malicious authorization server tricks a client into
sending a code to the wrong token endpoint.
http://127.0.0.1:33418/callback
?code=SplxlOBeZQQYbYS6WxSbIA
&state=af0ifjsldkj
&iss=https%3A%2F%2Fauth.pinehill.example
// client check, before anything else:
assert(params.iss === recordedIssuer) 10 token exchange + storage
Code becomes token, token gets a home 🔌 MCP client ⇄ 🔑 Auth server
The client trades the code (plus the PKCE verifier and the same
resource parameter) for an access token and a refresh
token. Where they land matters: the client's token store is keyed by
the authorization server's issuer identifier. If the Pinehill server
ever points at a different auth server, these credentials must never be
reused there. That binding is now a hard requirement. From here on, the
token rides silently on every request, and the refresh token keeps this
whole timeline from ever running again.
POST /token HTTP/1.1
Host: auth.pinehill.example
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
&client_id=https%3A%2F%2Fagentapp.example%2Foauth%2Fclient-metadata.json
&redirect_uri=http%3A%2F%2F127.0.0.1%3A33418%2Fcallback
&resource=https%3A%2F%2Fcabins.example.com%2Fmcp {
"access_token": "eyJhbGciOiJFUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "8xLOxBtZp8",
"scope": "bookings:read bookings:write"
}
// token store (client side, encrypted at rest)
{
"https://auth.pinehill.example": {
"resource": "https://cabins.example.com/mcp",
"access_token": "eyJhbGciOiJFUzI1NiIs...",
"refresh_token": "8xLOxBtZp8"
}
} Session start: assembling the context
The other thing that happens before any prompt is quieter. When the harness boots a session, it reads its MCP config (a file no spec governs), refreshes what it knows about each server’s tools, and builds the context the model will wake up in. This timeline is short, and its last step is my favorite row in the whole post: the exact moment MCP hands off to the model API, where tool schemas stop being protocol and become prompt.
1 harness reads its MCP config
A new session begins 📄 happens at Harness
You open the harness and it reads its config: which servers this session may talk to. Like the file itself, this moment belongs entirely to the harness. The protocol has nothing to say about how servers are chosen, and that is a feature: the host decides what exists, the protocol only decides how to talk to it.
// ~/.config/agentapp/mcp.json
{
"mcpServers": {
"pinehill": {
"url": "https://cabins.example.com/mcp"
}
}
} 2 tools/list
Tool discovery, stateless like everything else 🔌 MCP client → ⚙️ MCP server
The client asks the server what tools it has. Under every
previous revision this request would have been illegal: you had to
initialize first, negotiate capabilities, and possibly
carry a session id. All of that is gone. The request is entirely
self-contained, with the protocol version, client identity, and client
capabilities riding in _meta, and the token from first
contact in the Authorization header.
POST /mcp HTTP/1.1
Host: cabins.example.com
Authorization: Bearer eyJhbGciOiJFUzI1NiIs...
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/list
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "AgentApp",
"version": "3.2.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} },
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
} New: stateless, no initialize (SEP-2575)New: Mcp-Method header (SEP-2243) Spec: Streamable HTTP ↗
3 tools + ttlMs
A tool list with a shelf life ⚙️ MCP server → 🔌 MCP client
The response carries the tool schemas plus two new fields:
ttlMs, a freshness hint that lets the client cache instead
of re-asking, and cacheScope, which says whether shared
intermediaries may cache it too. The spec also asks servers to return
tools in a deterministic order, for an unglamorous but very real
reason: stable ordering keeps the model provider's prompt cache warm.
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"ttlMs": 3600000,
"cacheScope": "private",
"tools": [
{
"name": "book_cabin",
"description": "Book a cabin at any Pinehill property.",
"inputSchema": {
"type": "object",
"properties": {
"property": { "type": "string" },
"checkIn": { "type": "string", "format": "date" },
"checkOut": { "type": "string", "format": "date" },
"paymentMethod": { "type": "string" },
"calendar": { "type": "string", "enum": ["google_calendar", "outlook", "calconnect"] }
},
"required": ["property", "checkIn", "checkOut"]
},
"outputSchema": {
"type": "object",
"properties": {
"confirmation": { "type": "string" },
"property": { "type": "string" },
"checkIn": { "type": "string", "format": "date" },
"checkOut": { "type": "string", "format": "date" },
"total": { "type": "number" },
"currency": { "type": "string" },
"earlyCheckIn": { "type": "string" }
},
"required": ["confirmation", "property", "checkIn", "checkOut", "total", "currency"]
}
},
{
"name": "check_availability",
"description": "List available Pinehill cabins for a date range.",
"inputSchema": {
"type": "object",
"properties": {
"checkIn": { "type": "string", "format": "date" },
"checkOut": { "type": "string", "format": "date" }
},
"required": ["checkIn", "checkOut"]
}
}
]
}
} New: ttlMs + cacheScope (SEP-2549)New: resultType on every result (SEP-2322) Spec: Tools ↗
4 schemas cached until ttl lapses
Cached for the next hour 🗂️ happens at MCP client
The client files the schemas away with their expiry. This little
note is why none of the three storylines below re-fetch the tool list:
for the next hour, tool discovery is free. When the ttl lapses, or a
tools/list_changed notification arrives on a subscription,
the client asks again.
5 tool schemas enter the system prompt
The schemas join the model’s world 🧾 happens at Harness + Model
The handoff. The harness folds the tool definitions into what the model will see, and this is the exact boundary where MCP ends: the model never speaks the protocol, never sees a URL or a token. The first time it learns Pinehill exists is as a JSON schema sitting in its context. Everything the model will ever know about this server passes through this note.
{
"system": "You are AgentApp. You can call tools when useful.",
"tools": [
{
"name": "book_cabin",
"input_schema": {
"type": "object",
"properties": {
"property": { "type": "string" },
"checkIn": { "type": "string", "format": "date" },
"checkOut": { "type": "string", "format": "date" },
"paymentMethod": { "type": "string" },
"calendar": { "type": "string", "enum": ["google_calendar", "outlook", "calconnect"] }
},
"required": ["property", "checkIn", "checkOut"]
}
},
{
"name": "check_availability",
"input_schema": {
"type": "object",
"properties": {
"checkIn": { "type": "string", "format": "date" },
"checkOut": { "type": "string", "format": "date" }
},
"required": ["checkIn", "checkOut"]
}
}
]
} That ttlMs stamp on the tool list is what powers everything below: for the
next hour, the harness knows the tools without asking. With the server met,
the token stored, and the schemas cached, we can finally type something.
The two tools, and how the server answers
Before we type that something, meet the two tools Pinehill exposes. The whole
post lives in how they behave:
check_availability(checkIn, checkOut)is a read-only lookup. No auth, no side effects, an answer the server can produce on the spot.book_cabin(property, checkIn, checkOut, paymentMethod, calendar?)is a mutation. It needs things from you (confirm the charge, complete a payment, connect the calendar named by the optionalcalendarparameter), and it needs time from the server itself (a property manager has to approve a group booking before it is real).
For this post, I wanted to ensure we hit some interesting use cases for a tool call, and that largely comes down to two questions the server asks about the call.
The first question is what the result should say, and it has three answers.
If the server is waiting on you, it returns resultType: "input_required".
If the server itself needs time, with nothing more from you, it returns
resultType: "task". And if the answer is ready now, it returns
resultType: "complete".
The second question is how to deliver that answer, and it is orthogonal to
the first. A response travels either as a single application/json object or as
a text/event-stream meant to push progress notifications before the final
result. A server reaches for the stream only when it has progress worth showing.
Here is the whole decision:
input_required waits on you, and because it ends the
call and waits for a retry, an out-of-band step like a payment page or a
browser OAuth costs the server nothing while you finish. A
task waits on the server.
Trace a call through all the branches and you have already read the three storylines.
check_availability is a pure lookup, so it comes straight back as a complete
result (plain JSON, in storyline A). book_cabin stops to make you confirm and
pay (using input_required); it streams progress while it checks availability
(a good use case for text/event-stream); and its property-manager approval is
the server’s own wait (perfect for a task).
Pick a storyline
The timeline reads like a sequence diagram, and it stays one while you dig: click any row and it unfolds right where it happens, with the messages that actually crossed the wire. Solid arrows are requests; dashed arrows are the responses coming back, so a request and its reply share one row. Dashed boxes are the moments between messages, the parts no protocol specifies (a cache lookup, a consent dialog, a human at the other company), and their icons tell you which component they happen in. The lane header sticks to the top of the screen so you never lose track of who is who, and the green badges link out to the specific spec section or pull request behind each change.
Storyline A · No auth. A public tool and the bare skeleton of the stateless protocol. Solid arrows are requests, dashed arrows coming back are their responses, and dashed boxes are things that happen inside a component, pinned to the lane where they happen. Click any row and it unfolds in place.
1 prompt
The prompt arrives 🧑 You → 🖥️ Harness
One read-only question. Everything below this point is machinery reacting to it. The harness owns this moment: it decides what context to assemble, which servers are relevant, and what the model gets to see.
Which Pinehill cabins are free July 24 to 26? 2 tool schemas served from cache
Tool definitions come from cache 🗂️ happens at MCP client
Nothing crosses the wire here, which is exactly the point. The
MCP client fetched the tool list from the Pinehill server at session start, and the server stamped that tools/list response with a
freshness hint. One hour has not passed, so the client serves the
schemas from its own cache. ttlMs and
cacheScope are new fields that make list results cacheable,
and deterministic tool ordering keeps the model's prompt cache warm
too.
{
"resultType": "complete",
"ttlMs": 3600000,
"cacheScope": "private",
"tools": [
{
"name": "check_availability",
"description": "List available Pinehill cabins for a date range.",
"inputSchema": {
"type": "object",
"properties": {
"checkIn": { "type": "string", "format": "date" },
"checkOut": { "type": "string", "format": "date" }
},
"required": ["checkIn", "checkOut"]
}
},
{
"name": "book_cabin",
"description": "Book a cabin at any Pinehill property.",
"inputSchema": {
"type": "object",
"properties": {
"property": { "type": "string" },
"checkIn": { "type": "string", "format": "date" },
"checkOut": { "type": "string", "format": "date" },
"paymentMethod": { "type": "string" },
"calendar": { "type": "string", "enum": ["google_calendar", "outlook", "calconnect"] }
},
"required": ["property", "checkIn", "checkOut"]
},
"outputSchema": {
"type": "object",
"properties": {
"confirmation": { "type": "string" },
"property": { "type": "string" },
"checkIn": { "type": "string", "format": "date" },
"checkOut": { "type": "string", "format": "date" },
"total": { "type": "number" },
"currency": { "type": "string" },
"earlyCheckIn": { "type": "string" }
},
"required": ["confirmation", "property", "checkIn", "checkOut", "total", "currency"]
}
}
]
} New: ttlMs + cacheScope (SEP-2549)New: resultType on every result (SEP-2322) Spec: Tools ↗
3 prompt + tool schemas
The model sees the prompt and the schemas 🖥️ Harness → 🧠 Model
The harness sends the conversation plus the available tool
definitions to the LLM provider. Note what the model does not
see: no server URLs, no protocol details. The model only knows that a
tool named check_availability exists and what arguments it
takes. MCP stops at the harness boundary; the model API is a different
protocol entirely.
4 tool_use: check_availability
The model decides to call the tool 🧠 Model → 🖥️ Harness
The model answers with a structured tool call instead of text, with the arguments filled from the prompt. This is still not MCP: it is the model provider's tool calling format. The harness now has to translate this intent into a protocol request.
{
"type": "tool_use",
"id": "toolu_01",
"name": "check_availability",
"input": {
"checkIn": "2026-07-24",
"checkOut": "2026-07-26"
}
} 5 you approve the tool call
The consent gate ✅ happens at You + Harness
Before anything touches the wire, the harness asks you. The spec assigns this responsibility to the host: users must explicitly consent before any tool runs. Many harnesses let you pre-approve read-only tools like this one, but that policy decision still lives in the harness, never in the protocol.
6 tools/call (no auth)
The whole protocol in one request 🔌 MCP client → ⚙️ MCP server
This is MCP at its most minimal, and in the 2026-07-28 draft it
really is one request. No initialize handshake, no
session, and, because Pinehill leaves availability public, no
Authorization header either. Authorization is optional in
MCP. The request carries its own protocol version, identity, and
capabilities in _meta, and mirrors its method and tool name
into HTTP headers so intermediaries can route without parsing JSON.
POST /mcp HTTP/1.1
Host: cabins.example.com
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: check_availability
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "check_availability",
"arguments": {
"checkIn": "2026-07-24",
"checkOut": "2026-07-26"
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "AgentApp",
"version": "3.2.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} },
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
} New: stateless, no initialize (SEP-2575)New: Mcp-Method / Mcp-Name headers (SEP-2243) Spec: Streamable HTTP ↗
7 result: complete
A plain JSON answer ⚙️ MCP server → 🔌 MCP client
The server decides per request whether to answer with a single
JSON object or an SSE stream. A fast lookup needs no streaming, so this
one comes back as plain JSON. The result carries human-readable content
for the model and structuredContent for anything downstream
that would rather not parse prose.
HTTP/1.1 200 OK
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"content": [
{ "type": "text",
"text": "2 cabins available Jul 24-26: Lakeside ($412.50 total) and Ridge ($318.00 total)." }
],
"structuredContent": {
"available": [
{ "id": "pinehill-lakeside", "total": 412.5, "currency": "USD" },
{ "id": "pinehill-ridge", "total": 318.0, "currency": "USD" }
]
}
}
} 8 tool_result to model
The result rejoins the conversation 🖥️ Harness ⇄ 🧠 Model
The harness folds the tool result back into the conversation and
calls the model again. From the model's point of view, steps 5 through 7
collapse into a single tool_result block, and its reply is
ordinary assistant text.
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "toolu_01",
"content": "2 cabins available Jul 24-26: Lakeside ($412.50 total) and Ridge ($318.00 total)." }
]
} 9 final answer
The reply 🖥️ Harness → 🧑 You
The model writes the answer and the harness shows it to you. Two cabins, two round trips to the model, one stateless request on the wire. This is the skeleton every other storyline decorates.
Two cabins are free July 24 to 26: the Lakeside cabin
($412.50 total) and the Ridge cabin ($318.00 total). Storyline B · Server auth. The token from setup, a form plus a URL-mode payment, and a durable task. Solid arrows are requests, dashed arrows coming back are their responses, and dashed boxes are things that happen inside a component, pinned to the lane where they happen. Click any row and it unfolds in place.
1 prompt
The prompt arrives 🧑 You → 🖥️ Harness
One sentence from you, and this time it spends money. Everything below this point is machinery reacting to it. The harness owns this moment: it decides what context to assemble, which servers are relevant, and what the model gets to see.
Book the lakeside cabin at Pinehill for the team offsite,
July 24 to 26, on the company card. 2 tool schemas served from cache
Tool definitions come from cache 🗂️ happens at MCP client
The harness needs tool schemas to hand to the model, and nothing
crosses the wire to get them. The MCP client fetched the tool list from
the Pinehill server at session start, and the server stamped that
tools/list response with a freshness hint. One hour has not
passed, so the client serves the schemas from its own cache.
ttlMs and cacheScope are new fields that make
list results cacheable, and deterministic tool ordering keeps the
model's prompt cache warm too.
{
"resultType": "complete",
"ttlMs": 3600000,
"cacheScope": "private",
"tools": [
{
"name": "book_cabin",
"description": "Book a cabin at any Pinehill property.",
"inputSchema": {
"type": "object",
"properties": {
"property": { "type": "string" },
"checkIn": { "type": "string", "format": "date" },
"checkOut": { "type": "string", "format": "date" },
"paymentMethod": { "type": "string" }
},
"required": ["property", "checkIn", "checkOut"]
},
"outputSchema": {
"type": "object",
"properties": {
"confirmation": { "type": "string" },
"property": { "type": "string" },
"checkIn": { "type": "string", "format": "date" },
"checkOut": { "type": "string", "format": "date" },
"total": { "type": "number" },
"currency": { "type": "string" },
"earlyCheckIn": { "type": "string" }
},
"required": ["confirmation", "property", "checkIn", "checkOut", "total", "currency"]
}
},
{
"name": "check_availability",
"description": "List available Pinehill cabins for a date range.",
"inputSchema": {
"type": "object",
"properties": {
"checkIn": { "type": "string", "format": "date" },
"checkOut": { "type": "string", "format": "date" }
},
"required": ["checkIn", "checkOut"]
}
}
]
} New: ttlMs + cacheScope (SEP-2549)New: resultType on every result (SEP-2322) Spec: Tools ↗
3 prompt + tool schemas
The model sees the prompt and the schemas 🖥️ Harness → 🧠 Model
The harness sends the conversation plus the full tool
catalog to the LLM provider, both book_cabin and
check_availability, not just the one this task happens to need.
The harness has no way to know in advance which tool the model will pick, so
it forwards all of them every time; choosing is the model's job. Note what
the model does not see: no tokens, no server URLs, no protocol
details. MCP stops at the harness boundary; the model API is a different
protocol entirely.
{
"model": "claude-fable-5",
"messages": [
{
"role": "user",
"content": "Book the lakeside cabin at Pinehill for the team offsite, July 24 to 26, on the company card."
}
],
"tools": [
{
"name": "book_cabin",
"description": "Book a cabin at any Pinehill property.",
"input_schema": {
"type": "object",
"properties": {
"property": { "type": "string" },
"checkIn": { "type": "string", "format": "date" },
"checkOut": { "type": "string", "format": "date" },
"paymentMethod": { "type": "string" },
"calendar": { "type": "string", "enum": ["google_calendar", "outlook", "calconnect"] }
},
"required": ["property", "checkIn", "checkOut"]
}
},
{
"name": "check_availability",
"description": "List available Pinehill cabins for a date range.",
"input_schema": {
"type": "object",
"properties": {
"checkIn": { "type": "string", "format": "date" },
"checkOut": { "type": "string", "format": "date" }
},
"required": ["checkIn", "checkOut"]
}
}
]
} 4 tool_use: book_cabin
The model decides to call the tool 🧠 Model → 🖥️ Harness
The model answers with a structured tool call instead of text. It filled the arguments from the prompt. This is still not MCP: it is the model provider's tool calling format. The harness now has to translate this intent into a protocol request.
{
"type": "tool_use",
"id": "toolu_01",
"name": "book_cabin",
"input": {
"property": "pinehill-lakeside",
"checkIn": "2026-07-24",
"checkOut": "2026-07-26",
"paymentMethod": "company_card"
}
} 5 you approve the tool call
The consent gate ✅ happens at You + Harness
Before anything touches the wire, the harness asks you. The spec is explicit that this responsibility belongs to the host: users must explicitly consent before any tool runs, and tool descriptions are untrusted input unless they come from a trusted server. You click approve. The MCP client takes over from here.
6 tools/call (stateless)
The real tools/call, no handshake needed 🔌 MCP client → ⚙️ MCP server
This is the single most important message in the post, so it is
worth reading in full. Notice what is missing: there was no
initialize handshake, no session to join, no connection
warm-up. The request is entirely self-contained. The protocol version,
client identity, and client capabilities ride in _meta on
this very request. The headers mirror body fields so load balancers can
route on Mcp-Method and Mcp-Name without
parsing JSON, and the server must reject the request if they disagree
with the body. The trace context in _meta lets Pinehill's
observability stack join this request to the harness's trace. The
token earned at first contact rides in the Authorization header; had
it expired, the client would refresh silently, and a 401 would simply
rerun the first-contact dance.
POST /mcp HTTP/1.1
Host: cabins.example.com
Authorization: Bearer eyJhbGciOiJFUzI1NiIs...
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: book_cabin
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "book_cabin",
"arguments": {
"property": "pinehill-lakeside",
"checkIn": "2026-07-24",
"checkOut": "2026-07-26",
"paymentMethod": "company_card"
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "AgentApp",
"version": "3.2.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} },
"extensions": { "io.modelcontextprotocol/tasks": {} }
},
"progressToken": "bk-1",
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
}
} New: stateless, no initialize (SEP-2575)New: Mcp-Method / Mcp-Name headers (SEP-2243)New: trace context in _meta (SEP-414) Spec: Streamable HTTP ↗
7 SSE: progress
The response is a stream ⚙️ MCP server → 🔌 MCP client
The server chooses to answer with a Server-Sent Events stream
scoped to this one request. Progress notifications flow here because the
client sent a progressToken. If this stream breaks, there is
no resuming it: the client re-issues the request with a new id.
Redelivery via Last-Event-ID left the protocol in this
revision.
HTTP/1.1 200 OK
Content-Type: text/event-stream
X-Accel-Buffering: no
data: {"jsonrpc":"2.0","method":"notifications/progress",
"params":{"progressToken":"bk-1","progress":1,
"message":"Checking availability for Jul 24-26"}} 8 result: input_required
The server needs a human ⚙️ MCP server → 🔌 MCP client
The cabin is available, but the server needs two things before it
books, and it bundles both into one InputRequiredResult: a
form elicitation for the reservation options it can safely collect
in-band (early check-in), and a URL elicitation for the payment.
Payment goes through URL mode on purpose. Card details are exactly the
confidential data the spec says must never pass through the MCP client or
the model, so the server sends you to its own secure page rather than
asking for them in a form. In every previous MCP version the server would
have sent its own elicitation/create request back up the
stream; it cannot do that anymore. Instead it ends the request with
resultType: "input_required" plus a requestState
blob, the server's encrypted memory of this half-finished booking, handed
to the client for safekeeping. The server can now forget the request
entirely, and any replica can pick up the retry.
data: {
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "input_required",
"inputRequests": {
"reservation_options": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Lakeside cabin is available Jul 24-26. Any extras for the reservation?",
"requestedSchema": {
"type": "object",
"properties": {
"earlyCheckIn": {
"type": "boolean",
"title": "Request early check-in"
}
}
}
}
},
"payment": {
"method": "elicitation/create",
"params": {
"mode": "url",
"url": "https://cabins.example.com/pay?e=3d9f1c",
"message": "Confirm and pay for the booking ($412.50) on Pinehill's secure page."
}
}
},
"requestState": "v1.gcm.9yKX0jPmYQ4kFhk3...hT2w"
}
} New: MRTR replaces server-initiated requests (SEP-2322) Spec: Multi Round-Trip Requests ↗
9 you pick options and pay
The form and the payment link reach you 📝 happens at You + Harness
The MCP client hands both elicitations to the harness, which renders them however it likes: a dialog, a terminal prompt, a mobile notification. You tick the early-check-in box and open the payment link, which takes you to Pinehill's own secure page to complete the charge. The card number is entered there, never in the harness, so it never reaches the MCP client or the model. The model is not in this loop at all, and the form's requested schema is restricted to flat primitives precisely so any harness can render it.
{
"reservation_options": {
"action": "accept",
"content": { "earlyCheckIn": true }
},
"payment": {
"action": "accept"
}
} 10 tools/call retry + requestState
The same call, retried with answers 🔌 MCP client → ⚙️ MCP server
The client retries the original tools/call with a
new JSON-RPC id, the original arguments, your answers keyed exactly as
the server asked, and the requestState blob echoed back
untouched. The client must not peek inside it. The server that receives
this retry might be a different process on a different machine than the
one that answered in step 8. It does not matter. Everything it needs is
in the request.
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "book_cabin",
"arguments": {
"property": "pinehill-lakeside",
"checkIn": "2026-07-24",
"checkOut": "2026-07-26",
"paymentMethod": "company_card"
},
"inputResponses": {
"reservation_options": {
"action": "accept",
"content": { "earlyCheckIn": true }
},
"payment": {
"action": "accept"
}
},
"requestState": "v1.gcm.9yKX0jPmYQ4kFhk3...hT2w",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "AgentApp",
"version": "3.2.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} },
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
} 11 result: task handle
A task instead of an answer ⚙️ MCP server → 🔌 MCP client
Pinehill requires the property manager to confirm bookings, and
that can take minutes. Holding an HTTP connection open that long is
asking for a timeout. Because the client declared the tasks extension in
its capabilities back in step 6, the server may answer with a durable
task handle instead of a result. Tasks moved out of the core protocol
into the io.modelcontextprotocol/tasks extension in this
release, redesigned around polling.
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "task",
"taskId": "task-7f3a2b",
"status": "working",
"statusMessage": "Booking sent to the property for confirmation",
"createdAt": "2026-07-09T15:04:02Z",
"lastUpdatedAt": "2026-07-09T15:04:02Z",
"ttlMs": 86400000,
"pollIntervalMs": 5000
}
} 12 tasks/get → working
Polling, politely 🔌 MCP client ⇄ ⚙️ MCP server
The client polls tasks/get at the suggested
interval. Each poll is an ordinary stateless request carrying the same
_meta identity block as everything else. The task id is a
durable handle: if the harness crashes and restarts, it can resume
polling with the same id. The blocking tasks/result method
from the previous revision is gone.
{
"jsonrpc": "2.0",
"id": 3,
"method": "tasks/get",
"params": {
"taskId": "task-7f3a2b",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "AgentApp",
"version": "3.2.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} },
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
} {
"jsonrpc": "2.0",
"id": 3,
"result": {
"resultType": "complete",
"taskId": "task-7f3a2b",
"status": "working",
"statusMessage": "Waiting for property manager",
"createdAt": "2026-07-09T15:04:02Z",
"lastUpdatedAt": "2026-07-09T15:04:07Z",
"ttlMs": 86400000,
"pollIntervalMs": 5000
}
} 13 a human at Pinehill approves
A human at Pinehill approves ⏳ happens at MCP server
Nothing about this step is in any protocol, and that is why it is on the timeline. The property manager gets a notification, finishes their coffee, and clicks approve. The task machinery exists so that this entirely human pause does not hold an HTTP connection hostage. Between polls, the only trace of your booking anywhere in Pinehill's infrastructure is the durable task record.
14 tasks/get (final poll)
The booking lands 🔌 MCP client ⇄ ⚙️ MCP server
A few polls later the task reaches a terminal state. The
result field contains exactly what the original
tools/call would have returned synchronously: human-readable
content plus structuredContent that downstream code can
consume without parsing prose.
{
"jsonrpc": "2.0",
"id": 4,
"method": "tasks/get",
"params": {
"taskId": "task-7f3a2b",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "AgentApp",
"version": "3.2.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} },
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
} {
"jsonrpc": "2.0",
"id": 4,
"result": {
"resultType": "complete",
"taskId": "task-7f3a2b",
"status": "completed",
"statusMessage": "Booking confirmed by the property",
"createdAt": "2026-07-09T15:04:02Z",
"lastUpdatedAt": "2026-07-09T15:06:31Z",
"ttlMs": 86400000,
"result": {
"resultType": "complete",
"content": [
{ "type": "text",
"text": "Booked: Lakeside cabin, Jul 24-26. Confirmation PH-58291. Early check-in requested." }
],
"structuredContent": {
"confirmation": "PH-58291",
"property": "pinehill-lakeside",
"checkIn": "2026-07-24",
"checkOut": "2026-07-26",
"total": 412.5,
"currency": "USD",
"earlyCheckIn": "requested"
}
}
}
} 15 tool_result to model
The result rejoins the conversation 🖥️ Harness ⇄ 🧠 Model
The harness folds the tool result back into the conversation and
calls the model again. From the model's point of view, the whole
adventure between steps 4 and 14 collapses into a single
tool_result block. OAuth, elicitation, task polling: none of
it exists in the model's world.
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01",
"content": "Booked: Lakeside cabin, Jul 24-26. Confirmation PH-58291. Early check-in requested."
}
]
} 16 final answer
The reply 🖥️ Harness → 🧑 You
The model writes the confirmation message and the harness shows it to you. Fifteen events, three protocols (a model API, MCP, and SSE), three human checkpoints (you twice, a property manager once), and one durable task, all folded into what reads like a single chat message.
Done! I booked the Lakeside cabin at Pinehill for July 24 to 26
on the company card. Total was $412.50, confirmation code
PH-58291, and I requested early check-in for the team. Storyline C · Third-party auth. A long-running task that asks for payment, then calendar access, mid-flight. Solid arrows are requests, dashed arrows coming back are their responses, and dashed boxes are things that happen inside a component, pinned to the lane where they happen. Click any row and it unfolds in place.
1 prompt
The prompt arrives 🧑 You → 🖥️ Harness
The booking part of this prompt is storyline B territory. The new words are "put it on the team calendar." The prompt never says which calendar; that comes from the user's saved preferences in the next step, and it turns out to be CalConnect, a service Pinehill integrates with but does not control. Someone will have to grant Pinehill access to it, and that someone is you.
Book the lakeside cabin for the offsite and put it
on the team calendar. 2 prompt + tool schemas + prefs
Cache hit, and a preference the model can act on 🖥️ Harness → 🧠 Model
Same opening as the other storylines, compressed: the tool schemas
come out of the client's cache (still fresh, thanks to ttlMs),
and the harness hands the model the full catalog, both Pinehill tools, as it
always does (check_availability is shown here too, though this
task will not use it). Two things the model sees together matter today.
Pinehill's book_cabin takes a calendar parameter
naming which calendar service to use, an enum of the providers Pinehill can
integrate with; and the user's saved preferences, carried in the system
prompt, say the team schedules on CalConnect. That pairing is what lets the
next step fill the parameter with calconnect rather than Google
Calendar or Outlook. The model is not guessing; it is following a stated
preference.
{
"system": "You are AgentApp. The user's team uses CalConnect for shared calendars; prefer it for any calendar action. The company card is on file for payments.",
"tools": [
{
"name": "book_cabin",
"input_schema": {
"type": "object",
"properties": {
"property": { "type": "string" },
"checkIn": { "type": "string", "format": "date" },
"checkOut": { "type": "string", "format": "date" },
"paymentMethod": { "type": "string" },
"calendar": { "type": "string", "enum": ["google_calendar", "outlook", "calconnect"] }
},
"required": ["property", "checkIn", "checkOut"]
}
},
{
"name": "check_availability",
"input_schema": {
"type": "object",
"properties": {
"checkIn": { "type": "string", "format": "date" },
"checkOut": { "type": "string", "format": "date" }
},
"required": ["checkIn", "checkOut"]
}
}
]
} 3 tool_use: book_cabin
The model decides to call the tool 🧠 Model → 🖥️ Harness
One tool call covers both halves of the request. The model sets
calendar to calconnect, the value it read from the
user's preferences, not a guess and not the only option the tool offered.
Whether Pinehill can actually reach that CalConnect account is not the
model's problem: it emitted intent, and the protocol will sort out the
permission.
{
"type": "tool_use",
"id": "toolu_01",
"name": "book_cabin",
"input": {
"property": "pinehill-lakeside",
"checkIn": "2026-07-24",
"checkOut": "2026-07-26",
"paymentMethod": "company_card",
"calendar": "calconnect"
}
} 4 you approve the tool call
The consent gate ✅ happens at You + Harness
You approve the tool call. Keep count of how many times you appear in this storyline: this is the first of four, which is exactly the point of running agents against systems that spend money and touch shared calendars.
5 tools/call (token reused)
tools/call with a warm token 🔌 MCP client → ⚙️ MCP server
You authorized this client back at first contact (the first setup
timeline), so there is no 401 detour. The client looks up its token store
by the auth server's issuer, refreshes if needed, and attaches the
bearer token. The request is stateless as always, and the capability block
earns its keep today: the client declares form and
url elicitation and the tasks extension, because this
one call will use all three before it is done. Servers must never send an
elicitation mode, or a task, the client did not declare.
POST /mcp HTTP/1.1
Host: cabins.example.com
Authorization: Bearer eyJhbGciOiJFUzI1NiIs...
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: book_cabin
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "book_cabin",
"arguments": {
"property": "pinehill-lakeside",
"checkIn": "2026-07-24",
"checkOut": "2026-07-26",
"paymentMethod": "company_card",
"calendar": "calconnect"
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "AgentApp",
"version": "3.2.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} },
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
} 6 input_required: form (prefs)
First it needs your preferences ⚙️ MCP server → 🔌 MCP client
The cabin is available, but before it sets anything up the server asks
for the reservation preferences the model did not specify. These are
ordinary, non-sensitive choices, so they go as a form elicitation,
delivered the modern way: an InputRequiredResult on the
tools/call itself, with a requestState blob the
server hands the client for safekeeping. No payment and no calendar yet;
those are confidential, and they come later, from inside the task.
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "input_required",
"inputRequests": {
"booking_prefs": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Lakeside cabin is available Jul 24-26. A few preferences for the team:",
"requestedSchema": {
"type": "object",
"properties": {
"earlyCheckIn": {
"type": "boolean",
"title": "Request early check-in"
},
"groundFloor": {
"type": "boolean",
"title": "Prefer a ground-floor unit"
}
}
}
}
}
},
"requestState": "v1.gcm.Qm9va2luZ1N0YXRl...c3Rz"
}
} New: MRTR replaces server-initiated requests (SEP-2322) Spec: Multi Round-Trip Requests ↗
7 tools/call retry + prefs
You pick, the client retries 🔌 MCP client → ⚙️ MCP server
The harness renders the form, you tick early check-in, and the client
retries the original call with your answer keyed under
booking_prefs and the requestState echoed back
untouched. This is the pre-task exchange, resolved in-band on a
tools/call retry: everything the server needs to start
the work is now in hand.
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "book_cabin",
"arguments": {
"property": "pinehill-lakeside",
"checkIn": "2026-07-24",
"checkOut": "2026-07-26",
"paymentMethod": "company_card",
"calendar": "calconnect"
},
"inputResponses": {
"booking_prefs": {
"action": "accept",
"content": { "earlyCheckIn": true, "groundFloor": false }
}
},
"requestState": "v1.gcm.Qm9va2luZ1N0YXRl...c3Rz",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "AgentApp",
"version": "3.2.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} },
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
} 8 result: task handle
A task, not an answer ⚙️ MCP server → 🔌 MCP client
Booking the offsite is not instant: a property manager has to approve
a group reservation, and payment and calendar access still have to be
collected. Rather than hold the connection open, the server answers with a
CreateTaskResult (resultType: "task"). The client
declared the tasks extension back in step 5, so this is allowed. From here
the conversation is polling, and the remaining asks arrive from inside
the task.
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "task",
"taskId": "task-9c2e",
"status": "working",
"statusMessage": "Sent to the property for approval",
"createdAt": "2026-07-09T15:20:00Z",
"lastUpdatedAt": "2026-07-09T15:20:00Z",
"ttlMs": 86400000,
"pollIntervalMs": 5000
}
} 9 a human at Pinehill approves
A human at Pinehill approves ⏳ happens at MCP server
The task exists so this pause does not hold a connection hostage. A
property manager reviews the group booking and approves it. This is a
different human from you: the approval happens entirely on
Pinehill's side, invisible to the harness. Once it clears, the task finally
has something to ask you for, and it moves to input_required.
10 tasks/get → input_required: payment
The poll comes back asking for payment 🔌 MCP client ⇄ ⚙️ MCP server
The client has been polling tasks/get at the suggested
interval. This time the task is no longer just working: it is
input_required and carries an inputRequests map,
the same shape you saw on the tools/call earlier, now surfaced
through the task. The request inside is the payment, in URL mode, because
card details must never pass through the client or the model.
{
"jsonrpc": "2.0",
"id": 3,
"method": "tasks/get",
"params": {
"taskId": "task-9c2e",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "AgentApp",
"version": "3.2.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} },
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
} {
"jsonrpc": "2.0",
"id": 3,
"result": {
"resultType": "complete",
"taskId": "task-9c2e",
"status": "input_required",
"createdAt": "2026-07-09T15:20:00Z",
"lastUpdatedAt": "2026-07-09T15:22:10Z",
"ttlMs": 86400000,
"inputRequests": {
"payment": {
"method": "elicitation/create",
"params": {
"mode": "url",
"url": "https://cabins.example.com/pay?e=8f31a0",
"message": "Approved. Pay for the booking ($412.50) on Pinehill's secure page."
}
}
}
}
} New: task input_required + inputRequests (SEP-2663) Extension: Tasks ↗
11 tasks/update: payment
You pay, the client updates the task 🔌 MCP client ⇄ ⚙️ MCP server
The harness shows you the full URL, you open Pinehill's secure page in
a real browser, and you pay. The card number is entered there, never in the
harness, so it never reaches the MCP client or the model. Mid-task input
does not go back on a tools/call retry the way the pre-task
form did; it goes on tasks/update, keyed to the outstanding
request. The server acknowledges with an empty result and the task returns
to working.
{
"jsonrpc": "2.0",
"id": 4,
"method": "tasks/update",
"params": {
"taskId": "task-9c2e",
"inputResponses": {
"payment": {
"action": "accept"
}
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "AgentApp",
"version": "3.2.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} },
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
} {
"jsonrpc": "2.0",
"id": 4,
"result": {
"resultType": "complete"
}
} 12 tasks/get → input_required: calendar
The next poll asks for the calendar 🔌 MCP client ⇄ ⚙️ MCP server
Payment cleared, the task went back to work, and the next poll finds
it input_required again, this time for calendar access. Same
mechanism, second round: a URL elicitation inside the task's
inputRequests. The URL points at Pinehill's own connect page,
not CalConnect directly, which is the phishing defense you are about to
see.
{
"jsonrpc": "2.0",
"id": 5,
"method": "tasks/get",
"params": {
"taskId": "task-9c2e",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "AgentApp",
"version": "3.2.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} },
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
} {
"jsonrpc": "2.0",
"id": 5,
"result": {
"resultType": "complete",
"taskId": "task-9c2e",
"status": "input_required",
"createdAt": "2026-07-09T15:20:00Z",
"lastUpdatedAt": "2026-07-09T15:23:40Z",
"ttlMs": 86400000,
"inputRequests": {
"connect_calendar": {
"method": "elicitation/create",
"params": {
"mode": "url",
"url": "https://cabins.example.com/connect?flow=calconnect&e=8f31a0",
"message": "Connect your CalConnect calendar so Pinehill can add the offsite."
}
}
}
}
} 13 browser: third-party OAuth
The OAuth flow you never see in the chat 🧑 You → 📅 CalConnect
Your browser lands on Pinehill's connect page first, and that ordering is deliberate. The page checks that the person opening the link is the same Pinehill user who triggered the elicitation (a session cookie matched against the identity from the MCP authorization). Without that check, an attacker could send their elicitation URL to a victim and harvest the resulting grant. Only then does Pinehill redirect you to CalConnect's authorization endpoint, where you approve calendar access. In this leg Pinehill is the OAuth client and CalConnect is the authorization server; the MCP protocol is nowhere in sight.
14 code exchange (server-side)
The token Pinehill keeps to itself ⚙️ MCP server ⇄ 📅 CalConnect
CalConnect redirects back to Pinehill's own
redirect_uri, and Pinehill exchanges the code for tokens
server-side, binding them to your Pinehill identity. Two hard
rules from the spec meet here: the third-party credentials must never
transit the MCP client, and the server must never reuse the MCP client's
token against CalConnect (that would be token passthrough, which is
forbidden). Two different tokens, two different trust relationships,
stored on two different machines.
POST /token HTTP/1.1
Host: auth.calconnect.example
grant_type=authorization_code
&code=cAl9xKt...
&redirect_uri=https%3A%2F%2Fcabins.example.com%2Foauth%2Fcallback
&client_id=pinehill-server {
"access_token": "cc_at_71b...",
"refresh_token": "rt_5v8...",
"token_type": "Bearer"
}
// stored by Pinehill, bound to YOUR Pinehill identity (sub claim)
{ "user": "sub:9214", "calconnect_refresh_token": "rt_5v8..." } 15 tasks/update: calendar
The update that unblocks the task 🔌 MCP client ⇄ ⚙️ MCP server
Back in the harness, you confirm you finished in the browser, and the
client sends a second tasks/update: an ElicitResult
for the URL request that says only action: "accept" (URL results
carry no content). The server checks its own records, finds the grant it just
stored, acknowledges, and the task goes back to working with
everything it needs. There is no elicitationId and no
notifications/elicitation/complete anymore; the
tasks/update is the completion signal.
{
"jsonrpc": "2.0",
"id": 6,
"method": "tasks/update",
"params": {
"taskId": "task-9c2e",
"inputResponses": {
"connect_calendar": {
"action": "accept"
}
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "AgentApp",
"version": "3.2.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} },
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
} {
"jsonrpc": "2.0",
"id": 6,
"result": {
"resultType": "complete"
}
} Changed: retry replaces elicitation/complete (MRTR, SEP-2322) Spec: Elicitation (URL mode) ↗
16 tasks/get → completed
Booked, and on the calendar 🔌 MCP client ⇄ ⚙️ MCP server
A few polls later the task reaches a terminal state. The nested
result holds exactly what the original tools/call
would have returned synchronously: the booking confirmation plus
structuredContent a downstream tool can use, including the
CalConnect event id. One task, two mid-flight elicitations, both halves of
the prompt closed.
{
"jsonrpc": "2.0",
"id": 7,
"method": "tasks/get",
"params": {
"taskId": "task-9c2e",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "AgentApp",
"version": "3.2.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} },
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
} {
"jsonrpc": "2.0",
"id": 7,
"result": {
"resultType": "complete",
"taskId": "task-9c2e",
"status": "completed",
"createdAt": "2026-07-09T15:20:00Z",
"lastUpdatedAt": "2026-07-09T15:24:05Z",
"ttlMs": 86400000,
"result": {
"resultType": "complete",
"content": [
{ "type": "text",
"text": "Booked: Lakeside cabin, Jul 24-26 (PH-58291). Added to the team calendar." }
],
"structuredContent": {
"confirmation": "PH-58291",
"total": 412.5,
"currency": "USD",
"calendarEventId": "calconnect:evt_88d1"
}
}
}
} 17 tool_result to model
The result rejoins the conversation 🖥️ Harness ⇄ 🧠 Model
The harness folds the result back into the conversation. From the
model's point of view, the whole adventure, the task, the approval, the two
mid-flight elicitations, the second OAuth relationship between Pinehill and
CalConnect, collapses into a single tool_result. Neither the
model nor the MCP client ever held a CalConnect credential. That ignorance is
the security feature.
18 final answer
The reply 🖥️ Harness → 🧑 You
Eighteen events on this timeline: a form up front, a durable task, a property manager's approval, two URL elicitations delivered mid-task, and a third-party OAuth dance, all folded into two sentences. The interesting count is still the tokens: your harness holds one for Pinehill, Pinehill holds one for CalConnect, and no party ever holds both.
Booked the Lakeside cabin for July 24 to 26 ($412.50,
confirmation PH-58291) and added the offsite to the team
calendar. What the three storylines teach
Storyline A is the whole protocol in two messages. A stateless request
carrying its own identity, and a response. No handshake, no session, no
Authorization header, and the server answered with plain JSON because
nothing about a fast lookup needs a stream. When people say the draft made
MCP radically simpler to serve, this is what they mean: that request is
servable by a plain HTTP function with no memory.
Storyline B holds the two patterns that define the release. The token
from first contact rides along silently; what makes this storyline
interesting is what happens when the server needs a human. The first pattern
is the Multi Round-Trip Request, where the server asks for your confirmation
by ending the call with resultType: "input_required" and an encrypted
requestState it hands to the client for safekeeping. The second is the
tasks extension, where a slow booking becomes a durable handle the client
polls while a property manager finishes their coffee. Both exist for the
same reason: no server instance should have to remember your half-finished
work, and no connection should have to stay open for a human’s attention
span.
Storyline C is permission by proxy, and it’s the one I’d study if you
build servers. Pinehill needs access to your CalConnect calendar. It must
not ask for your CalConnect credentials through a form (the spec forbids
collecting secrets in form mode), and it must not borrow the MCP client’s
token (token passthrough is forbidden too). The answer is URL mode
elicitation: the server hands the client a URL, the client shows it to you
in full and opens it in a real browser only after you consent, and a
complete OAuth flow runs between your browser, Pinehill, and CalConnect
without the MCP client seeing anything but that URL. The resulting tokens
live server-side, bound to your identity, after Pinehill verifies that the
person in the browser is the person who triggered the elicitation. That last
check defeats a phishing attack where someone tricks you into completing an
authorization that lands in their session. URL mode itself shipped in
2025-11-25; what the draft changed is the plumbing around it. There is no
elicitationId and no completion notification anymore. The client just
retries the call, and the server checks whether the browser flow finished.
There were a lot of messages going back and forth in the diagrams above. However, from the point of view of the conversation, they’re all a single tool call. A well-designed harness should keep the context clean and free from the implementation details related to the lifecycle of the tool call. And yes, I did purposefully keep the happy path only in the storylines above. Agentic error handling is slightly more nuanced when it comes to elevating errors to the LLM, which often has enough context to automatically retry and heal the flow. In our example, the form elicitation may have been managed through a couple of extra messages between you and the model.
Where did all the state go?
“Stateless protocol” is an easy phrase to say and a hard one to mean. The storylines clearly have state: tokens on both sides, a half-confirmed reservation, a task. Statelessness never deletes state, it relocates it so the infrastructure can be scaled more conveniently when serving a large number of requests. This figure is the same material indexed by where things live instead of when things happen. The middle column is the draft’s cleverest trick, and the right column explains why servers just got easier to scale.
Conversation history + −
Never leaves the host. Servers only ever see the arguments of the calls addressed to them. This is a stated design principle of the protocol: servers should not be able to read the whole conversation, or see into other servers.
Consent decisions + −
Which tools you approved, and under what conditions. The spec assigns this responsibility to the host, and it never crosses the wire.
Token store, keyed by issuer + −
Access and refresh tokens live here, confidential in transit and
at rest. The draft makes the binding explicit: credentials are keyed
by the authorization server's issuer identifier, and
reusing them against a different issuer is forbidden.
tools/list cache + −
List results now carry ttlMs and
cacheScope, so the client can serve schemas from cache
instead of re-fetching on every turn. The fetch happens once at
session start; none of the three storylines touch the network for
schemas because of this.
PKCE verifier + expected issuer + −
Short-lived, one per authorization attempt. The recorded issuer
is what makes the iss check in the first-contact
timeline meaningful: the comparison only protects you if the
expected value came from validated metadata.
Task IDs + −
Durable handles for long-running work. They survive crashes and reconnects; the harness can resume polling with the same id after a restart.
requestState blob + −
The server's memory of a half-finished request, encrypted by the server and for the server, carried by the client, echoed back verbatim on retry. Clients must not inspect or modify it. Servers must treat it as attacker-controlled input and protect its integrity with an HMAC or AEAD. This is the trick that lets the server forget everything between the interim result and the retry in storyline B.
_meta identity block + −
Protocol version, client info, and client capabilities travel on
every single request. This block is what replaced the
initialize handshake: the negotiation that used to
happen once per session now happens implicitly on each message.
Mirrored headers + −
Mcp-Method and Mcp-Name repeat body
fields at the HTTP layer so load balancers and gateways can route
and rate-limit without parsing JSON. Servers must reject requests
where header and body disagree.
Trace context + −
traceparent, tracestate, and
baggage ride in _meta following the
OpenTelemetry conventions, so a single distributed trace can span
the harness, the MCP server, and whatever sits behind it.
Per-connection state + −
None. This cell is the headline of the release. There are no
sessions to store, no handshake to remember, no
Mcp-Session-Id to route on. Any replica behind the load
balancer can answer any request, including the retry of a request a
different replica saw first.
Task store + −
A deliberate exception to statelessness. Tasks are durable
records addressed by taskId, kept until their
ttlMs lapses, and designed to be shared storage so any
replica can answer a poll.
Third-party tokens + −
Storyline C's CalConnect grant lives here, bound to your Pinehill identity, obtained through your browser and never through the MCP client. The spec is absolute about this boundary: downstream credentials must not transit the client, and the server must never reuse the client's own token downstream (that would be token passthrough, which is forbidden).
Your conversation + −
Not here. The server saw a handful of booking arguments, your elicitation answers, and an encrypted blob it minted itself. Everything else in the story stayed on your machine.
marks state whose location or rules changed in the 2026‑07‑28 draft. Click any item to see what it does. Notice the server column.
The same moments, one release apart
Everything above described the draft. If you’re running an MCP client or
server today, you’re living in 2025-11-25, and the distance between the two
is best felt by flipping between them. Seven moments, two protocols.
| Moment | 2025‑11‑25 current | 2026‑07‑28 draft |
|---|---|---|
| Starting to talk to a server | Send initialize, negotiate a version and capabilities,
send notifications/initialized, and hold on to the
Mcp-Session-Id the server may assign. Every later request
must carry that session header or get rejected. | Just send the request. Version, identity, and capabilities travel in
_meta on every message. Sessions no longer exist. Clients
that want to check versions up front may call
server/discover, which every server must implement. |
| The server needs something from the client | The server sends its own JSON-RPC request
(elicitation/create, sampling/createMessage,
roots/list) back over an SSE stream, and the client answers
on a separate POST. This requires a live stream to the right server
instance at the right moment. | The server ends the request with
resultType: "input_required", listing what it needs as data
and packing its own memory into requestState. The client
retries the original request with inputResponses. Any
replica can handle the retry. |
| Getting a client_id | Dynamic Client Registration: POST to the auth server's
/register endpoint and receive fresh credentials. Every new
authorization server means another registration, and another set of
credentials to manage. | The client_id is an HTTPS URL pointing at a metadata
document the client hosts about itself. The auth server fetches and
validates it on demand. Nothing to register, and the identity is
portable across authorization servers. DCR is deprecated. |
| Server-pushed updates | Open a standalone HTTP GET stream to the server. Unsolicited
server messages can arrive on it at any time, and resource update
subscriptions are managed with resources/subscribe and
resources/unsubscribe. | The GET endpoint is gone. Clients that want change notifications
POST a subscriptions/listen request naming exactly the
notification types they opt in to, and the server tags everything it
sends with a subscription id. |
| A dropped SSE stream | Reconnect with Last-Event-ID and the server replays
the events you missed. Servers have to buffer messages per stream to
support redelivery. | The in-flight request is lost, and that is fine by design: the client re-issues it as a new request with a new id. One more reason every request has to be self-contained. |
| Learning a URL elicitation finished | URL mode requests carry an elicitationId, and when the
out-of-band browser flow completes, the server pushes a
notifications/elicitation/complete message referencing that
id over an open stream. | Both the notification and the id are gone. The client retries the
original request with the echoed requestState, and the
server checks its own records to see whether the browser flow finished.
A too-early retry just gets another input_required. |
| Long-running work | Experimental tasks live in the core spec. Clients opt in per
request, retrieve results through a blocking tasks/result
call, and can enumerate tasks with tasks/list. | Tasks are an official extension
(io.modelcontextprotocol/tasks). Servers may return task
handles unsolicited to capable clients, results come from polling
tasks/get, and mid-flight input flows through
tasks/update. |
The changelog also retires things these stories never needed: ping is
gone, log verbosity moved into a per-request _meta field, and the Roots,
Sampling, and Logging features will be deprecated with a twelve-month window.
The full changelog
is worth your time.
What this release is really about
Deconstructing the calls makes the theme visible: this release moves MCP onto the patterns that already made the web scale. Stateless requests that are easy to serve from any replica. Cache lifetimes on cacheable responses. Routing metadata in headers. Identity you host at a URL. Trace context that crosses service boundaries. Every green arrow in the diagrams is MCP trading a bespoke mechanism for a “boring” (proven to work) one.
There’s a cost, and it lands on client developers: the client now holds the tokens, the caches, the task handles, and the retry logic that servers used to carry in their sessions. Servers got radically simpler to operate. Clients got a decent amount of homework. Given that there are maybe a few dozen serious harnesses and many thousands of servers, I think the protocol picked the right side of that trade. Having said that, I’m not optimistic about a frictionless transition.
If you maintain a server: your migration is mostly deletion. Drop the
handshake, drop the session store, return resultType, stamp your list
results with ttlMs, and rethink any flow where you called elicitation/create
as a request. If you maintain a client: start with MRTR, because when servers
adopt the draft, elicitation as you know it stops arriving.
Also, this is my first technical deep dive about MCP, so if you liked it and want to talk about it or request another deconstruction (or if you hated it and want to point out my mistakes), please reach out to me!