Chapter 3 of 7 · 11 min read
The elicitation capability check
On this page
Capability negotiation is the opening exchange in which each side declares what it is able to do, and it
happens once, at connection, before any tool is listed or called. What a client sends in that exchange is not
a preference or a hint. A client that omits elicitation from its capabilities has stated that it does not
support elicitation and will not be answering the request, and the set is fixed for the session, so nothing
later in the connection can change the answer.
That makes the check unusually cheap, and it makes skipping it unusually expensive. The failure mode is not a clean error at the point of the mistake; it is an operation that stops in the middle, holding whatever it was holding, with no response ever arriving.
Silence is a legal response to a request nobody agreed to accept
Here is the client half of a handshake, the second message of a session:
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {
"roots": { "listChanged": true },
"sampling": {}
},
"clientInfo": { "name": "orchestrator-runtime", "version": "2.9.1" }
}
}That client declared roots, the filesystem locations it is willing to expose to a server, and sampling,
its willingness to run model completions on a server's behalf. It said nothing about elicitation. A server
holding that response has a complete and permanent answer to the question of whether it may ask this client
anything.
The reason a violation presents as a hang rather than an error is worth being precise about, because the
wrong mental model here produces a fix that does not work. JSON-RPC, the request-and-response envelope every
MCP message travels in, has no timeout of its own. A request is a message with an id, and the caller waits
for a response carrying that id, indefinitely, unless something in the transport or the application layer
imposes a deadline. When a server sends elicitation/create to a client that has no handler registered for
that method, three things can happen and only one of them is an error you can catch.
A well-behaved implementation returns -32601 Method not found, and against that client the strategy of
sending first and catching the error does work. An SDK that logs an unknown method at debug level and drops
it produces exactly the silence in the diagram. A transport that terminates the connection on a protocol
violation gives you a disconnect, which surfaces as a different exception in a different place and is easy to
misattribute to the network. Building your fallback on the assumption that the error path will be taken means
your fallback is exercised on the clients that already behaved well and never runs on the ones that did not.
There is a second reason not to probe, independent of what comes back. Capability negotiation is a contract,
and a server that sends a request outside the negotiated set is misbehaving whether or not the client copes.
A client that declared only roots and sampling has told you what it will do, and the correct response to
that is to build an operation that does not need what it lacks.
One layer further down is where the variation between those three client behaviours comes from, and it is worth going that far and no further. A JSON-RPC message carries a method name and, when it is a request rather than a notification, an id. Nothing in the envelope states what a receiver must do with a method it does not recognise, so the behaviour is decided entirely by the receiving SDK's dispatch table.
One implementation looks the method up, misses, returns the method-not-found error and carries on. Another logs the miss at debug level and goes back to its read loop. A third treats an unnegotiated method as a protocol violation and closes the connection. All three are choices client authors are entitled to make, none of them is a bug, and nothing your server can send will tell it which of the three it is talking to.
The last two notes in the diagram decide how expensive getting this wrong is, and they are easy to read past. The framework that kills the run at six hundred seconds sits on the client's side of the connection, and killing a run sends nothing to the server. The server is still waiting for a response to id seven and will keep waiting for as long as its process lives, because what it is waiting for is a message nobody is going to send. Whatever the handler acquired before it asked the question — a transaction, an advisory lock, a reserved slot — is therefore not held for ten minutes. It is held until the process restarts, once per occurrence, which is why the quantity that matters is not how long a stalled call lasts but how many have happened since the last deploy.
The check is two lines, and it goes before the work
Every SDK exposes the negotiated client capabilities on the session. The shape differs; the position in the code does not.
from mcp.types import ClientCapabilities, ElicitationCapability
@mcp.tool(annotations={"destructiveHint": True, "readOnlyHint": False})
async def purge_index(index: str, ctx: Context) -> dict:
can_ask = ctx.session.check_client_capability(
ClientCapabilities(elicitation=ElicitationCapability())
)
if not can_ask:
raise ToolError(
f"Refusing to purge {index}: this client cannot prompt for a "
"retention choice, and purge_index requires one. Call "
"purge_index with retention_days set explicitly "
"(0 for a full purge, or a positive number of days to keep). "
"Index size: 2,400,118 documents."
)
...const caps = server.server.getClientCapabilities();
if (!caps?.elicitation) {
return {
isError: true,
content: [{ type: "text", text: FALLBACK_MESSAGE }],
};
}Note where the check sits: before the transaction, before the lock, before anything that has to be unwound. When an operation depends on a capability, testing for that capability is part of validating the request, alongside checking that the index exists and that the caller is allowed to touch it. A missing check does not cost you one failed call; it costs you a leaked resource per call, because the server committed something before discovering it could not finish.
Because the negotiated set is fixed for the session, the check is also cheap to
hoist. A server that elicits from several tools can read the capability once
when the session is established, store it on the session object alongside
whatever else it keeps there, and have every handler consult a boolean. Logging
it at that moment is worth the one line it costs, since
session_start client=orchestrator-runtime/2.9.1 elicitation=false in your own
logs turns a partner's report from an investigation into a lookup. It also gives
you the population statistic you will want later, which is what fraction of your
live sessions can be asked anything at all.
The same information can shape what you advertise. A server is free to vary its tool list by session, so a
workflow that only makes sense with a human answering questions can be omitted from tools/list for a client
that declared no elicitation, which stops the model from choosing a path that ends in a refusal. That is a
heavier design than a fallback error and it earns its place when the difference is large — a set of
interactive troubleshooting tools, say, that have nothing useful to do in an unattended run. For a single
tool with one prompt in it, the fallback error is the proportionate answer.
What the model sees, and why that is the whole fallback
The fallback is a tool result, which means its audience is the model rather than the user, and its job is to
give the model a way to finish the job without you. That imposes a shape. It has to name what is missing in
the vocabulary of the tool's own arguments, so the model knows what a corrected call looks like; it has to
state the legal values, so the model does not have to guess; and it should carry whatever context the user
would have needed to answer, so the model can hold the conversation in chat instead. The message above does
all three, and against a client with no elicitation the model's next move is either to call
purge_index(index="prod-catalog", retention_days=0) because the user already said "delete everything", or
to ask the user in the transcript, which is a question the user can answer on any client in the world.
That last point is the reason this fallback is not a consolation prize. A question asked through the conversation reaches every client, appears in the transcript where it can be audited, and does not require the user to context-switch into a modal. What elicitation buys over it is structure and the ability to pause mid-operation without losing state, and where you cannot have those, the conversation is a genuine substitute rather than a degraded one.
Which of the three mechanisms from the previous chapter you fall back to depends on which one you were using.
A value the user owns falls back to a precise error of the kind above. An approval never should have been an
elicitation, so on a client without the capability it is already handled by the destructiveHint on the tool
definition and there is nothing to fall back to. The case that has no fallback is the one where you were
about to ask a question that was neither — and finding that you have no answer for a client without
elicitation is a reliable way to discover that the question should not have existed.
Proceeding anyway is not on the table
A colleague will propose it, and the proposal is reasonable-sounding: the confirmation was only a safety net, the host already approved the call, and failing on clients that cannot elicit means the tool simply does not work for that partner. The problem is that "proceed without confirmation" silently changes the behaviour of the operation based on a property of the client, so the same tool call deletes 2.4 million documents on one client and asks first on another, and nothing in the tool's description, its annotations, or its result says so. Whatever safety the dialog was providing is now provided on some connections and not others, and the connections where it is absent are precisely the automated ones, where nobody is watching. If the operation is safe to run unconfirmed, it is safe on every client and the dialog was never needed; if it is not, the client's feature set is not the thing that should decide.
Stated as a rule: proceeding anyway is not a fallback, it is a different operation wearing the same name. A fallback preserves what the operation guarantees and changes how it gets there, which is what the error above does — it still refuses to purge without a retention choice, and it asks the model for one instead of the user. Dropping the question keeps the code path and discards the guarantee. The two are confusable only because both of them end with the tool returning something.
The behaviour on an unsupporting client is also not something you can reason about and skip testing, because
the failure it produces is invisible from the supporting side. The cheapest useful harness is a pair: one
client that declares elicitation in its initialize params and answers the prompt, and one that declares
nothing beyond roots. The MCP Inspector, the reference client the protocol ships for driving a server by
hand, covers the first. The second is twenty lines against the SDK's client class with the elicitation
handler left unregistered, and it is worth writing because it also reproduces the drop-and-log behaviour
rather than the polite -32601.
| Supporting client | Non-supporting client | |
|---|---|---|
| Capability in initialize | "elicitation": {} | absent |
| What the server sends | elicitation/create | nothing |
| What the tool returns | result after the answer | error naming retention_days and its legal values |
| Server state on exit | committed or rolled back | nothing acquired |
| Time to terminal state | user response time, bounded by your own timeout | under 50 ms |
The last row is the one to assert on in continuous integration, because it is the one partners report. A test that only checks the error text will pass on a build that also holds the lock, and a test that only checks the supporting path will pass on the build that shipped.
The whole of this chapter is one ordering claim: the capability is known before the work starts, so the check belongs before the work starts. Everything downstream follows from getting that ordering wrong — the held lock, the exhausted pool, the run that dies on someone else's timeout, and the diagnosis that looks like a network fault because a missing response is what a network fault also looks like.
With the capability question settled, the remaining constraints are on the request itself. The protocol is
specific about what a server may put in a requestedSchema, and it is considerably more restrictive than the
JSON Schema you use for tool arguments — which is the subject of the next chapter, along with a category of
value you must never request no matter how well the client supports asking.
Elicitation — read the current version at https://daviddominguez.dev/learn/en/elicitation/the-elicitation-capability-check