On 28 July, the Model Context Protocol removed protocol-level sessions.
The change is easy to misread. Stateful tools did not disappear. A remote server can still keep a basket, browser, or long-running job. The difference is that cross-call state is no longer inferred from a connection. When a server needs such state, it exposes an explicit identifier such as basket_id, which later calls carry.
That is a cleaner boundary. It lets three subagents share one basket while keeping three browser contexts separate. It also exposes a question that a session could obscure.
Suppose two subagents receive basket_id=bsk_42. Both inspect version 7 of a laptop configuration. One upgrades the memory and writes version 8. The other still reasons from version 7, then submits a complete configuration that increases storage but silently restores the old memory value.
The second call has the right resource identifier. It can have the right authenticated user. It can be valid JSON produced from an accurate tool schema.
Its observation is still stale.
Nothing in this trace requires a hallucinated ID, a leaked token, a crash, or a duplicate retry. The missing information is which version of the resource the transition was based on.
MCP does not promise to answer that question. Nor should a general interoperability protocol prescribe every application’s concurrency model. Once application state becomes a model-visible product primitive, however, tool designers have to decide what continuity means.
An explicit handle gives an agent an address. Safe continuation needs a versioned transition contract around it.
What the protocol actually removed
The MCP 2026-07-28 changelog removes both the initialize handshake and protocol-level sessions. Protocol version and client capabilities now travel with each request. Mcp-Session-Id is gone. A server may not depend on an earlier request to recover protocol context for a later one.
Cross-call application state is different. The current statelessness requirement says that such state must be referenced by an explicit identifier carried on each request. The state itself can remain in a database, a durable service, or behind routing keyed by the explicit handle. What disappears is the protocol-level session context that a server could previously require later calls to resume.
SEP-2567 uses a basket as its canonical example. A tool creates the basket, returns basket_id, and accepts that ID on later calls. This removes an awkward fixed scope. Collaborating agents can share the basket while keeping their browsers isolated. Work can also move between conversations or workers without relying on sticky routing, provided the handle survives the hand-off and every server replica that may receive the call can resolve it.
The handle is deliberately not a new MCP primitive. The protocol sees an ordinary string in a tool result and an ordinary string in a later argument. Its opacity, retention, cleanup, and expiry are application concerns. SEP-2567’s non-normative server guidance recommends validating the pair of handle and current authorization context on every call. Its non-normative client guidance says the client’s main responsibility is to preserve the handle across context compaction, since discarding the result that contained it can orphan the state.
This is good protocol design. State with product-specific semantics belongs to the product that owns it.
It also means the handle carries fewer guarantees than its convenient syntax may suggest. It tells the server which object the caller means. It does not, by itself, say whether the caller saw the current object, whether the intended transition remains valid, or what should happen when another actor changed the object first.
The handle can be right while the view is wrong
Here is the constructed schedule behind the opening example.
At version 7, the basket contains a laptop with 16 GiB of memory and 512 GiB of storage. Agents A and B both read that state. A changes memory to 32 GiB. B independently changes storage to 1,024 GiB.
If each agent sends a complete snapshot to a last-write-wins tool, both requests can succeed:
read A -> bsk_42 @ v7 { RAM: 16, storage: 512 }
read B -> bsk_42 @ v7 { RAM: 16, storage: 512 }
write A -> bsk_42 @ v8 { RAM: 32, storage: 512 }
write B -> bsk_42 @ v9 { RAM: 16, storage: 1024 }
The final basket contains B’s storage upgrade and has lost A’s memory upgrade. No identifier was confused. The error lives in the relationship between B’s observation and B’s transition.
This is a classic lost-update schedule, not an agent-specific invention. Agent workflows can create it readily because explicit handles are passed across subagents, contexts, and workers, while model reasoning can lengthen the interval between read and write. That leaves more time for the underlying resource to change.
The important design move is not to blame parallelism. Parallel work is one of the reasons explicit handles are useful. The move is to make the assumption behind a mutation inspectable.
I built a small dependency-free Go model of this exact interleaving. The naive implementation accepts both full snapshots and loses A’s change. The guarded implementation rejects B’s stale write, then applies B’s intent after a refresh. Its assertions check only that constructed schedule under an atomic check-and-write assumption. They are not a proof of distributed consensus, durability, or general correctness.
A handle answers where, not when
Several identifiers can appear in one agent workflow. They have different jobs.
| Value | Question it answers | What it does not establish |
|---|---|---|
| Authenticated principal | Who is making this request? | Which resource version was observed |
| Resource handle | Which application object? | Freshness, authority, or ownership |
| Resource version | Which state does the caller declare as the transition basis? | Whether a retry represents the same intent |
| Operation ID | Which logical mutation? | Whether its starting state is current |
| Task ID | Which asynchronous execution? | Business success or resource freshness |
| Trace ID | Which distributed trace? | Resource state, business outcome, or durable application lineage |
The distinctions matter most when a workflow crosses boundaries. A handle can survive a new HTTP connection. If the client persists a task ID, it can resume polling after its own crash or restart while the server still retains the task. A tracing system can correlate telemetry from several services under one trace. None says that the model’s description of the resource is still current.
The same applies to conversation history. A transcript might contain both bsk_42 and a prose summary of version 7. The handle can still resolve while the summary is obsolete. Context is a useful projection of application state, not its authoritative record.
The MCP Tasks extension, defined by a final Extensions-Track proposal and still evolving outside core, reinforces this separation. It gives asynchronous work a server-generated taskId, lifecycle states, timestamps, polling, and a nullable TTL. The server must create a task durably before returning its ID, and clients should persist IDs across restarts. That is an execution contract. It does not turn the task ID into the version of every resource the task may read or change.
Even completed is a protocol-level state, not a universal claim of business success. The Tasks SEP allows a completed task to contain a tool result marked isError: true. Names are useful precisely when they retain narrow meanings.
Put the precondition on the transition
For a mutable resource whose updates can conflict, I would make a validator for the transition’s declared basis part of the tool contract:
{
"basket_id": "bsk_42",
"expected_version": 7,
"operation": {
"type": "upgrade_storage",
"gigabytes": 1024
}
}
The server authenticates the current caller and authorizes the requested operation on bsk_42. Every validation derived from mutable resource state, including lifecycle, the version comparison, and the mutation, must then share one transaction or conditional-write boundary. Otherwise each separate check opens another race.
If the current version is still 7, it applies the operation and returns version 8. If another accepted transition has already produced version 8, it does not apply the stale operation. Its structured domain payload might be:
{
"code": "STALE_VIEW",
"expected_version": 7,
"current_version": 8,
"recovery": "refresh_and_replan"
}
At the MCP boundary, this payload would sit inside a complete tool result, with human-readable content for the model. The validator turns a declared transition basis into a precondition. It is not evidence that the model inspected or understood version 7. For consequential mutations, a trusted harness can propagate an opaque server-issued validator from the read result instead of asking the model to invent a version number.
The comparison and write must be atomic. Reading the version in one query and updating in another merely moves the race between the two.
The proposed transition contract
Compare the declared basis before changing the resource.
FIG. 02
Resourcebsk_42where
Expected versionv7declared transition basis
Operationupgrade storageproposed transition
- 01Load currentbsk_42
- 02Comparev7 = current?
- 03Validate transitionallowed now?
- 04Conditional commitstate + v8
v8mutation committed
return the fresh projectionno writeprecondition rejected
return current version + recovery pathInvariantthe comparison and mutation share one atomic boundary
A mutation request names basket bsk_42, declares version seven as the transition basis, and proposes a storage upgrade. The server loads the current resource, compares its version, validates the transition, and commits the change inside one atomic boundary. If the current version remains seven, it applies the change and returns version eight. If another actor has already produced version eight, it performs no write and returns the typed outcome stale view with a recovery path.
Conditional mutation is established API and storage practice. RFC 9110 defines If-Match so a server evaluates a representation validator before performing a state-changing method. Its explicit use case is preventing one client from overwriting another client’s parallel update.
The same pattern appears at other layers. Google’s AIP-154 recommends resource ETags when client and server must agree on the state being changed, with an ABORTED error response for a mismatch. DynamoDB’s optimistic-locking guidance uses a version attribute and a conditional write that fails after another process changes the item.
The point is not that every MCP tool should expose an integer called version. A strong ETag, an aggregate revision, or a domain-specific snapshot token can carry the same precondition. The point is that the next state change should name the state it assumes whenever an intervening change would matter.
Nor is expected_version a complete mutation protocol. A response-lost retry may also need a stable operation_id so the service can return the recorded outcome instead of applying the same intent twice.
- The version asks, “Has the resource changed since this declared basis?”
- The operation ID asks, “Have I already accepted this logical mutation?”
Those checks must share a durable boundary with the mutation, or an equivalent recovery protocol. Within that boundary, a repeated operation ID with the same payload returns its recorded outcome before the old expected version is treated as a new stale transition. Concurrent uses of the same ID collapse into one logical outcome; reuse with a different payload is rejected. A conflict-triggered replan is a new mutation, so it receives a new operation ID.
Record 001 explored that retry and execution axis in detail. The contract here isolates another problem: two distinct, legitimate operations acting from one shared but diverging view.
Conflict is a product state
A stale view is not an infrastructure error that should always disappear behind an automatic retry.
Consider the storage upgrade. If memory changed from 16 GiB to 32 GiB, the two changes may be compatible. The agent can refresh, reapply only its storage intent, and present the combined configuration. If another actor changed the storage itself, silently choosing a winner may be wrong. The product might show the delta or ask which configuration to keep.
The same conflict has different product meaning elsewhere. In a travel booking, deployment plan, or document awaiting approval, a changed input may invalidate the decision built on it. An automatic retry cannot decide whether an old approval still covers a new total, an edited deployment plan needs another review, or a terminal action should proceed.
Conflict is a product state
Recovery follows the operation, not one global retry rule.
FIG. 03
- 01 · CommutativeAppend note
Another independent note arrives
Merge by stable entry ID
CONTINUE - 02 · Field scopedUpgrade storage
A different field changes
Refresh, revalidate, apply intent
APPLY - 03 · ReplaceRewrite basket
Any observed field changes
Show the delta, then replan
REPLAN - 04 · TerminalCheckout
Price, item, or lifecycle changes
Refresh material facts + confirm
CONFIRM - 05 · ClosedExpired basket
The lifecycle has ended
Perform no mutation; explain why
STOP
Policy testCan this operation preserve intent after the state changed?merge · replan · confirm · stop
The matrix shows five recovery policies. An append-only note can merge by stable entry identifier. A storage upgrade can be reapplied after a refresh and revalidation when the transition remains valid. A whole-resource replacement should surface the delta and require replanning. A terminal checkout should refresh material facts and require confirmation. An expired resource should reject mutation and explain that its lifecycle ended.
This is where backend semantics become product design. STALE_VIEW needs a recovery contract, not just an error code.
For each state-changing tool, I would decide:
- Which observations make the proposed transition valid?
- Which concurrent changes commute with it?
- What is safe to merge without new judgment?
- What delta does the agent or person need to see before trying again?
- Which transitions become terminal after expiry, closure, approval, or execution?
The answers shape both API and interface. A recoverable conflict can return a current projection and, where the service retains enough history to compute one, a field-level delta. A consequential conflict can require renewed confirmation. An expired object should return an explicit terminal state rather than tempt the model into repeated guesses. A naturally commutative append may not need a strict resource version at all.
Optimistic concurrency is therefore not “reject every stale request.” It is “do not let the storage engine silently choose product semantics.”
Context is a projection, not the record
In many agent prototypes, continuity lives largely in the conversation. The transcript contains the plan, tool outputs, handles, and recent explanations. Context compaction turns that history into a smaller working set.
That is valuable, but it creates two separate preservation problems.
The first is identity. If the handle falls out of context, the resource can become orphaned. SEP-2567 calls this out directly; its non-normative client guidance says preserving the handle across context compaction is the client’s main responsibility.
The second is freshness. Preserving a handle and an old snapshot perfectly can still preserve the wrong basis for a new mutation. A hand-off can therefore carry a small resumption record such as:
resource: bsk_42
basis_version: 7
lifecycle: draft
expires_at: 2026-07-31T18:00:00Z
next_operation: compare_configurations
This record is not an access token. For an authenticated server, the server still derives the principal from the current authorization context and validates the handle on every call. If possession of the handle is the only authority on an unauthenticated service, it functions as a bearer capability and needs appropriate entropy and lifetime controls. The record is also not a cache guarantee. It tells the next worker what the previous worker believed, so the worker knows what to revalidate before acting.
When consequence is low, the harness can rehydrate current state automatically. When a material fact changed, it can surface the delta to the model or user. What it should not do is treat a compacted sentence as stronger evidence than the resource that sentence describes.
The same discipline applies to observability. W3C Trace Context standardizes HTTP headers and value formats for propagating trace context between services. A tracing system can use that context to correlate telemetry and reconstruct where a call travelled. Unless the application deliberately records resource versions and domain outcomes, it cannot establish which state the model saw or whether the resulting transition remained valid.
Shortcuts still make product decisions
Always read immediately before writing. This shortens the stale interval. State can still change between the read and the write unless the write is conditional.
Queue mutations by handle. A server-side queue orders arrival. It does not establish that a request was planned from current state; the request can already be stale when enqueued. End-to-end serialization from read through commit can prevent this race, but requires a long-lived critical section.
Use last-write-wins. This is reasonable for some replaceable preferences. It is not a neutral default. It silently embeds a product decision that arrival order matters more than the work overwritten.
Put the snapshot inside the handle. That produces a larger handle containing stale data. It can also leak application state into prompts and logs. The current server state still needs a conditional comparison.
Require a version on every call. Reads, immutable resources, server-relative increments, and naturally commutative operations may not need one. Concurrency policy should follow the operation’s semantics and consequence.
Use a lock. A write-phase lock can make check-and-write atomic, but does not validate assumptions formed before acquisition. Holding the lock from read through planning avoids that race, at the cost of ownership, leases, expiry, recovery, and poor concurrency. If the lock begins after the plan or approval was formed, the product still needs a policy for changes that occurred before acquisition.
The smallest useful contract is not universal machinery. It is an explicit precondition where stale reasoning could overwrite, contradict, approve, or execute against a materially different state.
Where the contract stops
A version precondition can detect an intervening mutation in the resource store that participates in the atomic check. It cannot prove that the model’s plan is sensible. It does not authorize the caller, deduplicate retries, make external effects exactly once, or guarantee that a market, browser, filesystem, or third-party API remains unchanged.
One scalar version only protects the aggregate it actually represents. A transition spanning several independently stored resources may need per-resource validators, a transaction snapshot, a reservation, or a domain-specific coordination protocol.
There is also a cost. Contended resources will produce more conflicts. Every typed recovery path needs product work. Low-consequence or immutable tools should not inherit a distributed-systems ceremony they do not need.
This proposal is therefore not a missing MCP extension. It is an application contract that MCP’s new boundary makes easier to see.
The protocol is now free to forget which process handled the previous call. The application should not forget which version the next action assumes.