CourseModel Context Protocol: The Complete Course · Module 1: Foundations · part 10 of 77
Part 10 · Module 1: Foundations

Lesson 4: Primitives, who decides, and how MCP has changed

17 min read·13 Oct 2026
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.

ONE QUESTION, THREE WAYS IN What did we decide? Nare picks /summarise_topic prompt Host attaches notes://lab-sync... resource Model calls search_notes tool lab-sync-2026-09-02
One question can reach the anchor note three ways, and someone different triggers each: Nare (plain), the host application (blue) or the model (amber).

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.

2026-07-28: ANY REQUEST, ANY INSTANCE Client version, identity, capabilities Round robin Instance 1 Instance 2 Instance 3 no shared session store
Since 2026-07-28 every request carries what the server needs, so a plain round-robin balancer can send it to any instance without shared storage.

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.

WHAT THE NOTES SERVER OFFERS MCPServer("notes") Tools functions to run search_notes example Resources content to read notes://{note_id} example Prompts templates to start summarise_topic example
A server offers three primitives: tools to run, resources to read and prompts to start from; the notes server has one of each.

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.

WHAT A CLIENT OFFERS BACK Elicitation ask the user Sampling deprecated Roots deprecated form or URL call a model API pass folders as arguments
A client offers one active feature back to servers, elicitation; sampling and roots (grey) still work but are deprecated, with the migration shown under each.

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.

python
%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.
python
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_notes writes one file per note. The output lists the two file names.
python
%%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 default

Code 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 on NoteStore.
  • @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.
LISTED BY THE CLIENT (REAL RUN) tools search_notes resources notes://index templates notes://{note_id} prompts summarise_topic 1 each
The in-memory client lists exactly what the server advertised: one tool, one resource, one resource template and one prompt.
python
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_capabilities is 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 CONTROL HIERARCHY User picks prompts slash commands Application attaches resources file pickers Model calls tools mid-conversation
The control hierarchy: the user (plain) picks prompts, the application (blue) attaches resources, the model (amber) calls tools.

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".

python
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_model is a stand-in that always chooses the same tool; no real model runs in this lesson.
  • get_prompt fills the prompt's topic and returns messages; read_resource fetches the note by URI; call_tool runs the search. trace records who decided each step. The output shows user, application, then model with search_notes -> lab-sync-2026-09-02, and 2 pieces of context totalling 490 characters before the tool ran.
ONE RUN, THREE DECIDERS (REAL TRACE) 1 user prompt summarise_topic 2 application resource lab-sync note 3 model (stand-in) tool search_notes 2 pieces of context, 490 characters, then the tool result
The traced run: Nare's prompt and the pinned note enter the context before any model runs; only the tool call is the model's choice (here a scripted stand-in).
WHO SHOULD DECIDE? the model, mid-chat tool the app or user, what to read resource the user, on purpose prompt a human, during a call elicitation
Choose a primitive by asking who should decide; that answer, not the shape of the data, picks tool, resource, prompt or elicitation.

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.

FIVE REVISIONS 2024-11-05 first release 2025-03-26 OAuth, HTTP 2025-06-18 elicitation 2025-11-25 URL mode 2026-07-28 stateless handshake era modern
Five revisions in under two years, each named by its date; the first four share the handshake, the fifth (green, the current one) removed it.

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".

python
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.
WHAT MCP 2.3.0 SPEAKS (REAL RUN) 2024-11-05 2025-03-26 2025-06-18 2025-11-25 2026-07-28 HANDSHAKE_PROTOCOL_VERSIONS: 4 MODERN: 1
The installed SDK speaks all five revisions: four handshake-era ones (blue) and one modern one (green).

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.

2025-11-25 AND EARLIER Client Mcp-Session-Id Instance 1 Instance 2 session lives here knows no session
Before: initialize created a session on one instance, so every later request had to go back there (red: the request that fails elsewhere).
2026-07-28 Every request version, identity capabilities Instance 1 Instance 2 either answers
After: each request carries its version, identity and capabilities in _meta, so any instance can answer it (green: works).

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).

TRANSPORT: 2024-11-05 TO 2025-03-26 HTTP+SSE POST endpoint plus held-open SSE stream Streamable HTTP one endpoint, /mcp JSON or a stream deprecated current
2025-03-26 replaced two-part HTTP+SSE (grey, now deprecated) with Streamable HTTP's single endpoint.
AUTHORIZATION: 2025-06-18 Auth server issues tokens Client presents token MCP server checks token only token bound to one server (RFC 8707)
2025-06-18 made the MCP server an OAuth resource server: it checks tokens a separate authorization server issues, each bound to one server.

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.

MULTI ROUND-TRIP REQUEST Client Server tools/call input_required: ask Nare tools/call + answers complete
Multi Round-Trip Requests: the server never calls the client; it answers input_required (amber, waiting on a person) and the client retries with the answers.

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.

THE WIRETAP Client nare-host wiretap.py Server notes_server.py wire-auto.log
The wiretap sits between client and server, passes every line on unchanged, and copies it into a log file (grey).

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.

