An MCP Tool Call, Deconstructed

Published:

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.

The AAIF ambassador map: 140 ambassadors across 41 countries, with the Brazil pin open and labeled 'Brazil (1), Mateo Torres'.

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_availability and book_cabin at https://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.

new or changed in 2026‑07‑28 response outside the protocol
Act I · Sizing up the server One URL in a config file becomes a protocol version, a capability list, and the address of the authorization server that guards it.
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.

The entire configuration
// ~/.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.

→ MCP client to MCP server The request
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": {} }
      }
    }
  }
}
← MCP server to MCP client The response
{
  "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.

→ MCP client to MCP server The request
GET /.well-known/oauth-protected-resource HTTP/1.1
Host: cabins.example.com
← MCP server to MCP client Protected resource metadata
{
  "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.

→ MCP client to Auth server The request
GET /.well-known/oauth-authorization-server HTTP/1.1
Host: auth.pinehill.example
← Auth server to MCP client Authorization server metadata
{
  "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"]
}
Act II · Introducing yourself The client has never met this authorization server, and it never will register with it. Its identity is a URL the auth server can read on demand.
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.

The client metadata document
// 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.

→ MCP client to You The authorization URL
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.

→ Auth server to MCP client The fetch
GET /oauth/client-metadata.json HTTP/1.1
Host: agentapp.example
← MCP client to Auth server What comes back, and what gets validated
{
  "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.

→ Auth server to MCP client The callback
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.

→ MCP client to Auth server The token request
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
← Auth server to MCP client Token response, and where it goes
{
  "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.

new or changed in 2026‑07‑28 response outside the protocol
Act I · Session start This runs when the harness boots, before you type anything. You are not in it, and the model only appears at the very end.
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.

What the harness reads
// ~/.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.

→ MCP client to MCP server The full request
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": {} }
      }
    }
  }
}
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.

→ MCP server to MCP client The tools/list result
{
  "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"]
        }
      }
    ]
  }
}
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.

What the model will receive (provider format)
{
  "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 optional calendar parameter), 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:

A tools/call (or a retry) arrives Is it waiting on you? confirm · pay · connect a calendar yes resultType: input_required the call ends; you act, then the client retries storylines B · C retry no Is the server busy on its own? a human approves · a job runs yes resultType: task a durable handle you poll; the server's own wait storylines B · C no resultType: complete the result, delivered right now storyline A
The spine decides one thing: what the result says. 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.

new or changed in 2026‑07‑28 response outside the protocol
Act I · Before the wire No MCP traffic yet. The harness gathers schemas from cache, lets the model decide, and gets your consent.
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.

→ You to Harness The entire input
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.

The cached tools/list result
{
  "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"]
      }
    }
  ]
}
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.

→ Model to Harness The tool_use block
{
  "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.

Act II · The call One request out, one response back. This pair of messages is the entire protocol footprint of the storyline.
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.

→ MCP client to MCP server The full request
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": {} }
      }
    }
  }
}
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.

→ MCP server to MCP client The response
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" }
      ]
    }
  }
}
Act III · The return The result flows back through the model to you.
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.

→ Harness to Model The tool_result block
{
  "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.

→ Harness to You What you read
Two cabins are free July 24 to 26: the Lakeside cabin
($412.50 total) and the Ridge cabin ($318.00 total).

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.

new or changed in 2026‑07‑28
The harness’s machine host + MCP client
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.

The wire carried inside requests
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.

The server Pinehill’s side
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!

← Back to Archive