Index Ai Academy
Phase 9 · Model Context Protocol · Protocol foundations

Discovery, capabilities and the two eras

server/discover, declared capabilities, and talking to legacy and modern software alike.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. The sign on the door: server/discover
  2. Capabilities: say what you can do
  3. A guard before a feature is used
  4. Living with two eras
  5. 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.

Try it now

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.

Quick check
A host reads an instructions string from a server's discover result. What should it do with it?
  1. Ignore it, since it's only meant for logs
  2. Show it to the user as an error message
  3. Pass it to the model as guidance on using this server's tools
  4. Use it to pick which protocol version to send
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, resources and prompts. Each may add details, such as listChanged, 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 like io.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.

Worked example

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")
It runs right here in your browser, with nothing to install.

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.

Quick check
A server's tool needs to ask the user a question mid-call, but the request's clientCapabilities is {}. What should the server do?
  1. Close the connection
  2. Ask anyway and hope the client supports it
  3. Silently skip the question and guess
  4. Return MissingRequiredClientCapability (-32021), naming elicitation as required
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.

Worked example

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))
In the lesson you can change this and run it.

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.

Quick check
The same decision, on two replies. What does this print?
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}}))
  1. legacy modern
  2. modern legacy
  3. legacy legacy
  4. modern modern
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

Create a free account

Try a quiz question

A dual-era client on stdio sends server/discover and gets a "Method not found" error. What should it conclude?

  1. The server is legacy, so fall back to the initialize handshake
  2. The server is broken, because every server has to answer that method
  3. Retry server/discover with another version, starting from the oldest it knows
  4. The server is modern but has no tools, so there's nothing it can discover

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson