Keep state with handles, and listen for changes
Carry state across calls with explicit handles, and get notified when lists or resources change.
- Design a tool that returns and accepts a state handle
- Bind handles to the authenticated user
- Use
subscriptions/listenfor change notifications
MCP has no protocol session, so a server can’t tie two tool calls together by connection. When it needs state - a shopping basket, an open browser tab, a database transaction - the spec’s guidance is an explicit handle:
- a creation tool returns a handle, e.g.
{"basket_id": "bsk_a1b2c3"}, - the model passes it as an argument to later calls (
add_item(basket_id, sku)), - the server looks up the state under that key.
1import secrets
2baskets = {}
3
4def create_basket(user):
5 basket_id = "bsk_" + secrets.token_hex(8)
6 baskets[(user, basket_id)] = []
7 return basket_id
8
9def add_item(user, basket_id, sku):
10 if (user, basket_id) not in baskets:
11 return "error: unknown or expired basket"
12 baskets[(user, basket_id)].append(sku)
13 return f"{len(baskets[(user, basket_id)])} item(s)"
14
15basket = create_basket("ana")
16print(add_item("ana", basket, "book"), "|", add_item("mallory", basket, "tv"))1 item(s) | error: unknown or expired basket
Good handles are:
- unguessable - random (like a UUIDv4), not
basket-1,basket-2; - bound to the user - stored as
user:handlewith the user taken from the verified token, so a leaked handle is useless to anyone else (a handle is a name, not a password); - limited in lifetime, with the policy stated in the tool description (“baskets expire after 24 hours”);
- clear when expired - return a tool execution error saying so, so the model can create a new one.
Subscriptions and long-running work
- Change notifications. A client opens a long-lived
subscriptions/listenrequest, choosing what to hear about (tool, prompt or resource list changes, or updates to specific resource URIs). The response is a stream that stays open; notifications carry asubscriptionIdin_meta. - Progress. A request can include a
progressTokenin_metato receivenotifications/progresswhile it runs. - Cancellation. On Streamable HTTP, closing a request’s response stream cancels it; on stdio, the client sends
notifications/cancelled. - Tasks for work that takes minutes or hours now live in the official
io.modelcontextprotocol/tasksextension, withtasks/getfor polling.
Key takeaways
No sessions: carry state with explicit handles passed as tool arguments.
Make handles random, user-bound and expiring - never a substitute for authorization.
subscriptions/listenstreams change notifications;progressTokenstreams progress; Tasks handle long-running work.
Lesson quiz
5 questions · pass with 4 correct · up to 50 XP
Passing this quiz completes the lesson and keeps your streak going. Questions you miss come back in review sessions later.
Practice: write Python
Write Python in the editor and run it against sample inputs. Python runs locally in your browser using a WebAssembly runtime.
Implement user-bound handles
Process commands until the input ends. Each line is USER create HANDLE, USER add HANDLE ITEM or USER show HANDLE. State is stored per (user, handle).
create: start an empty basket and printcreated HANDLE.add: if the user owns that basket, add the item and printadded ITEM; otherwise printerror: unknown basket.show: print the items joined by commas (orempty), orerror: unknown basket.
- Owner and intruder
- Empty and unknown
Python runs in a sandboxed browser worker with a 60 second time limit. Its runtime loads from the Pyodide CDN; your code stays in this browser.
Questions about this lesson
Stuck? Ask. Figured something out? Share it. Explaining is one of the best ways to learn.
Loading posts…