Part 3 of a three-part, hands-on series. We finish the permit server by reaching for the MCP primitives a plain tool can't reach — and find exactly where the protocol's edge actually is.
Follow along. This is the last stretch of the same FastMCP server design, now with elicitation and sampling. The repo starts with Part 1's runnable code and grows from there: github.com/roman-romanov-o/mcp-article.
Go back to the one tool we shipped uneasily.
acknowledge_fees took no arguments. Its whole content was the word yes, and we let it write on the conversation's say-so because — we argued — consent is the one fact a server cannot observe for itself. The model was its only witness.
That reasoning was sound for a tool. It was also an admission: at that step, the server wanted to hear from the human, and the only instrument it had was a function that waits to be called. So it trusted the model's report and moved on.
It shouldn't have had to.
The whole of Parts 1 and 2 ran on a single, unspoken rule — the model speaks, the server answers — and acknowledge_fees was the place that rule first chafed. Because sometimes the server is the one with a question. It hits a fork it cannot resolve from its own state and needs a person, not a parameter.
This part is about what MCP offers for exactly that — elicitation and sampling, the primitives where the server stops waiting to be called and speaks first.
The server asks a question
Here is the move the tool vocabulary couldn't make. Mid-call, the server turns to the client and poses a question — to the human, in their own words — then waits for the answer before it does anything else:
class FeeConsent(BaseModel):
agreed: bool
@mcp.tool
async def acknowledge_fees(ctx: Context) -> ConsentAck:
"""Show the citizen the assessed fees and obtain their consent to proceed."""
fees = _application.fees
answer = await ctx.elicit(
message=f"The assessed fee is €{fees.total_eur}. Proceed to payment?",
response_type=FeeConsent,
)
if answer.action != "accept" or not answer.data.agreed:
return ConsentAck(status="FeesAssessed", message="Consent not given.")
_application.status = "AwaitingPayment"
return ConsentAck(status="AwaitingPayment", message="Fees acknowledged.")That ctx.elicit is the inversion of everything so far.
Every tool until now received its arguments and returned a value — input came in with the call. Here the call is already running, and partway through it the server reaches back out for more. It sends the client a question and a schema for the reply, and the client's job is to put that question in front of the person and hand back what they say.
The argument the server needed wasn't in the call. So the server asked for it.
And look what it does to the tool we were uneasy about. acknowledge_fees no longer takes the model's word that the citizen agreed; it asks the citizen and reads the reply itself.
Consent is still the one thing the server can't observe for itself — Part 2 had that right. What changed is that the server no longer has to take the fact secondhand from the model; it can ask the citizen directly and read the answer. The model is no longer the witness — it's just the thing that called the tool.
The fork that made us shrug in Part 2 has a clean answer here, and the answer is: when you need a human, ask the human.
What the answer can be
Notice the reply isn't a plain bool.
An elicitation comes back with an action before it comes back with data, and that action has three values, not two: the person can accept and answer, decline the question outright, or cancel — walk away without answering at all.
The code has to read the action first, because "they said no" and "they never said anything" are different facts that demand different next moves, and only one of them carries data worth looking at. It's the verdict-shaped return from Part 1 yet again — the not-yet answer carries its own meaning — only now the verdict is the human's, relayed back through the protocol.
In raw MCP terms, the accepted answer is returned as structured content; FastMCP unwraps that into the .data field the code above reads. The important shape is the same either way: action first, payload only when the action is accept.
That third value, cancel, is the interesting one. It's the protocol admitting, in its own type system, that the question might reach no one — that you can ask and hear nothing back.
Which points at the quiet precondition under this whole mechanism. An elicitation only works if two things are true at once:
- The client has to support the asking. It's an optional capability, and plenty of clients don't implement it — in which case the request has nowhere to go.
- There has to be a person on the far side to receive it.
The capability and the human both have to be there, right now, on the same live connection the tool call is riding. The server can speak first — but only when a client is listening and a person is there to answer. That precondition is what the end of this part turns on.
Borrowing a brain
Elicitation reaches past the model to the human. There's a second reversal that goes the other way — past the human, to the model — and it's the stranger of the two.
Go back to document review. In Part 2 we cheated: submit_documents checked that the required kinds were present and called that a review, and we admitted out loud it was a simplification standing in for something slower and more human.
Here's what it was standing in for. A complete set isn't a valid one — a file tagged SITE_PLAN still has to actually be a site plan, show boundaries and setbacks, mean something. Judging that takes a reader, which means a document can no longer be a bare tag — it has to carry its contents.
The server still can't do the reading itself. It has no model of its own; it's a state machine with a fee table. So the documents grow a body, and the server borrows a reader:
class Document(BaseModel):
kind: DocumentKind
text: str # the contents now, not just the label
@mcp.tool
async def submit_documents(filed: list[Document], ctx: Context) -> DocumentReview:
present = {d.kind for d in filed}
missing = [k for k in _application.checklist if k not in present]
if missing:
return DocumentReview(accepted=False, missing=missing)
# the set is complete — now judge whether the site plan is really one
if DocumentKind.SITE_PLAN in present:
site_plan = next(d for d in filed if d.kind == DocumentKind.SITE_PLAN)
screening = await ctx.sample(
messages=f"Does this site plan show parcel boundaries and setbacks?\n\n{site_plan.text}",
system_prompt="You're a permit clerk pre-screening documents. Answer YES or NO, with one reason.",
)
if screening.text.strip().upper().startswith("NO"): # FastMCP exposes the reply as .text
return DocumentReview(accepted=False, missing=[DocumentKind.SITE_PLAN])
_application.status = "FeesAssessed"
return DocumentReview(accepted=True, missing=[])ctx.sample is the server asking the client's LLM to think for it.
It packages a prompt, ships it to the client, and the client runs it against a model it controls — usually the very one the user is already talking to — then hands the completion back. The server originates a turn of inference it is fundamentally incapable of performing alone.
That's a deeper speaking-first than elicitation. The server isn't requesting a fact a human happens to hold; it's requesting judgment, and renting the only brain on the connection to get it.
And that judgment is load-bearing — a no drops the site plan straight back into missing, into the same resubmission loop the document review ran on in Part 2.
It also surfaces a second kind of waiting. Part 2 already met one — the UnderReview decision that hangs on an authority's own clock, a wait on the world. This one is internal: a sampling call takes real time because a model has to run before the call can return.
Same symptom we shrugged off with that inline check — a step that can't answer the instant it's called — but a different cause, and this time the server is the one left waiting.
Two asymmetries, not one
So the server can speak first after all. Twice over — to the human, to the model.
It's tempting to read that as the proactive gap closing, the wall from the end of Part 2 coming down a brick at a time. It isn't. What's actually happening is that we named one gap in Part 2 and it was quietly two, stacked so flush they passed for a single thing.
The first asymmetry is about origination: a tool can only respond, never start a turn. That one we just broke. ctx.elicit and ctx.sample are the server starting a turn — no model called them, the server reached out first. Origination is no longer the model's exclusive verb.
The second asymmetry is about reach: can the server get to a human who isn't here? And nothing we built in this part so much as touches it.
Look again at where every one of these new powers runs — on the same live connection the tool call is riding, into the room someone is currently standing in. That was the precondition we kept flagging, and now it collects:
- Elicitation needs the human present to answer.
- Sampling needs the client present to lend its model.
Both travel server → client, the very route Part 2 showed a notification taking. And like that notification, they terminate at a client that has to be connected and attended for any of it to mean anything.
But the proactive gap was never about the attended case. The citizen who wants "tell me when it's decided" has closed the app. They are the absent human by definition.
So these primitives can't close that gap — not because they're weak, but because they're defined on exactly the condition the gap is the absence of. They reverse who speaks first on a live connection. The gap is about a connection that isn't live.
Two different verbs — originate and reach the absent — and MCP just handed us the first while leaving the second precisely as open as we found it.
Where the last hop lives
It's worth being exact about why the second verb is missing, because it's not an oversight waiting on a future spec.
Run down everything MCP lets a server send unprompted — elicitation requests, sampling requests, progress updates while a long call runs, log messages. Every one of them goes server → client. Even Tasks, which make long-running work durable and pollable, keep the result in a requestor-driven shape: someone still comes back to retrieve it. The client is the last hop MCP can name.
There is no address in the protocol for the human — no field that says "this person can be reached at this push token, this inbox, this number." MCP describes the conversation between a client and a server; it was never given a way to refer to someone who isn't part of that conversation right now.
Which means the fix for the proactive gap doesn't live in MCP at all. That's not a disappointment; it's just where the boundary is.
Reaching a human who walked away is a job for the layer that owns that human's attention: the host application. The app that embeds the client is the thing that knows the citizen's email, holds their device's push token, can light up a notification on a phone that's face-down on a table three weeks later.
So the realistic shape of "tell me when it's decided" is a handoff. The server still can't push to a person, so something outside the protocol — the host, a webhook it registered, a plain background job with an SMTP password — watches for the decision and reaches the human by a channel MCP doesn't model and was never meant to.
Draw everything this part added, plus that handoff, and the boundary is plain — every arrow MCP owns terminates at the client:
MCP gets a message reliably to the client's doorstep. The last hop, from that doorstep to a person who has left the room, belongs to somebody else.
That was the proactive gap all along: not a hole in the protocol, just the edge of it — the place where the conversation between a client and a server ends and the rest of the system takes the last step.
The server that carried this series lives in the repo. Build right up to the edge. Then hand off.