Skip to content
Chapter 3 of 7 · ElicitationThe elicitation capability check

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.

Here is the client half of a handshake, the second message of a session:

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

Sequence: Model → Client (no elicitation) (call purge_index("prod-catalog")); Client (no elicitation) → Server (tools/call); Server → Server (BEGIN#59; acquire advisory lock); Server → Client (no elicitation) (elicitation/create (id=7)); Client (no elicitation) → Server (nothing).

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.

python
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."
        )
    ...
ts
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 clientNon-supporting client
Capability in initialize"elicitation": {}absent
What the server sendselicitation/createnothing
What the tool returnsresult after the answererror naming retention_days and its legal values
Server state on exitcommitted or rolled backnothing acquired
Time to terminal stateuser response time, bounded by your own timeoutunder 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

Back to top