Discovery, capabilities and the two eras
server/discover, declared capabilities, and talking to legacy and modern software alike.
Create a free account Try part of it here
Why it matters
The last lesson made every MCP request stand on its own. That raises two questions. How does a client find out what a server can do before it starts asking? And how do new clients keep talking to the many servers that still open with a handshake? MCP answers the first with a method called server/discover and a vocabulary of capabilities, and the second with dual-era software that speaks both styles. You'll meet both in the wild, and the capstone server you build later in this phase has to get them right.
What you'll be able to do
- Implement server/discover and say what a host does with its result
- Declare capabilities on both sides, and refuse a request that needs one the client didn't declare
- Tell a legacy server from a modern one, and fall back to the initialize handshake
- Handle MissingRequiredClientCapability and name extensions
What it covers
- The sign on the door: server/discover
- Capabilities: say what you can do
- A guard before a feature is used
- Living with two eras
- A client works out which era a server is from
The sign on the door: server/discover
Walk up to a shop and the sign on the door tells you the hours, what it sells and whether it takes cards. You know before you go in.
Every modern server must implement server/discover. It's that sign. It returns:
supportedVersions: the revisions it speaks, newest first.capabilities: the optional features it offers (more on these below).serverInfo: its name and version.instructions(optional): plain-language guidance a host can pass to the model about how to use the server. "Use confirm_order only after the customer has seen the total" is the kind of thing that goes here.ttlMs(optional): how long the answer may be cached, in milliseconds.
Calling it first is optional. A client can send any request straight away and handle a version error if one comes back. But discover is a cheap way to show "what can this server do?" in one call, and a host can cache the result for as long as ttlMs allows. Like any request, discover itself carries _meta with a version and capabilities. If the server doesn't speak that version, it says so with -32022, the same error as for any other method.
In plain words: discover is one call that returns everything a client needs to decide how to talk to this server.
The practice server is dual-era. Send server/discover and read the versions, capabilities and instructions it returns. An unsupported version gets a 400 listing the supported ones, and the legacy initialize still works. The headers are covered in the Streamable HTTP lesson.
instructions string from a server's discover result. What should it do with it?Show the answer
Pass it to the model as guidance on using this server's tools
instructions is the server's advice to the model, such as when a tool should or shouldn't be called. Versions come from supportedVersions, and the user never needs to see it.
Capabilities: say what you can do
Capabilities are how each side says which optional features it supports. It's like telling a caller that your phone can't receive photos, so they don't send one.
- Servers list
tools,resourcesandprompts. Each may add details, such aslistChanged, which means the server can announce when its list changes. - Clients list features such as
elicitation, which means the server may ask the user a question mid-call. - Both may list
extensions: optional add-ons named likeio.modelcontextprotocol/tasks.
Because every request carries the client's capabilities, a server knows on each call what it may do. The rule is strict. A server must never rely on a capability the client didn't declare. If a request can't be done without one, the server answers MissingRequiredClientCapability, error code -32021, saying which capabilities it needed:
{"jsonrpc": "2.0", "id": 5, "error": {"code": -32021, "message": "confirm_order needs elicitation",
"data": {"requiredCapabilities": {"elicitation": {}}}}}
The client can then decide: add the capability if it has it, or tell the user that this tool needs a feature their app lacks. An empty clientCapabilities of {} is valid. It means "only the basics", and it keeps simple scripts simple.
In plain words: each side declares what it can do, and nobody uses a feature the other side didn't declare.
A guard before a feature is used
A server checks the request's declared capabilities before it does anything that needs one. The same tool is refused for a plain client and runs for a richer one.
PV = "io.modelcontextprotocol/protocolVersion" CAPS = "io.modelcontextprotocol/clientCapabilities" def needs(meta, *names): """The capabilities a request lacks, so the server can refuse before it starts.""" declared = meta.get(CAPS, {}) return [n for n in names if n not in declared] plain = {PV: "2026-07-28", CAPS: {}} rich = {PV: "2026-07-28", CAPS: {"elicitation": {}, "extensions": {"io.modelcontextprotocol/tasks": {}}}} for label, meta in [("plain client", plain), ("rich client", rich)]: missing = needs(meta, "elicitation") if missing: print(label, "-> error -32021, requiredCapabilities:", {m: {} for m in missing}) else: print(label, "-> ask the user, then confirm")
The check happens before the tool does any work, so a refused call changes nothing. The error's data names exactly what was missing (elicitation, for the plain client), so a client can fix its request instead of guessing.
{}. What should the server do?Show the answer
Return MissingRequiredClientCapability (-32021), naming elicitation as required
Servers may only use capabilities the client declared on that request. The error tells the client exactly what's missing.
Living with two eras
New software must still talk to old software. Think of a bilingual receptionist who answers each caller in whichever language they open with. The spec defines dual-era clients and servers that speak both styles.
A dual-era server looks at how each request opens. An initialize request gets the legacy handshake. A request carrying modern _meta is served statelessly. The same server does both.
A dual-era client has to find out which kind of server it's facing. On stdio, it sends server/discover first. A proper discover result means the server is modern. So does a modern error such as UnsupportedProtocolVersion, because only a modern server knows that code. Any other error, or no answer at all, means the server is legacy, so the client falls back to initialize. Over HTTP it tries a modern request and reads the body of a 400 the same way.
The answer is a property of the server, not of the moment, so clients cache it instead of probing every time. A client asks again only when its cached discover result's ttlMs runs out, or when a call that used to work starts returning a version error, which means the server was upgraded underneath it.
A modern-only client can't use a legacy server, and a legacy-only client can't use a modern-only server. That's why many SDKs and hosts stay dual-era during the transition, and why the spec now promises at least twelve months' notice before removing a deprecated feature.
In plain words: a dual-era client asks "discover?" once. A real answer or a modern error means modern; anything else means legacy.
A client works out which era a server is from
A dual-era client sends server/discover and decides from the reply. Four replies, four decisions.
MODERN_ERRORS = {-32022, -32021} # only a modern server knows these codes
def era(reply):
"""Decide from a server's reply to server/discover."""
if reply is None:
return "legacy" # no answer at all
if "result" in reply:
return "modern"
code = reply["error"]["code"]
return "modern" if code in MODERN_ERRORS else "legacy"
print(era({"result": {"supportedVersions": ["2026-07-28"]}}))
print(era({"error": {"code": -32022, "message": "Unsupported protocol version"}}))
print(era({"error": {"code": -32601, "message": "Method not found"}}))
print(era(None))A result is modern, and so is a version error, because the server understood the question. Method not found and silence both mean the server never learned discover, so the client should send initialize next. The decision is cached per server.
def era(reply): if "result" in reply: return "modern" return "modern" if reply["error"]["code"] in (-32022, -32021) else "legacy" print(era({"error": {"code": -32601}}), era({"error": {"code": -32021}}))
Show the answer
legacy modern
-32601 (Method not found) isn't a modern code, so the first is legacy. -32021 (MissingRequiredClientCapability) is one, so the second server is modern even though it refused.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
A dual-era client on stdio sends server/discover and gets a "Method not found" error. What should it conclude?