Lesson 4: Primitives, who decides, and how MCP has changed
Module 1 · Lesson 4 · About 40 minutes · Needs: Lesson 3 (a server and a client in memory and over stdio, JSON-RPC requests)
The situation
Thursday 10 September 2026. The prototype server runs. Nare wants three things from it: a menu entry that summarises a topic, a way to drop the anchor note into a chat without asking, and the search she already has. Tomas has read that MCP "dropped sessions" in July and asks whether the lab's older desktop assistant will still connect.
Both answers start from the same place: what MCP can carry, who triggers each kind, and how the protocol has changed. This lesson follows Nare's question, "what did we decide about the nap study?", through all three.
A real case, first
On 28 July 2026 the MCP lead maintainers, David Soria Parra and Den Delimarsky, published "The 2026-07-28 Specification" on the official MCP blog (link). The highlight, in its words: "MCP is transforming from a bidirectional stateful protocol into a request/response stateless protocol." The initialize exchange and the Mcp-Session-Id header were retired. Each request now carries "its protocol version, client identity, and client capabilities", so "any request can now land on any server instance behind a plain round-robin load balancer without needing shared storage."
The reason is set out in SEP-2575, "Make MCP Stateless" (link): with a stateful handshake, operators were "forced to implement complex and fragile solutions like sticky sessions", and per-client session state was "a common source of bugs and memory leaks". The post warns of "some migration cost, especially for developers that did depend on session identifiers", and the same release deprecated Roots, Sampling and Logging. A protocol you build on can change underneath you; the change is announced and dated.
What you will be able to do
- Name the three server primitives and the client feature, and give a notes example of each.
- Decide which primitive fits a feature by asking who should trigger it.
- List the five specification revisions and explain the major shifts between them.
- Capture the same request in both protocol eras and point at what differs.
- Follow proposals and deprecations so the next revision does not surprise you.
1. What servers offer, what clients offer (Essential)
Everything that crosses MCP is one of a few kinds of thing, called primitives. Knowing them tells you what you can build into the notes server and what a host will do with it.
A server offers three. Tools are functions to run, like search_notes. Resources are content to read, named by a URI such as notes://lab-sync-2026-09-02. Prompts are ready-made requests the user starts from, like summarise_topic. Modules 3 and 4 build each one properly; this lesson puts one of each into a small server so you can see them side by side.
Clients offer features back, and in the 2026-07-28 revision one is active: elicitation, where a server asks the user for a missing detail through a form or a URL. Sampling (the server borrowing the host's model) and roots (the client naming folders) still work but are deprecated. If you met these in the agentic course (Module 4, Lesson 1), this course goes deeper.
%pip install -q mcp==2.3.0
import logging
for name in ("mcp", "httpx2", "uvicorn"):
logging.getLogger(name).setLevel(logging.WARNING)Code walkthrough
- The course's MCP cell from course-guide/02: the pinned SDK, and three loggers turned down so connection messages do not bury your output.
from pathlib import Path
NOTES = {
"lab-sync-2026-09-02": """---
title: Lab sync, 2 September 2026
tags: [meeting, sleep]
created: 2026-09-02
---
Attendees: Priya, Tomas, Nare.
Decision: run the nap study with 24 participants instead of 16, budget approved by Priya.
Action: Tomas books the sleep lab for the weeks of 5 and 12 October.
Action: Nare drafts the consent form by 15 September.
Risk: EEG caps are shared with another group, so booking conflicts are likely.
""",
"sleep-and-memory": """---
title: Sleep and memory consolidation
tags: [sleep, memory, neuroscience]
created: 2026-03-02
---
Slow-wave sleep replays the day's hippocampal activity and moves it into long-term cortical storage.
Walker (2017) reports that a night without sleep cuts new memory formation by about 40 percent.
A 20 minute nap after learning helps recall of word pairs, but only if the nap reaches stage 2 sleep.
Open question: does REM sleep matter more for procedural skills than for facts?
""",
}
def write_notes(folder="notes", notes=NOTES):
"""Write every note to <folder>/<note_id>.md and return the folder."""
root = Path(folder)
root.mkdir(parents=True, exist_ok=True)
for note_id, text in notes.items():
(root / f"{note_id}.md").write_text(text, encoding="utf-8")
return root
print(sorted(p.name for p in write_notes().glob("*.md")))Code walkthrough
- Two of the eight notes from course-guide/06, copied exactly: the anchor note and one that mentions a nap but not the study.
write_noteswrites one file per note. The output lists the two file names.
%%writefile notes_server.py
from pathlib import Path
from mcp.server import MCPServer
NOTES_DIR = Path("notes")
mcp = MCPServer("notes", version="0.1.0")
def body(note_id: str) -> str:
return (NOTES_DIR / f"{note_id}.md").read_text(encoding="utf-8").split("---\n")[2]
@mcp.tool()
def search_notes(query: str) -> list[str]:
"""Search Nare's notes. Returns the ids of notes whose body contains every query word."""
words = query.lower().split()
return [p.stem for p in sorted(NOTES_DIR.glob("*.md")) if all(w in body(p.stem).lower() for w in words)]
@mcp.resource("notes://index")
def note_index() -> str:
"""Every note id, one per line."""
return "\n".join(p.stem for p in sorted(NOTES_DIR.glob("*.md")))
@mcp.resource("notes://{note_id}")
def read_note(note_id: str) -> str:
"""One note in full."""
return (NOTES_DIR / f"{note_id}.md").read_text(encoding="utf-8")
@mcp.prompt()
def summarise_topic(topic: str) -> str:
"""A ready-made request to summarise what the notes say about a topic."""
return f"Summarise what my notes say about {topic}. Cite the note id for every claim."
if __name__ == "__main__":
mcp.run() # stdio by defaultCode walkthrough
@mcp.tool()makes a tool; this search, a teaching stand-in, returns notes whose body holds every query word. Module 3 builds the real one onNoteStore.@mcp.resource("notes://index")is a fixed resource."notes://{note_id}"is a resource template: one pattern standing for many URIs.@mcp.prompt()returns the text of a request the user can start from.
import notes_server
from mcp import Client
async with Client(notes_server.mcp) as client: # in memory
caps = client.server_capabilities
print("server offers:", [k for k in ("tools", "resources", "prompts") if getattr(caps, k)])
print("tools: ", [t.name for t in (await client.list_tools()).tools])
print("resources: ", [str(r.uri) for r in (await client.list_resources()).resources])
print("templates: ", [t.uri_template for t in (await client.list_resource_templates()).resource_templates])
print("prompts: ", [p.name for p in (await client.list_prompts()).prompts])Code walkthrough
client.server_capabilitiesis what the server advertised when the client connected. The output was['tools', 'resources', 'prompts'].- Each
list_...call is one protocol request, listing one kind of primitive. Templates are listed separately from resources.
2. Control models: who decides (Essential)
The primitives differ less in what they hold than in who pulls the trigger. Get that wrong and the model ends up deciding things Nare meant to decide, or the reverse.
The specification calls this the control hierarchy. Prompts are user-controlled: Nare picks one from a menu. Resources are application-controlled: the host decides what to attach, often because Nare pinned it. Tools are model-controlled: the model chooses to call them mid-conversation. The rule matters for safety too. The specification's own wording is that hosts "must obtain explicit user consent before invoking any tool".
def scripted_model(context, tool_names):
"""A stand-in for a model, NOT a real one: it always asks for the same search."""
return {"tool": "search_notes", "arguments": {"query": "nap study"}}
trace, context = [], []
async with Client(notes_server.mcp) as client:
# 1. Nare picks /summarise_topic from a menu: the USER decided
prompt = await client.get_prompt("summarise_topic", {"topic": "the nap study"})
context.append(prompt.messages[0].content.text)
trace.append(("user", "prompt", "summarise_topic"))
# 2. The host attaches the note Nare pinned: the APPLICATION decided
pinned = "notes://lab-sync-2026-09-02"
note = await client.read_resource(pinned)
context.append(note.contents[0].text)
trace.append(("application", "resource", pinned))
# 3. The model asks for a tool: the MODEL decided (here, the stand-in)
tool_names = [t.name for t in (await client.list_tools()).tools]
choice = scripted_model(context, tool_names)
result = await client.call_tool(choice["tool"], choice["arguments"])
trace.append(("model", "tool", f'{choice["tool"]} -> {result.content[0].text}'))
for who, primitive, what in trace:
print(f"{who:<12} {primitive:<9} {what}")
print(len(context), "pieces of context,", sum(len(c) for c in context), "characters")Code walkthrough
scripted_modelis a stand-in that always chooses the same tool; no real model runs in this lesson.get_promptfills the prompt'stopicand returns messages;read_resourcefetches the note by URI;call_toolruns the search.tracerecords who decided each step. The output showsuser,application, thenmodelwithsearch_notes -> lab-sync-2026-09-02, and 2 pieces of context totalling 490 characters before the tool ran.
Use it as a design test. "Search my notes when it helps" is the model's call: a tool. "Always include the note I pinned" is the host's call: a resource. "Summarise a topic when I ask" is Nare's call: a prompt. "Ask me which tag to use before saving" is a question for a person during a call: elicitation.
3. The five specification revisions (Recommended)
Servers in the wild speak different revisions, so when a host and a server disagree, the revision is the first thing to check.
Each revision is named by the date of its last breaking change, and that string is the protocol version client and server agree on. The first, 2024-11-05, had tools, resources, prompts, sampling and roots over "stateful connections". 2025-03-26 added OAuth 2.1 authorisation, Streamable HTTP and tool annotations. 2025-06-18 added structured tool output and elicitation. 2025-11-25 added URL-mode elicitation and experimental tasks. 2026-07-28 made requests "stateless, self-contained".
from importlib.metadata import version
from mcp.types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
print("mcp", version("mcp"))
print("handshake era:", list(HANDSHAKE_PROTOCOL_VERSIONS))
print("modern era: ", list(MODERN_PROTOCOL_VERSIONS))
print(len(HANDSHAKE_PROTOCOL_VERSIONS) + len(MODERN_PROTOCOL_VERSIONS), "revisions this SDK can speak")Code walkthrough
version("mcp")reads the installed package version:2.3.0.- The two tuples are the SDK's own registry. The output lists four handshake-era revisions and one modern one:
5 revisions this SDK can speak.
4. The major shifts and why they happened (Recommended)
Each shift answered a problem real deployments hit. Knowing the reason tells you which old habits to drop.
The biggest is the one from the opening case. Up to 2025-11-25, a client opened with an initialize handshake, and over HTTP the server issued a session id that tied the client to one instance. In 2026-07-28 the handshake is gone. Each request carries its version and capabilities in _meta, and a client that wants the server's capabilities first can call server/discover. State that must survive between calls becomes a handle a tool returns and the model passes back (Module 2).
Two earlier shifts shaped remote servers. In 2025-03-26, Streamable HTTP replaced HTTP+SSE, which needed a POST endpoint plus a held-open event stream. In 2025-06-18, the MCP server became an OAuth resource server: it checks tokens issued by a separate authorisation server, and clients must name the server a token is for, so a malicious server cannot obtain tokens meant for others. Module 7 builds that.
The last shift is how a server asks for something mid-call. Earlier, a server sent its own request to the client, which needs a connection held open. With Multi Round-Trip Requests, the server returns resultType: "input_required" with its questions, and the client retries the original call with the answers. Module 4 drives it by hand.
5. One server, two protocol eras (Recommended)
Nare's server must work with the lab's newest host and with older hosts that still shake hands. You can see both conversations by recording the wire.
A stdio server talks through its standard input and output, one JSON message per line. The wiretap below is a tiny program that starts the server, passes every line through unchanged, and copies it into a log, so you see real messages rather than typed examples.
%%writefile wiretap.py
"""Start an MCP stdio server and copy every message, both ways, into a log file."""
import subprocess, sys, threading
log_path, server_cmd = sys.argv[1], sys.argv[2:]
child = subprocess.Popen(server_cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
log = open(log_path, "a", encoding="utf-8")
lock = threading.Lock()
def pump(src, dst, arrow):
for line in iter(src.readline, b""):
with lock:
log.write(f"{arrow} {line.decode().strip()}\n")
log.flush()
dst.write(line)
dst.flush()
dst.close()
threading.Thread(target=pump, args=(child.stdout, sys.stdout.buffer, "<-"), daemon=True).start()
pump(sys.stdin.buffer, child.stdin, "->") # runs until the client closes stdin
child.wait()Code walkthrough
subprocess.Popenstarts the real server with pipes for its input and output.pumpcopies lines between pipes, logging each with->(client to server) or<-(server to client); the lock stops the two directions mixing.- A thread pumps the server's replies; the main thread pumps the client's requests until the client closes the pipe.
import json, os, sys
from mcp import StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.types import ElicitResult, Implementation
async def ask_nare(context, params): # the host could show Nare a form; never called here
return ElicitResult(action="decline")
async def capture(mode, action):
"""Run action(client) over stdio in one mode; return the version spoken and every message."""
log_path = f"wire-{mode}.log"
if os.path.exists(log_path):
os.remove(log_path)
params = StdioServerParameters(command=sys.executable,
args=["wiretap.py", log_path, sys.executable, "notes_server.py"])
with open("server.log", "w") as err:
async with Client(stdio_client(params, errlog=err), mode=mode, elicitation_callback=ask_nare,
client_info=Implementation(name="nare-host", version="0.1.0")) as client:
await action(client)
spoke = client.protocol_version
lines = open(log_path, encoding="utf-8").read().splitlines()
return spoke, [(line[:2], json.loads(line[3:])) for line in lines]
search = lambda client: client.call_tool("search_notes", {"query": "nap study"})
wire = {}
for mode in ("auto", "legacy"):
spoke, wire[mode] = await capture(mode, search)
print(f"mode={mode}: spoke {spoke}, {len(wire[mode])} messages")
for arrow, msg in wire[mode]:
print(" ", arrow, msg.get("method", "(result)"), f"id={msg['id']}" if "id" in msg else "")Code walkthrough
ask_nareis an elicitation callback. Passing it makes the client advertise elicitation, so the capture shows a client feature on the wire.capturelaunches the server through the wiretap, runs one action, and returns the version spoken and every logged message.mode="auto", the default, probes withserver/discoverand falls back to the handshake for older servers;mode="legacy"always shakes hands. The output:autospoke2026-07-28in 6 messages,legacyspoke2025-11-25in 7.
Both eras found lab-sync-2026-09-02. After the call, the SDK's client also sent tools/list: it fetches the tool's output schema to check the result against it.
def first(mode, method):
"""The first message the client sent with this method."""
return next(m for a, m in wire[mode] if a == "->" and m.get("method") == method)
modern, legacy = first("auto", "tools/call"), first("legacy", "tools/call")
print("modern params:", sorted(modern["params"]))
for key, value in modern["params"]["_meta"].items():
print(" ", key, "=", json.dumps(value))
print("legacy params:", sorted(legacy["params"]))
size = lambda m: len(json.dumps(m, separators=(",", ":")))
print("bytes:", size(modern), "modern,", size(legacy), "legacy")
print("legacy said it once, in initialize:", json.dumps(first("legacy", "initialize")["params"]))Code walkthrough
firstfinds the first client message with a given method in a capture.- The modern
tools/callhas a_metawith three keys: protocol version, client info and client capabilities (elicitation, form and URL). The legacy one has onlynameandarguments, because it said those things once, ininitialize. sizemeasures the compact JSON: 336 bytes modern, 113 legacy, the price of every request standing alone.
The specification's compatibility matrix names three kinds of implementation: modern, legacy and dual-era, which speaks both. MCPServer is dual-era: it answered server/discover and initialize from the same file. So the older desktop host Tomas asked about will connect; a client that speaks only 2026-07-28 would not reach an old server.
Break it: pin the version and lose the menus
A client can skip the probe by pinning a version. It saves a round trip and quietly removes something the host relied on.
def menus(client):
"""What the host shows Nare, decided from the server's advertised capabilities."""
caps = client.server_capabilities
return [k for k in ("tools", "resources", "prompts") if getattr(caps, k)]
async with Client(notes_server.mcp) as client: # auto: one server/discover
print("auto: ", client.protocol_version, menus(client), client.server_info.name)
saved = client.session.discover_result
async with Client(notes_server.mcp, mode="2026-07-28") as client: # pinned: no discovery at all
print("pinned:", client.protocol_version, menus(client), client.server_info)
print(" the tool still works:", (await client.call_tool("search_notes", {"query": "nap study"})).content[0].text)
async with Client(notes_server.mcp, mode="2026-07-28", prior_discover=saved) as client:
print("fixed: ", client.protocol_version, menus(client), client.server_info.name)Code walkthrough
menusis host logic: show a menu for each primitive the server advertised.- With
autoit shows all three. With a bare pin,server_capabilitiesis empty andserver_infoisNone, so the host shows Nare nothing, yet the tool call still returnslab-sync-2026-09-02. savedkeeps the discovery result; handing it back asprior_discoverrestores all three menus with no extra round trip.
try:
Client(notes_server.mcp, mode="2025-06-18")
except ValueError as err:
print(err)Code walkthrough
- Only modern versions can be pinned. The SDK refuses before any input or output:
'2025-06-18' is a handshake-era version; use mode='legacy'.
6. Awareness card: following proposals and keeping up (Optional)
MCP changes every few months. Two habits keep the notes server from breaking quietly.
Following SEPs. Every significant change starts as a Specification Enhancement Proposal (SEP): a Markdown file in the specification repository, numbered after its pull request (SEP-1850). A maintainer sponsors it from Draft through In-Review and Accepted to Final, which needs a reference implementation. SEP-2575 was created on 18 June 2025 and shipped in the 2026-07-28 revision. Read the in-review ones to see what is coming.
Keeping up with future revisions. Each revision has a changelog and a deprecated features registry. A deprecated feature stays for at least twelve months (ninety days under an expedited exception). Roots, Sampling and Logging may go from "the first revision released on or after 2027-07-28". The roadmap of 22 August 2026 names five priority areas but no release date.
from datetime import date
from mcp.types.version import LATEST_MODERN_VERSION
TESTED_WITH = {"mcp": "2.3.0", "revision": "2026-07-28"} # what this course was run on
DEPRECATED = { # the 2026-07-28 deprecated features registry (checked 13 October 2026)
"roots": "pass folders as tool arguments or server configuration",
"sampling": "call a model provider's API directly",
"logging": "log to stderr, or use OpenTelemetry",
}
EARLIEST_REMOVAL = date(2027, 7, 28) # "first revision released on or after" this date
def keep_up(host_features, today=date(2026, 10, 13)):
if version("mcp") != TESTED_WITH["mcp"] or LATEST_MODERN_VERSION != TESTED_WITH["revision"]:
print("SDK changed: read the changelog and the SDK release notes before upgrading")
for feature in host_features:
if feature in DEPRECATED:
days = (EARLIEST_REMOVAL - today).days
print(f"{feature}: deprecated; earliest removal in {days} days; {DEPRECATED[feature]}")
print("checked", len(host_features), "features against", LATEST_MODERN_VERSION)
keep_up(["tools", "resources", "prompts", "elicitation", "sampling"])Code walkthrough
TESTED_WITHrecords what the course ran on; a different SDK or revision prints a warning.DEPRECATEDandEARLIEST_REMOVALcopy the registry. The output flagssampling, 288 days before its earliest removal, with its migration.
In production
The blog post says all four Tier 1 SDKs (TypeScript, Python, Go and C#) spoke 2026-07-28 on release day, with Rust in beta. Hosts and servers built earlier speak 2025-11-25 or older, so the Python SDK's defaults are dual-era: in its documentation's words, "One Client, any era of server". HTTP+SSE and Dynamic Client Registration are deprecated too. Expect the dates, the roadmap and the version numbers here to change first.
What you added to the notes assistant
- A server with one of each primitive:
search_notes,notes://index,notes://{note_id}andsummarise_topic. - A rule for choosing a primitive: who should decide.
- A wiretap that records real messages in both protocol eras.
- A
keep_upcheck against the SDK version and the deprecated features registry.
Check yourself
- Nare wants every chat to start with the lab's meeting notes attached, without the model choosing. Which primitive?
A resource. Attaching context is application-controlled; the host decides, so no model decision is involved.
- Why did 2026-07-28 remove the
initializehandshake?
Session state tied each client to one server instance, which forced sticky sessions behind load balancers, lost state when an instance failed, and caused bugs and memory leaks (SEP-2575). Self-contained requests can land on any instance.
- The captured modern
tools/callwas 336 bytes and the legacy one 113. Where did the difference go in the legacy era?
Into initialize, sent once per connection: version, client info and capabilities. The modern request repeats them on every call.
- A host pins
mode="2026-07-28"and its prompt menu disappears. Why, and what fixes it?
A pin skips discovery, so the client knows no server capabilities. Pass a saved discovery result as prior_discover, or use auto.
Exercise
Capture a resources/read of notes://lab-sync-2026-09-02 in both eras with your capture function and confirm that only the modern request carries _meta. Try it first; one answer follows.
read = lambda client: client.read_resource("notes://lab-sync-2026-09-02")
wire_read = {}
for mode in ("auto", "legacy"):
spoke, wire_read[mode] = await capture(mode, read)Code walkthrough
readis the action: one resource read.captureruns it through the wiretap in each mode and keeps the messages.
for mode in ("auto", "legacy"):
sent = [m for a, m in wire_read[mode] if a == "->" and m.get("method") == "resources/read"]
assert sent, f"no resources/read captured in mode={mode}"
assert ("_meta" in sent[0]["params"]) == (mode == "auto"), f"unexpected _meta in mode={mode}"
print(mode, [m.get("method", "(result)") for a, m in wire_read[mode]])
print("check passed: only the modern request carries its version and capabilities")Code walkthrough
- The check finds the
resources/readeach client sent and asserts_metais present only inauto. It printedserver/discover, then the read, forauto, andinitialize,notifications/initialized, then the read, forlegacy.
Key terms
| Term | Meaning |
|---|---|
| Primitive | One kind of thing MCP carries: tool, resource or prompt from servers; elicitation from clients |
| Resource template | A URI pattern such as notes://{note_id} standing for many resources |
| Elicitation | A server asking the user, through the client, for a missing detail |
| Control hierarchy | Prompts user-controlled, resources application-controlled, tools model-controlled |
| Protocol version | The date string naming a revision, such as 2026-07-28 |
server/discover | The 2026-07-28 request that returns a server's versions and capabilities |
| Multi Round-Trip Request | The server answers input_required; the client retries with answers |
| Dual-era | A client or server that speaks both modern and legacy revisions |
| SEP | Specification Enhancement Proposal: the document behind every significant change |
Sources
- David Soria Parra and Den Delimarsky, "The 2026-07-28 Specification", MCP blog, 28 July 2026. https://blog.modelcontextprotocol.io/posts/2026-07-28/
- Jonathan Hefner, Mark Roth, Shaun Smith, Harvey Tuch and Kurtis Van Gent, "SEP-2575: Make MCP Stateless", created 2025, Final. https://modelcontextprotocol.io/seps/2575-stateless-mcp
- Model Context Protocol, "Specification" (overview, revision 2026-07-28), checked 13 October 2026. https://modelcontextprotocol.io/specification/2026-07-28
- Model Context Protocol, "Server Features: Overview" (control hierarchy), 2026. https://modelcontextprotocol.io/specification/2026-07-28/server
- Model Context Protocol, "Key Changes" changelogs for 2025-03-26, 2025-06-18, 2025-11-25 and 2026-07-28. https://modelcontextprotocol.io/specification/2026-07-28/changelog
- Model Context Protocol, "Versioning and Compatibility", 2026. https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning
- Model Context Protocol, "Deprecated Features", 2026. https://modelcontextprotocol.io/specification/2026-07-28/deprecated
- Model Context Protocol, "Versioning", 2026. https://modelcontextprotocol.io/docs/2026-07-28/learn/versioning
- Nick Cooper and David Soria Parra, "SEP-1850: PR-Based SEP Workflow", 2025. https://modelcontextprotocol.io/seps/1850-pr-based-sep-workflow
- David Soria Parra and Den Delimarsky, "The New MCP Roadmap", MCP blog, 22 August 2026. https://blog.modelcontextprotocol.io/posts/mcp-roadmap/
- MCP Python SDK, "Protocol versions", checked 13 October 2026. https://py.sdk.modelcontextprotocol.io/protocol-versions/