python
%%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.Popen starts the real server with pipes for its input and output.
  • pump copies 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.
python
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_nare is an elicitation callback. Passing it makes the client advertise elicitation, so the capture shows a client feature on the wire.
  • capture launches the server through the wiretap, runs one action, and returns the version spoken and every logged message.
  • mode="auto", the default, probes with server/discover and falls back to the handshake for older servers; mode="legacy" always shakes hands. The output: auto spoke 2026-07-28 in 6 messages, legacy spoke 2025-11-25 in 7.
mode=auto (2026-07-28) mode=legacy (2025-11-25) server/discover tools/call tools/list initialize initialized tools/call tools/list 6 messages 7 messages
The captured search: the modern client probes once with server/discover (green); the legacy one shakes hands first (grey); both then call the tool and list tools.

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.

python
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

  • first finds the first client message with a given method in a capture.
  • The modern tools/call has a _meta with three keys: protocol version, client info and client capabilities (elicitation, form and URL). The legacy one has only name and arguments, because it said those things once, in initialize.
  • size measures the compact JSON: 336 bytes modern, 113 legacy, the price of every request standing alone.
THE SAME TOOLS/CALL, TWO ERAS (CAPTURED) modern: name, arguments, _meta (336 bytes) protocolVersion: 2026-07-28 clientInfo: nare-host 0.1.0 clientCapabilities: elicitation legacy: name, arguments (113 bytes)
The modern tools/call carries the client's version, identity and capabilities on every request (336 bytes); the legacy one said them once, in initialize (113 bytes).
CLIENT ERA BY SERVER ERA modern server legacy server modern client works fails dual-era client works works legacy client fails works a dual-era server, like MCPServer, also answers legacy clients
The specification's compatibility matrix: single-era pairs that do not match fail (red); a dual-era client works with both (green), and a dual-era server also answers legacy clients.

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.

python
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

  • menus is host logic: show a menu for each primitive the server advertised.
  • With auto it shows all three. With a bare pin, server_capabilities is empty and server_info is None, so the host shows Nare nothing, yet the tool call still returns lab-sync-2026-09-02.
  • saved keeps the discovery result; handing it back as prior_discover restores all three menus with no extra round trip.
MENUS THE HOST SHOWS (REAL RUN) auto tools, resources, prompts pinned none pinned + saved tools, resources, prompts the tool answered in all three: lab-sync-2026-09-02
Pinning a version skips discovery, so the host sees no capabilities and hides every menu (red) although the tool still works; handing back the saved discovery result fixes it.
python
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.

A SEP'S LIFE Draft In-Review Accepted Final or: Rejected, Withdrawn, Superseded, Dormant SEP-2575: created 2025-06-18, shipped 2026-07-28
A SEP moves from Draft through review to Final (green) once implemented; SEP-2575 took just over a year from creation to release.

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.

THE DEPRECATION WINDOW 2026-07-28 deprecated 2026-10-13 today: 288 days left 2027-07-28 earliest removal Roots, Sampling, Logging, Dynamic Client Registration
Roots, Sampling, Logging and Dynamic Client Registration were deprecated on 2026-07-28 (grey) and may be removed (red) from the first revision on or after 2027-07-28.

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.

python
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_WITH records what the course ran on; a different SDK or revision prints a warning.
  • DEPRECATED and EARLIEST_REMOVAL copy the registry. The output flags sampling, 288 days before its earliest removal, with its migration.
ONCE A MONTH Changelog what changed Registry what is going SDK notes what you call Roadmap what is next keep_up() passes
A monthly routine: read the changelog, the deprecated registry, your SDK's release notes and the roadmap, then rerun the keep_up check (green when nothing needs action).

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} and summarise_topic.
  • A rule for choosing a primitive: who should decide.
  • A wiretap that records real messages in both protocol eras.
  • A keep_up check against the SDK version and the deprecated features registry.

Check yourself

  1. 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.

  1. Why did 2026-07-28 remove the initialize handshake?

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.

  1. The captured modern tools/call was 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.

  1. 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.

python
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

  • read is the action: one resource read. capture runs it through the wiretap in each mode and keeps the messages.
python
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/read each client sent and asserts _meta is present only in auto. It printed server/discover, then the read, for auto, and initialize, notifications/initialized, then the read, for legacy.

Key terms

TermMeaning
PrimitiveOne kind of thing MCP carries: tool, resource or prompt from servers; elicitation from clients
Resource templateA URI pattern such as notes://{note_id} standing for many resources
ElicitationA server asking the user, through the client, for a missing detail
Control hierarchyPrompts user-controlled, resources application-controlled, tools model-controlled
Protocol versionThe date string naming a revision, such as 2026-07-28
server/discoverThe 2026-07-28 request that returns a server's versions and capabilities
Multi Round-Trip RequestThe server answers input_required; the client retries with answers
Dual-eraA client or server that speaks both modern and legacy revisions
SEPSpecification 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/