Most people I've talked to since the 2026-07-28 MCP specification landed believe
elicitation was killed along with the rest of the bidirectional surface. It wasn't. It's
the one that got invested in, and the asymmetry tells you something real about how to
think about safety primitives in a protocol.
What actually happened
The 2026-07-28 revision turned MCP from a stateful, bidirectional protocol into plain
request/response. Every request is self-describing, so any request can land on any
instance behind a round-robin load balancer.
That rewrite had to do something about the features that assumed an open connection:
| feature | fate | SEP |
|---|---|---|
| Roots | deprecated | SEP-2577 |
| Sampling | deprecated | SEP-2577 |
| Logging | deprecated | SEP-2577 |
| Elicitation | redesigned onto MRTR | SEP-2322 |
Deprecated does not mean gone. A formal deprecation policy (SEP-2596) guarantees a
twelve-month minimum window, so the first three still work.
But only one of the four was rebuilt.
Why elicitation got the different treatment
Roots and sampling are capabilities. They let a server do more.
Elicitation is a control. It lets a server do less, on purpose, until a human says
otherwise.
A protocol can drop a capability and leave users with a smaller feature set. If it drops
the control, every tool that guards an irreversible operation loses its guard, and the
failure is silent, because the tool just starts succeeding. That asymmetry is why elicitation
was worth the cost of a redesign.
What elicitation is
A server, mid tool call, pauses and asks the user a question.
Without it, a tool either runs or refuses. With it, a tool can stop halfway and say
"this will delete 40 records, confirm?" and block until answered.
In a production MCP deployment this typically guards the operations a retry cannot
undo: outbound messaging, content publishing, record deletion.
Old mechanism vs MRTR
Before. The server pushes a request down an open bidirectional stream to the client
and blocks waiting for the answer. This requires a persistent connection, precisely what
the stateless core removes.
After (MRTR, SEP-2322). The server returns an ordinary response:
{ "resultType": "input_required", "requests": [ ... ] }
The client collects answers from the user, then retries the original tool call with
them attached in inputResponses. Each leg is a complete request/response cycle.
The 4.x implementation states the philosophy directly in its own docstring:
The protocol is stateless: each MRTR leg is a complete request→response cycle. When a
guard tool returns anInputRequiredResultfrom its body to ask the client for input,
that ask is the legitimate result of this tool call — not a pause, not an error, not
a third control-flow outcome.
That framing matters for implementers. The ask flows through the middleware chain as an
ordinary return value. Middleware completes normally. You identify one with a plain
isinstance check rather than a special control path.
The trap: an empty schema is not a confirmation
Here is the part worth acting on today, regardless of which protocol version you're on.
FastMCP's Context.elicit takes a response_type. Passing None produces this schema:
{ "type": "object", "properties": {} }
The library documents it: "When response_type is None, the accepted elicitation will
contain an empty dict."
An empty dict means the accept payload carries no data. And that means a client that
auto-accepts produces bytes indistinguishable, on the wire, from a human clicking
approve.
I confirmed this against a running system. A delete tool executed its full delete path
with confirm=False. The gate existed in the code, was reached at runtime, and stopped
nothing.
The fix is not "add a confirmation step." It is to stop treating acceptance as the
signal and require an affirmative value:
CONFIRM: Final[list[str]] = ["cancel", "confirm"]
result = await ctx.elicit(message, response_type=CONFIRM)
Now approval carries information. An empty or absent response is not approval.
The part where two bugs turned out to be one bug
That corrected call needs the list[str] overload of elicit.
Under mypy, that overload did not exist.
Context.elicit declares six @overload stubs. Each stub's explanatory string sat
after the stub body rather than inside it, making it a bare expression statement rather
than a docstring. A statement between overloads terminates the overload series for mypy,
so mypy registered stub one and ignored the rest.
pyright and ty both tolerate it and see all six. Fixed upstream in
PrefectHQ/fastmcp#4774.
So the safest available call shape was the one the type checker reported as
nonexistent. The type bug and the security bug were the same bug, one layer apart.
Two things I verified that you may want to check yourself
response_type: None is removed in 4.0.0b1. In 3.x it was overload one and marked
deprecated. In the 4.x beta the first overload is response_type: type[T] and the None
variant is gone. The unsafe default deleted itself. If you already hardened to an
explicit value list, you are on the 4.x-correct shape ahead of the migration.
The overload fix is not in any release. Read from the PyPI JSON API:
latest stable 3.4.6 uploaded 2026-08-05 still has all six
newest 4.x 4.0.0b1 uploaded 2026-07-28 predates the fix, has five
4.0.0 stable does not exist
The fix is on main. There is no 3.x maintenance branch. If you pin below the major, you
do not have it.
What to do this week
-
Grep for
response_type=Nonein anything that gates an irreversible operation. That is the empty-schema path. - Add a second type checker as a non-blocking advisory job. Not to gate on. Just so something in your pipeline is capable of seeing what your primary checker cannot.
- Write your dual-protocol contract tests now. The confirm gate has to hold on both an old-protocol client and a 2026-07-28 client through the twelve-month runway. The failure mode to test explicitly is a silent fall-through that auto-approves. Writing these before the SDK ships means the migration has a green target instead of being validated afterward.
- Prove the gate is reached, not merely present. Revert the wiring, watch the new tests fail, restore. Green tests over unreachable code is the failure this catches.
A note on the scan
I checked whether the overload defect was widespread by AST-scanning a production
dependency tree for the construct: a bare string expression sitting between two
@overload definitions.
13,920 .py files. Six occurrences. All six in one file, all unintentional, all now
fixed upstream.
Worth stating because it cuts against the obvious conclusion. This is not a common
Python footgun you should go hunting for. It is a single localized mistake that survived
into a release because the checker that catches it was not the checker that project ran.
The typing specification, incidentally, does not cover this case at all. It requires only
that overload definitions "be followed by an overload implementation, which does not
include an @overload decorator", and says nothing about intervening statements. So
mypy is not right and ty is not wrong. Each picked a behavior in a gap, and a bug lived
in the gap between them.
The takeaway
A green gate proves that the checker you ran agrees with you. It does not prove the code
is right.
Three type checkers looked at that file. One saw the bug. The project shipped it running
one of the two that did not.
Upstream issue: PrefectHQ/fastmcp#4773
Upstream fix: PrefectHQ/fastmcp#4774
Spec: the 2026-07-28 MCP specification announcement, SEP-2322 / SEP-2577 / SEP-2596
Links
- The 2026-07-28 MCP specification announcement — source for SEP-2322 (MRTR), SEP-2577 (deprecations) and SEP-2596 (the 12-month policy)
- modelcontextprotocol/modelcontextprotocol — the spec repo, where the SEPs live
- The overload bug referenced above: issue PrefectHQ/fastmcp#4773, fix PrefectHQ/fastmcp#4774
- fastmcp on PyPI — confirm which version you are on before assuming you have the fix
Previously in this series: The bug FastMCP's own CI could not see — three type checkers, one saw it, and the project ran one of the two that did not.
Top comments (0)