Optional and evolving features
- Lesson 22
- 45 min
- Module 7: Operate and maintain the integration
Lesson 22 of 22 · MCP: Connect AI Applications to Tools and DataOptional and evolving features
1. Understand the integration problem
3. Build and inspect a local server
4. Connect an AI application
5. Remote access and authorization
6. Protect tools and user control
7. Operate and maintain the integration
What you will be able to do
Know what else MCP offers, and what it no longer offers, before you need it. You sort the features into core, optional, deprecated and legacy, with the SDK’s own method maps as evidence. You watch elicitation and progress on the wire in both protocol eras. Then you finish the module with your own compatibility checklist.
Before you startWhat to know and to have ready
What you need to know
- Discovery and capabilities, from JSON-RPC messages.
- The two protocol eras, and how to read an old tutorial, from Version awareness.
- Approval outside the AI application, from Approval for effects.
Your project
~/projects/support-mcp, with the virtual environment active:python -m pytest -qprints94 passed. This lesson adds no file to the project.- For the module practice:
harbour_server.pyfrom Inspect behaviour, in the project folder.
Extras on a car
Section titled “Extras on a car”Every car has wheels, brakes and a steering wheel. Some cars also have heated seats or a parking camera. These extras are useful, but you cannot count on them: a rental car may not have one. And some parts are gone. An old car has a cassette player. You can recognize it, but you would not design a new car around it.
A careful driver checks the extras before a long trip. A careful developer checks the optional features before using one, and labels the old ones as old.
The problem: a feature that the other side does not have
Section titled “The problem: a feature that the other side does not have”Omar has an idea for the partner app. When the assistant drafts an apology, it should ask the agent one question first: “formal or friendly?” MCP has a feature for this, elicitation: a server asks the person for a piece of information in the middle of a call.
The course team built a small constructed demo server for this lesson, feature_server.py. It is not part of support-mcp. Its tool draft_apology asks for the tone. Then they called it from a client that did not declare elicitation. That is the normal case: the course’s own host does not declare it either.
draft_apology -> MCPError -32021: Client did not declare the form elicitation capability required by resolver '__main__:ask_tone' data={'requiredCapabilities': {'elicitation': {'form': {}}}}The call failed before the tool ran, in both eras, with error −32021. The data names exactly what was missing. A feature that only one side has is not a feature.
There is a second trap. Tomás finds a tutorial for elicitation. It shows the server sending an elicitation/create request to the client. In revision 2026-07-28, as SDK 2.3.0 implements it, a server sends no requests to the client at all. The feature still exists, but its messages changed.
The idea: core, optional, deprecated, legacy
Section titled “The idea: core, optional, deprecated, legacy”Sort every feature into one of four groups:
- Core: every client and server uses it. Tools, resources, prompts, discovery and cancellation.
- Optional: one side offers it in its capabilities, and the other side may use it. If a capability is missing, do not use the feature. Elicitation, progress, change notifications, completion and long-running tasks.
- Deprecated: still there, but marked for removal. Do not start using it.
- Legacy: from an older revision. Recognize it in old code, and never build on it.
Where do capabilities come from? A client declares them in _meta on every request in revision 2026-07-28, under io.modelcontextprotocol/clientCapabilities, or once in initialize in the older era. A server declares them in its answer to server/discover, or to initialize.
What is in each revision? The official SDK keeps a map of every method for every revision, in mcp_types.methods. The course team printed it for SDK 2.3.0 and compared 2025-11-25 with 2026-07-28. Here are some of the lines that changed; the printout lists every method:
## client -> server request (2025-11-25 | 2026-07-28)initialize yes | - <- removed in 2026-07-28logging/setLevel yes | - <- removed in 2026-07-28resources/subscribe yes | - <- removed in 2026-07-28server/discover - | yes <- new in 2026-07-28subscriptions/listen - | yes <- new in 2026-07-28…
## server -> client request (2025-11-25 | 2026-07-28)elicitation/create yes | - <- removed in 2026-07-28roots/list yes | - <- removed in 2026-07-28sampling/createMessage yes | - <- removed in 2026-07-28The same printout lists the SDK’s warnings. Three features are “deprecated as of 2026-07-28 (SEP-2577)”: sampling, roots and logging. The specification’s own list of deprecated features names the same three, and also dynamic client registration and the old HTTP+SSE transport. The SDK also warns that progress from the client to the server is deprecated: in revision 2026-07-28, only the server sends progress.
Text description of the diagram
| Feature | 2025-11-25 | 2026-07-28 | support-mcp | Status |
|---|---|---|---|---|
| Tools, resources, prompts | yes | yes | uses all three | core |
| Cancellation | notification | notification | the host’s time limit | core |
| Progress | notification | notification, server to client only | not used | optional |
| Elicitation | elicitation/create request |
input_required result, then a retry |
not used | optional |
| List changes, resource updates | list_changed, resources/subscribe |
subscriptions/listen stream |
not used | optional |
| Completion | completion/complete |
completion/complete |
not used | optional |
| Long-running tasks | experimental, in the core | extension io.modelcontextprotocol/tasks |
not used | optional |
| Sampling, roots, logging | requests, notifications | deprecated (SEP-2577) | not used | deprecated |
initialize handshake, session |
yes | removed | accepted from older clients | legacy |
| HTTP+SSE transport | deprecated since 2025-03-26 | deprecated | not offered | legacy |
The SDK is evidence of what this course runs. It is not the specification itself. Before you rely on a feature, read its page in the specification for your revision.
Elicitation in two eras
Section titled “Elicitation in two eras”Here is the demo tool. A resolver fills the parameter choice by asking the person. The model never chooses the tone:
class ToneChoice(BaseModel): tone: Literal["formal", "friendly"] = Field(description="The tone of the apology")
def ask_tone(ticket_id: str) -> Elicit[ToneChoice]: """The person chooses the tone; the model does not.""" return Elicit(f"Which tone for the apology on {ticket_id}?", ToneChoice)
@mcp.tool()def draft_apology(ticket_id: str, choice: Annotated[ToneChoice, Resolve(ask_tone)]) -> str: """Draft a short apology for a ticket, in the tone the person chooses.""" if choice.tone == "formal": return f"Dear customer, we apologise for the trouble with {ticket_id}." return f"Hi! Sorry about the trouble with {ticket_id}, we are on it."The client gives the SDK an elicitation_callback. In a real host, the callback shows a small form to the person. In the demo, it answers “formal”, as a person would. With the callback, the client declares the capability by itself. The course team ran the same call in both eras through scripts/tee.py:
261.2 ms connected: protocol 2026-07-28263.2 ms elicitation: 'Which tone for the apology on T-30002?' … -> the person picks: formal279.9 ms draft_apology -> isError=False: Dear customer, we apologise for the trouble with T-30002.The answer was the same in both eras. The messages were not:
Text description of the diagram
- Legacy handshake, 2025-11-25: the client sends
tools/call. While the call is open, the server sends its own request,elicitation/create, with the question and a JSON schema. The client answers{"action": "accept", "content": {"tone": "formal"}}. Then the call’s result arrives. - Current, 2026-07-28: the client sends
tools/call. The server answers at once with a result of typeinput_required: the question ininputRequests, and a sealedrequestState. The client asks the person and sends the sametools/callagain, withinputResponsesand therequestState. Then the final result arrives.
Why the change? In revision 2026-07-28, there is no session, and every request stands alone. A server that asks a question mid-call needs an open line back to the client. A result that says “I need more input, call me again” needs nothing more. The server keeps no memory between the two calls: the sealed requestState carries it.
When would support-mcp use elicitation? For a missing detail that only the person knows, such as the tone. Never for a secret: the form passes through the client. And never as the approval of a write: Approval for effects keeps that outside the AI application, with a different person.
Progress and long-running work
Section titled “Progress and long-running work”The demo’s second tool, rebuild_index, works for 0.6 seconds in three steps, and reports each step. The client passed a progress_callback; the SDK then adds a progressToken to the request:
483.6 ms progress: 1/3 step 1 of 3684.6 ms progress: 2/3 step 2 of 3886.8 ms progress: 3/3 step 3 of 3888.8 ms rebuild_index -> index rebuilt: 3 stepsOn the wire, each step is a notifications/progress from the server, about 200 ms apart, with "progressToken": 5, "progress": 1, "total": 3. It worked the same way in both eras. Progress tells the person that something is happening. It does not stop the work: for that, the client sends notifications/cancelled, as Results and failures showed.
For work that takes minutes or hours, there are tasks: the client starts the work, goes away, and asks for the result later. Inspector 2.10.1 declares the extension io.modelcontextprotocol/tasks in its capabilities; you can see it in its recorded initialize. In revision 2025-11-25, tasks were an experimental part of the core. In 2026-07-28 they moved to an official extension, so SDK 2.3.0 keeps them outside the core method map. support-mcp answers in milliseconds, so it needs neither progress nor tasks. The course did not test tasks.
Change notifications
Section titled “Change notifications”In Contract and compatibility tests, Inspector in the current era sent subscriptions/listen right after discovery. It asked for three kinds of notification: tools, resources and prompts changed. The server answered with notifications/subscriptions/acknowledged, and kept the stream open until Inspector left.
The same server declares different capabilities in the two eras. To initialize, SDK 2.3.0 answered "listChanged": false for tools, resources and prompts. To server/discover, it answered "listChanged": true, and "subscribe": true for resources. In the current era, the SDK runs the listen stream for every server. But support-mcp never publishes a change: its tools are fixed. A client that waits for a notification from it waits forever. Read a capability as “may happen”, not as “will happen”.
The current era also adds caching hints. Lists and discovery carry ttlMs and cacheScope: how long a client may keep the answer, and whether it may share the answer between users. support-mcp sends "ttlMs": 0 and "cacheScope": "private" from discovery. “Private” fits this server: its resource list depends on the caller.
Legacy and deprecated: label them
Section titled “Legacy and deprecated: label them”When you read old code or an old tutorial, label each of these, and do not copy it:
- The
initializehandshake and sessions (revision 2025-11-25 and older): legacy. SDK 2.3.0 still accepts them from older clients, as the older client in Contract and compatibility tests showed. - The HTTP+SSE transport (revision 2024-11-05, endpoints
/sseand/messages): legacy. The specification has marked it deprecated since 2025-03-26.support-mcpdoes not offer it. - SDK 1.x names, such as
FastMCP,McpErrorand camelCase attributes: legacy code. SDK 2.x usesMCPServer,MCPErrorand snake_case. - Server requests
elicitation/create,sampling/createMessageandroots/list: legacy messages. Elicitation lives on in the current era throughinput_required. - Sampling, roots and the logging capability: deprecated as of 2026-07-28 in SDK 2.3.0. The server’s own logs, from Resource limits and observability, go to stderr and never used the logging capability.
When to use an optional feature, and what it costs
Section titled “When to use an optional feature, and what it costs”Use an optional feature only when three things are true. The feature solves a real problem of your users. Every client in your matrix declares it, or you have a fallback for the ones that do not. And you have tested it in each era that you support. Then add a row to the compatibility checklist for it.
Each optional feature costs a test per era, and a fallback for clients without it. A deprecated feature costs more: you build on something that is going away. Labelling costs nothing, and saves the next developer a day.
Common mistakes
Section titled “Common mistakes”Common mistake
A tool fails with error -32021 before it runs
Symptom: the call fails with −32021, “Client did not declare the form elicitation capability …”, and data.requiredCapabilities.
Cause: the tool needs an optional feature, and the client did not declare it.
Fix: give the client the callback, such as elicitation_callback, if your host can show a form. Otherwise, design the tool without the feature: for example, make the tone a normal argument with a default.
Common mistake
An example from a tutorial sends elicitation/create, and nothing arrives
Symptom: you copied code that sends a request from the server to the client, and a current-era client never answers it.
Cause: the example is for revision 2025-11-25 or older. In 2026-07-28, SDK 2.3.0 sends no requests to the client.
Fix: use the SDK’s high-level way, such as Resolve with Elicit. It picks the right messages for each era by itself.
Module practice: a compatibility checklist for Harbour Cycles
Section titled “Module practice: a compatibility checklist for Harbour Cycles”Guided example. This module’s worked example is docs/compatibility.md with python -m scripts.compat. You recorded the server’s contract and pinned its error shapes. You built a matrix of clients, transports and eras, with every era named. You listed the limits and gave every refusal a log line. And you sorted the optional features.
Your turn. Deliver your Harbour Cycles server from Inspect behaviour with inspection evidence and a versioned compatibility checklist:
- Write
harbour_compat.py. It records the server’s tools and resources inharbour_contract.json, and it compares them on every run, asscripts/contract.pydoes. - In the same script, connect the SDK’s
Clientover stdio in both eras,autoandlegacy. Check discovery, a validget_bike, a validation failure ("104"), a missing bike (B-999) and the resource. - Run Inspector 2.10.1 in both eras:
tools/list, a valid call, the validation failure andresources/read. - Write
harbour-compatibility.md: the pinned versions, one row per client and era, and what is optional or not tested.
Show a worked solution
"""Harbour Cycles: the contract and the compatibility matrix of harbour_server.py."""
import difflibimport jsonimport sysfrom importlib.metadata import versionfrom pathlib import Path
import anyiofrom mcp import Client, StdioServerParameters
import harbour_server
CONTRACT = Path("harbour_contract.json")ERA = {"auto": "current", "legacy": "legacy handshake"}
async def contract() -> str: async with Client(harbour_server.mcp) as c: data = { "tools": [t.model_dump(mode="json", by_alias=True, exclude_none=True) for t in (await c.list_tools()).tools], "resources": [r.model_dump(mode="json", by_alias=True, exclude_none=True) for r in (await c.list_resources()).resources], } return json.dumps(data, indent=2, sort_keys=True) + "\n"
async def row(mode: str) -> dict: params = StdioServerParameters(command=sys.executable, args=["harbour_server.py"]) async with Client(params, mode=mode) as c: out = {"era": f"{ERA[mode]} ({c.protocol_version})"} out["discovery"] = {t.name for t in (await c.list_tools()).tools} == {"get_bike", "list_available"} out["valid_call"] = not (await c.call_tool("get_bike", {"bike_id": "B-104"})).is_error out["validation_failure"] = (await c.call_tool("get_bike", {"bike_id": "104"})).is_error out["not_found"] = (await c.call_tool("get_bike", {"bike_id": "B-999"})).is_error rr = await c.read_resource("terms://harbour/rental-terms") out["resource"] = rr.contents[0].text.startswith("# Rental terms") return out
def main(argv: list[str]) -> int: text = anyio.run(contract) if "--write" in argv: CONTRACT.write_text(text, encoding="utf-8") print(f"Wrote {CONTRACT}") return 0 old = CONTRACT.read_text(encoding="utf-8") if CONTRACT.exists() else "" if old != text: sys.stdout.writelines(difflib.unified_diff(old.splitlines(True), text.splitlines(True), "contract", "server")) return 1 print(f"harbour-bikes matches {CONTRACT}.") print(f"MCP Python SDK {version('mcp')} Client, stdio") checks = ["discovery", "valid_call", "validation_failure", "not_found", "resource"] print(" | ".join(["era", *checks])) ok = True for mode in ("auto", "legacy"): r = anyio.run(row, mode) ok = ok and all(r[c] for c in checks) print(" | ".join([r["era"], *("pass" if r[c] else "FAIL" for c in checks)])) return 0 if ok else 1
if __name__ == "__main__": sys.exit(main(sys.argv[1:]))The course team’s runs, from the project folder with harbour_server.py next to the script. The first run prints the whole contract as new lines, and exits with 1. Then:
$ python harbour_compat.py --writeWrote harbour_contract.json
$ python harbour_compat.py 2>/dev/nullharbour-bikes matches harbour_contract.json.MCP Python SDK 2.3.0 Client, stdioera | discovery | valid_call | validation_failure | not_found | resourcecurrent (2026-07-28) | pass | pass | pass | pass | passlegacy handshake (2025-11-25) | pass | pass | pass | pass | passInspector, in both eras (--protocol-era modern after the -- for the second):
npx @modelcontextprotocol/inspector@2.10.1 --cli python harbour_server.py -- --method tools/call --tool-name get_bike --tool-arg bike_id=B-104Both eras listed get_bike and list_available, and the valid call and the resource read exited with 0. The validation failure exited with 5, tool_is_error. The current-era output started with "_meta" and "name": "harbour-bikes".
# Harbour Cycles: compatibility checklist
Pinned: MCP specification revision 2026-07-28, MCP Python SDK 2.3.0, Python 3.12. Server `harbour-bikes` 0.1.0 (stdio only).Contract: `harbour_contract.json` (2 tools, 1 resource). Check it after every change: `python harbour_compat.py`.
| Client | Transport | Era (revision) | Discovery | Valid call | Validation failure | Not found | Resource | How tested ||---|---|---|---|---|---|---|---|---|| MCP Python SDK 2.3.0 `Client` (`auto`) | stdio | current (2026-07-28) | pass | pass | pass | pass | pass | `harbour_compat.py` || MCP Python SDK 2.3.0 `Client` (`legacy`) | stdio | legacy handshake (2025-11-25) | pass | pass | pass | pass | pass | `harbour_compat.py` || MCP Inspector 2.10.1 CLI (default) | stdio | legacy handshake (2025-11-25) | pass | pass | pass (exit 5) | — | pass | by hand || MCP Inspector 2.10.1 CLI (`--protocol-era modern`) | stdio | current (2026-07-28) | pass | pass | pass (exit 5) | — | pass | by hand || Older SDKs, desktop hosts, editors | — | — | not tested | | | | | — |
Optional features: none used (no elicitation, progress, subscriptions or completion). Legacy: none offered beyond the handshake that SDK 2.3.0 accepts.Your solution can differ. Check four things. The contract is a file that a test or a script compares on every run. Every row names its era. The validation failure counts as a pass when the server refuses. And the checklist says what was not tested, instead of leaving it out.
Knowledge check
Section titled “Knowledge check”Check your understanding
Predict: the course's host, which passes no elicitation callback, calls draft_apology on the demo server in the current era. What happens, and how would you change the tool so that every client in your matrix can use it?
Show the answer
The call fails before the tool runs, with error −32021, “Client did not declare the form elicitation capability required by resolver ‘main:ask_tone’”. The error’s data names the missing capability: {"requiredCapabilities": {"elicitation": {"form": {}}}}. The same happens in the legacy era. To serve every client, make the tone a normal argument with a default, such as tone: Literal["formal", "friendly"] = "formal". Or offer elicitation only to clients that declare it, and test both paths in each era.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Label each as core, optional, deprecated or legacy for revision 2026-07-28 with SDK 2.3.0: (a) notifications/progress from the server, (b) the initialize handshake, (c) the sampling capability, (d) tools/call, (e) the /sse endpoint of an old tutorial.
Show the answer
(a) Optional: a server may report progress when the client sends a progressToken. Client-to-server progress is deprecated. (b) Legacy: removed in 2026-07-28, still accepted from older clients by SDK 2.3.0. (c) Deprecated: the SDK warns “deprecated as of 2026-07-28 (SEP-2577)”. (d) Core. (e) Legacy: the HTTP+SSE transport of revision 2024-11-05. Recognize it, and do not build on it.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Module completion check: deliver the server, inspection evidence and a versioned compatibility checklist. Explain which capabilities were tested with which client and what remains optional.
Show the answer
A complete answer has evidence from your own runs for each part.
- The server and its contract.
python -m pytest -qprints94 passed(or your Harbour script passes).python -m scripts.contractprintsThe server matches contract/capabilities.json., and the error-shape test passes. - Inspection evidence. Inspector 2.10.1 in both eras: the default (legacy handshake, 2025-11-25) and
--protocol-era modern(2026-07-28). In each:tools/list, a valid call, a validation failure (exit 5), a resource read and the prompt. The current-era output starts with_meta. - The checklist. One row per client, transport and era, and each row names its era. The rows: SDK 2.3.0 in
autoandlegacyover in-process, stdio and HTTP; Inspector in both eras; the host. Desktop hosts and the three older handshake revisions are marked “not tested”. - Tested capabilities, per client. Tools (discovery, a valid call, a validation failure), resources (a read) and the prompt, with every client in the matrix. Over HTTP, also the refused request without a token.
- What remains optional. Elicitation, progress, change notifications, completion and tasks: not used by the server and not in the matrix. Sampling, roots and logging: deprecated, not used. The handshake: legacy, accepted.
Demonstrated needs all five, with your own outputs, and every era named. A matrix without eras, or a checklist without its “not tested” rows, is partially demonstrated.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- Sort every feature: core, optional (check the capability first), deprecated (do not start) and legacy (recognize only).
- Elicitation asks the person mid-call. In 2025-11-25 the server sends
elicitation/create; in 2026-07-28 the call returnsinput_required, and the client calls again. A client without the capability gets −32021. - Progress is a server notification; tasks are an extension; change notifications use
subscriptions/listenin the current era. A capability means “may happen”. - Sampling, roots and the logging capability are deprecated in SDK 2.3.0 for revision 2026-07-28.
Module recap. You pinned the server’s promise in a contract file and its error shapes in a test. You tested the server with each client in each era, an older SDK 1.30.0 client included, and named the era in every row. You listed the limits, gave every refusal one log line without secrets, followed one request on the wire, and measured a local call. Finally, you sorted the optional and evolving features. Your project equals the snapshot end-of-m07: 94 passed.
Next come the case studies: you build an MCP server over a real open dataset, or you review a weak one and fix it.