Class 12: Elicitation
Duration: ~85 minutes | Level: Advanced | Prerequisites: Class 11: Progress and Logging.
What We'll Cover
- A tool that must not act without a person agreeing
context.elicit(...), and the three answers it can get back- Why this only works on a stateful server
@McpElicitationon the client, and its one permitted signature- Closing the other route to the same change, which the confirmation cannot prevent
- The model's own confirmation, and why it asks even when the tool says it will
- Holding an MCP request open while a human decides, and the three timeouts that bound the wait
This class carries on from Class 11. If you followed along, keep working in the project you
already have. If you skipped it, clone the class_11
branch to start from the same place:
git clone --branch class_11 https://github.com/the-mcp-guy/spring-ai-mcp-course.git
The Tool That Should Ask
Every tool so far either reads something or makes a change that can be made again. Cancelling an order is different: it stops the shipment and starts a refund, and no tool exists to undo it.
Class 3 introduced destructiveHint = true for exactly this case, but a hint does not prevent anything. If the person writes "yeah cancel that one", whether the order is cancelled depends entirely on the model's judgement. For a change that cannot be undone, we want to pause until a person confirms.
| Mechanism | What it is | Who reads it | Can it stop the call? |
|---|---|---|---|
destructiveHint = true | an annotation on the tool | the client and the model | No. A client may warn and a model may be careful, and the tool can still be called. |
| elicitation | a question the server sends while the tool runs | the person at the client | Yes. The tool does not act until an answer comes back. |
Elicitation is the protocol's mechanism for this: partway through executing, the server asks the client a question, the client puts it in front of a person, and the answer comes back before the tool finishes. The model is not involved in the decision.
Drawn as a sequence, with the tool this class builds:
The first arrow and the last are the ordinary tool call from Class 7. Everything between them happens while that call is still open: the server turns around and sends a request of its own, and the tool does not finish until the answer arrives.
This course targets 2025-11-25, where the server sends elicitation/create while the tool call is open, as in the diagram above. The 2026-07-28 revision replaces that shape with Multi Round-Trip Requests. The server returns an InputRequiredResult carrying the question, and the client retries the original request with its inputResponses. The client capability also moves out of initialize into _meta.io.modelcontextprotocol/clientCapabilities on each request, and elicitationId and notifications/elicitation/complete are removed. Everything below is the 2025-11-25 behaviour that Spring AI 2.0.x implements.
Asking From Inside the Tool
A question needs an answer of a known shape. If the client could reply with any text it liked, the tool would be left parsing free text to work out whether the person agreed. So the server declares the shape up front, as an ordinary Java type, and Spring AI turns that type into a JSON schema that travels with the question. The client then knows which fields to collect and what to send back.
A record fits, because the answer is a small fixed set of values. Ours holds two: whether the person agreed, and a note they can add explaining the decision. Create it alongside the tools, in order-service/src/main/java/com/themcpguy/supportdesk/orders/mcp/CancellationConfirmation.java:
package com.themcpguy.supportdesk.orders.mcp;
public record CancellationConfirmation(boolean confirmed, String note) {
}
On the way out, Spring AI builds a JSON schema from it and sends that with the question. The schema names two fields, confirmed and note, and marks both as required. The generator does not add descriptions, because on this path it reads only the component names and types.
On the way back, Spring AI turns the client's answer into a CancellationConfirmation again. The tool then calls confirmed() and note() on it, instead of pulling values out of a map.
Then the tool itself. It is a sixth @McpTool method, and it goes in OrderTools, the class that has held every tool since Class 2. Add it below recheckShipments from Class 11, along with the three imports at the top of the file:
import org.springframework.ai.mcp.annotation.context.McpSyncRequestContext;
import org.springframework.ai.mcp.annotation.context.StructuredElicitResult;
import io.modelcontextprotocol.spec.McpSchema.ElicitResult.Action;
@McpTool(
name = "cancel_order",
description = """
Cancel an order and start a refund. The user is asked to confirm before
anything changes. Only PENDING and PROCESSING orders can be cancelled.
""",
annotations = @McpTool.McpAnnotations(
readOnlyHint = false,
destructiveHint = true,
idempotentHint = false,
openWorldHint = false))
public String cancelOrder(
McpSyncRequestContext context,
@McpToolParam(description = "The order ID, for example ORD-10002") String orderId) {
Order order = orderService.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException(
"No order with ID '%s'. Check the ID and try again.".formatted(orderId)));
if (!order.status().isCancellable()) {
return "Order %s is %s and can no longer be cancelled. Only PENDING and PROCESSING orders can."
.formatted(orderId, order.status());
}
if (!context.elicitEnabled()) {
return "This client cannot ask for confirmation, and cancelling needs it. "
+ "Cancel %s through the admin console instead.".formatted(orderId);
}
StructuredElicitResult<CancellationConfirmation> answer = context.elicit(
spec -> spec.message("Cancel order %s for %s? The total is %.2f and a refund will be started."
.formatted(orderId, order.customer().name(), order.totalAmount())),
CancellationConfirmation.class);
return switch (answer.action()) {
case ACCEPT -> {
if (answer.structuredContent() == null || !answer.structuredContent().confirmed()) {
yield "Order %s was not cancelled: the confirmation was declined.".formatted(orderId);
}
orderService.cancel(orderId, answer.structuredContent().note());
context.info("Cancelled %s".formatted(orderId));
yield "Order %s is cancelled and a refund of %.2f has been started."
.formatted(orderId, order.totalAmount());
}
case DECLINE -> ("Order %s was not cancelled. The client declined the confirmation, "
+ "either because the person said no or because the question could not be presented.")
.formatted(orderId);
case CANCEL -> "Order %s was not cancelled: the user dismissed the question.".formatted(orderId);
};
}
The method makes three checks before it asks anything:
Only the third check leads to a question. Five parts of that path are worth explaining.
elicitEnabled() is checked before the question goes out. Not every client can put a question in front of a person and wait for the answer. A batch job, or any client without a user interface, leaves the elicitation capability out of the handshake, so elicitEnabled() comes back false and calling elicit would fail. The tool returns a plain sentence instead, which tells the model the order was left alone and points the user at the admin console.
ACCEPT on its own does not mean yes. The person answered, and what they answered is in the record:
| Action | What the client is saying | What cancel_order does |
|---|---|---|
ACCEPT, confirmed true | the person filled in the form and agreed | cancels the order, logs it, reports the refund |
ACCEPT, confirmed false | the person filled in the form and said no | leaves the order alone: "the confirmation was declined" |
ACCEPT, structuredContent() null | the client accepted without sending the form back | the same as confirmed false |
DECLINE | the person refused, or the client could not present the question | leaves the order alone and says both are possible |
CANCEL | the dialog was closed, or the wait ran out | leaves the order alone: "the user dismissed the question" |
Both checks inside the ACCEPT branch are therefore needed, the null one and the confirmed one. DECLINE and CANCEL leave the order in the same state, and the tool still words them differently, because the two mean different things to anyone reading the logs afterwards.
The status check comes before the question. Asking someone to confirm cancelling an order that cannot be cancelled wastes their time and produces a confusing failure afterwards. A minute can pass between that check and the answer, so the check that guards the write is a second one, inside OrderService.cancel. It reads the order again after the wait, and any elicitation that guards a write needs the same pair:
@Transactional
public Order cancel(String orderId, String note) {
OrderEntity order = require(orderId);
if (!order.getStatus().isCancellable()) {
throw new IllegalStateException(
"Order %s is %s and can no longer be cancelled. Only PENDING and PROCESSING orders can."
.formatted(orderId, order.getStatus()));
}
order.setStatus(OrderStatus.CANCELLED);
order.setCancellationNote(note);
order.setLastUpdated(Instant.now());
return orderRepository.save(order).toDomain();
}
DECLINE covers more than one situation, which is why the message above says two things at once. The person may have refused, or the client may have been unable to show the question at all, which is what the browser handler faces when no browser is attached. The specification keeps CANCEL for a question dismissed without an explicit choice, so a client could answer that way for the second case. Ours answers DECLINE for both, for the reason "Check It" gives below.
The message goes to whoever the client puts it in front of. The server does not choose that person, so it should carry only what that operator may see. Here that is a support agent who already has the order on screen, which is why the customer's name and the total are in it. The message and the generated schema leave together, in one request:
{
"jsonrpc": "2.0",
"id": 8,
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Cancel order ORD-10002 for Marcus Adeyemi? The total is 34.99 and a refund will be started.",
"requestedSchema": {
"type": "object",
"properties": {
"confirmed": { "type": "boolean" },
"note": { "type": "string" }
},
"required": ["confirmed", "note"]
}
}
}
A client builds its form from requestedSchema, and shows message as it stands. mode marks it as a form request, the kind explained below.
destructiveHint and elicitation are unrelatedThe two are independent, though both are about a dangerous tool. elicit(...) checks one thing, whether the client declared the elicitation capability during the handshake, and it ignores the annotations. cancel_order would ask exactly the same question with destructiveHint = false.
Keep it true all the same. The hint describes what the tool does to an order, and it is read by clients that never see our system prompt or our dialog. In Class 17 we connect this server to Claude Desktop, which cannot show an elicitation at all: there the hint is the only warning that client gets.
Setting it to false because our own agent asks for confirmation would put a fact about one client's behaviour into the server's public description of itself.
Why This Needs a Stateful Server
Every exchange in the course so far has gone one way. The client asks, the server answers, and the connection has done its job. Elicitation goes the other way: halfway through running cancel_order, the server sends a question to the client and waits for the reply.
That only works if the connection between the two is still open while the tool runs, and whether it is depends on spring.ai.mcp.server.protocol. In Class 2 we set that property to STREAMABLE when we turned order-service into an MCP server. It takes three values:
| Value | What the connection does | Can the server ask a question mid-call? |
|---|---|---|
STREAMABLE | one connection stays open per client | Yes, and this is what we have used since Class 2 |
SSE | one connection stays open per client | Yes, and it is deprecated as of Spring AI 2.0.0 |
STATELESS | each request is answered on its own and the connection closes | No, so the server cannot send anything between requests |
Stdio is stateful too. The client starts the server as a child process, and the two hold stdin and stdout open for as long as it runs, which is how we get cancel_order working inside Claude Desktop in Class 17.
Set protocol: STATELESS and Spring AI reads the signature of cancelOrder, sees the McpSyncRequestContext parameter, and knows a stateless server cannot support what that parameter offers. So it skips the method at startup and logs this:
Stateless servers doesn't support bidirectional parameters. Skipping method
public java.lang.String com.themcpguy.supportdesk.orders.mcp.OrderTools.cancelOrder(
org.springframework.ai.mcp.annotation.context.McpSyncRequestContext,java.lang.String)
with bidirectional parameters
The server then starts normally, and cancel_order is absent from tools/list. Class 15 covers the stateless protocol and the other capabilities it removes.
The failure is quiet, and that is what makes it worth knowing. A tool that throws an exception tells you something is wrong. A tool that is missing from tools/list does not report anything, and the warning explaining why is printed once among the startup logs, where it is easy to scroll past.
In Class 14 we write a test for this. It asserts on the full set of registered tool names, so a protocol change that drops one makes mvn test fail. Without that test the missing tool turns up later, as something the model can no longer call.
Answering On the Client
A handler decides what to do with the question. Its signature is fixed, with one permitted form. This first version reads the answer at the command line, so the whole mechanism is visible before the browser gets involved. A second handler for the browser arrives later in the class, and the two live side by side, chosen by profile.
It goes in support-agent, next to the notification handlers from Class 11. Create support-agent/src/main/java/com/themcpguy/supportdesk/agent/mcp/ConfirmationHandler.java:
package com.themcpguy.supportdesk.agent.mcp;
import java.util.Map;
import java.util.Scanner;
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
import org.springframework.ai.mcp.annotation.McpElicitation;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("cli")
public class ConfirmationHandler {
@McpElicitation(clients = "orders")
public ElicitResult confirm(ElicitRequest request) {
System.out.println();
System.out.println(request.message());
System.out.print("Type 'yes' to confirm: ");
String answer = new Scanner(System.in).nextLine().trim();
if (answer.equalsIgnoreCase("yes")) {
return new ElicitResult(ElicitResult.Action.ACCEPT,
Map.of("confirmed", true, "note", "confirmed at the command line"));
}
return new ElicitResult(ElicitResult.Action.DECLINE, Map.of());
}
}
Exactly one parameter, of type ElicitRequest. Spring AI rejects anything else at startup with Currently only methods with a single ElicitRequest parameter are supported.
ElicitRequest is an interface, because there are two kinds of elicitation request and a handler can be given either. The interface declares only what both kinds have: message(), meta() and mode().
The difference is in what each kind carries. ElicitFormRequest has a requestedSchema, which is the JSON schema generated from CancellationConfirmation, and it is what our server sends. ElicitUrlRequest has a url and an elicitationId instead. It sends the person to a page the server itself serves, so what they type there does not pass through the MCP client. The specification requires that mode for anything sensitive: a form must not ask for passwords, API keys, access tokens or payment credentials.
So the schema is available only after checking which kind arrived:
Map<String, Object> schema = request instanceof ElicitFormRequest form
? form.requestedSchema()
: null;
mode() answers the same question as a string, "form" or "url". Either check works, and a handler that can only show forms should make one of them before assuming there is a schema to read. The console handler above ignores the schema entirely, because it asks one fixed question.
clients = "orders" says which connection this handler answers for. A Spring AI client can hold several at once, and orders is the name the agent's application.yaml gives the order-service connection, chosen back in Class 6:
spring:
ai:
mcp:
client:
streamable-http:
connections:
orders:
url: http://localhost:8080
The notification handlers in Class 11 carry the same value for the same reason.
The argument has a second effect here that it does not have there. Without a handler on a matching connection, the client leaves the elicitation capability out of the handshake, so elicitEnabled() on the server comes back false and cancel_order takes the branch that skips the question. So this handler does two jobs: it answers the questions, and by existing at all it tells the server that questions can be asked.
Try it, with both applications running and the agent in the cli profile.
Starting the two applications (click to expand)
Two terminals, from the repository root. The order server first:
mvn -pl order-service spring-boot:run
Then the agent, with the cli profile so that it reads questions from the terminal instead of serving the browser:
mvn -pl support-agent spring-boot:run -Dspring-boot.run.profiles=cli
In PowerShell the property has to be quoted, because PowerShell splits the unquoted token at the first dot and Maven then reports Unknown lifecycle phase ".run.profiles=cli":
mvn -pl support-agent spring-boot:run "-Dspring-boot.run.profiles=cli"
Cancel order ORD-10002
Cancel order ORD-10002 for Marcus Adeyemi? The total is 34.99 and a refund will be started.
Type 'yes' to confirm: yes
Agent: Order ORD-10002 is cancelled and a refund of 34.99 has been started.
The pause in the middle is a tool call that has not returned yet. The model asked for cancel_order, the server started running it, and it stopped inside the method waiting for that line of input.
That happens, and it is not a mistake on your part. The model has a second tool that reaches the same state, and it sometimes picks that one instead. The next section shows how to tell which tool ran, and how to close that route.
The Other Way to Cancel an Order
Ask for the same cancellation again, and the agent may skip the question. The reply comes back like this instead:
Agent: Order ORD-10002 has been successfully cancelled. Here's a quick summary:
- Customer: Marcus Adeyemi (CUST-17)
- Item: Mechanical Switch Kit x 1
- Total: EUR 34.99
- New Status: CANCELLED
The order is cancelled, and the confirmation question did not appear. The model called update_order_status, the tool from Class 3, which takes any of the five statuses and CANCELLED is one of them. Both tools looked right for the request, and it picked the older one. You can tell which ran from the shape of the answer: cancel_order returns one sentence, and update_order_status returns the whole order, as above. It also skips the note that cancel_order records, so the database does not say why this order was cancelled.
Each tool takes its own route to the cancelled state:
Both routes end at the same row, and only the one through cancel_order passes a person on the way.
A confirmation on one tool is only as good as the other ways of reaching the same state. That applies well beyond our support desk. Whenever you put a check in front of an action, look for the other tools that can produce the same result and close those routes too.
Every caller reaches the row through OrderService, so the check goes into updateStatus there:
@Transactional
public Order updateStatus(String orderId, String newStatus) {
OrderStatus status = OrderStatus.parse(newStatus);
if (status == OrderStatus.CANCELLED) {
throw new IllegalArgumentException(
"Use cancel_order to cancel an order. It asks the customer to confirm "
+ "first and records the reason. This tool sets the other four statuses.");
}
OrderEntity order = require(orderId);
order.setStatus(status);
order.setLastUpdated(Instant.now());
return orderRepository.save(order).toDomain();
}
Putting the check in the tool method would work today, because that method is the only caller. It would also repeat the mistake this section is about: the rule would sit at one entry point, and the next entry point somebody adds would bypass it. In the service, any future caller runs into the same check without anyone having to remember it, whether that is a REST endpoint, a scheduled job, or a second tool.
Then tell the model, so it routes correctly on the first attempt instead of learning from an error. Change the description of update_order_status in OrderTools:
description = """
Move an order to a new status.
Valid statuses are PENDING, PROCESSING, SHIPPED and DELIVERED.
To cancel an order, use cancel_order instead: it asks the customer to confirm.
Returns the updated order.
""",
The description keeps the model from choosing the wrong tool in the first place. The exception catches the case where it chooses wrongly anyway, and this is what a client sees when it does:
{
"content": [
{
"type": "text",
"text": "Error invoking method: updateOrderStatus\nUse cancel_order to cancel an order. It asks the customer to confirm first and records the reason. This tool sets the other four statuses."
}
],
"isError": true
}
Spring AI puts Error invoking method: updateOrderStatus in front of our sentence and sets isError, which Class 7 covered: a failed tool call goes back to the model rather than to our code. So this text is what the model reads before it decides what to do next, which is why it names the tool to use instead. The other four statuses still work as they did.
Both changes are in order-service, so restart order-service for them to take effect. If support-agent then reports a connection problem on its next question, restart support-agent as well: its MCP session ended when the server it was connected to went down.
That restart also resets the data. The orders live in an in-memory H2 database that DataInitializer fills at startup, so every restart returns each order to its seeded status, which puts ORD-10002 back to PENDING:
Cancel order ORD-10002
The confirmation appears every time now, because only one tool can reach the cancelled state.
An order can be cancelled once, so a second attempt at the same one gets a refusal. To repeat the exercise without restarting order-service, pick a different order. Only PENDING and PROCESSING orders qualify, and 55 of the 200 seeded orders are in one of those two statuses: ORD-10008 is PENDING, and ORD-10009 is PROCESSING. ORD-10001 is the order the earlier classes use for almost everything, and it is SHIPPED, so asking to cancel that one gets the refusal from the status check instead.
If you ask for an order that is already cancelled, the answer can look like the order has gone missing, because the model goes looking for it in the PENDING and PROCESSING lists, where it no longer appears:
Agent: I'm sorry, but I was unable to retrieve ORD-10002 directly. Additionally,
ORD-10002 does not appear in either the PENDING or PROCESSING orders lists.
Could you double-check the order ID?
The Model's Own Confirmation
You may be asked to confirm twice: once by the model, in the conversation, and once by the tool:
You: Cancel order ORD-10008
Agent: I was able to retrieve some information about ORD-10008, but let me confirm
the details with you before proceeding. Could you confirm:
1. Is this your order?
2. Are you sure you'd like to cancel it?
You: yes
Cancel order ORD-10008 for Daniel Mensah? The total is 124.99 and a refund will be started.
Type 'yes' to confirm: yes
Agent: Order ORD-10008 is cancelled and a refund of 124.99 has been started.
Only the second question is elicitation. The first is the model being careful on its own, and the two are easy to tell apart:
| Who asks | Where it appears | What happens if you say no |
|---|---|---|
| the model | inside its prose answer in the chat | the model decides what to do next, and it can still call the tool |
| the tool | printed by ConfirmationHandler, our own code, while a tool call is still running | cancelOrder returns without cancelling |
Nothing is broken here. The model had two signals that this call needs care: destructiveHint = true, and the description we wrote for cancel_order, which says "The user is asked to confirm before anything changes."
So the model knew the tool would ask, and asked anyway. A description says what a tool does; it does not say that the tool's own question is enough. A model trained to check before a destructive action will check when it is left to decide.
The protocol does not settle it either:
| Where the fact could live | Does it say "this tool asks the user first"? |
|---|---|
ToolAnnotations | No. The five fields are title, readOnlyHint, destructiveHint, idempotentHint and openWorldHint. |
| the tool description | Yes, as one more sentence among the others, which is where we put it. |
| the system prompt | Yes, but only for a model that reads our prompt, so only for our own agent. |
One confirmation is enough, and the tool's is the one to keep, because it is the one that can refuse to act. The guideline below leaves which tools ask to their descriptions, and tells the model what to do when one of them does. Add it to BASE_SYSTEM in SupportAgentService, below the ones from Class 9:
- Some tools ask the user to confirm before they act. When the user asks for
such an action, call the tool and let it ask its own question. Do not ask
the user to confirm beforehand.
Restart the agent, ask for a cancellation again, and the model calls cancel_order straight away, leaving the tool's question as the only one.
The general point is worth more than the fix. A confirmation can live in two places, and they are not equally strong.
| Where the confirmation lives | What it is made of | What can get past it |
|---|---|---|
| in the model | a line of the system prompt | a different phrasing of the request, a longer conversation, or a different model |
| in the tool | Java in cancelOrder: elicitEnabled(), elicit(...) and a switch on the action | the code makes that decision, so nothing the model says changes the outcome |
So the guideline above is about tidiness, and the elicitation is about safety. If you only have room for one of them, it has to be the one in the tool, which is why this class puts the check inside cancel_order.
The same run against the local model (click to expand)
Everything above ran on Claude. This is qwen3:8b, our local model, given the same request and the same code:
You: Cancel order ORD-10002
Agent: The order ORD-10002 is currently in **PENDING** status. To cancel it and
initiate a refund, I'll need to confirm with you. Would you like to proceed with
canceling this order?
You: yes
Cancel order ORD-10002 for Marcus Adeyemi? The total is 34.99 and a refund will be started.
Type 'yes' to confirm: yes
Agent: The order **ORD-10002** has been successfully canceled, and a refund of
**€34.99** has been initiated. The refund will be processed back to your original
payment method shortly.
It behaves the same way in both respects that matter here. It chose cancel_order over update_order_status, so the elicitation ran and the order was cancelled properly. It also asked its own question first, so the double confirmation is a habit both models have.
Our local model is slower: each turn takes tens of seconds where Claude takes a few, for two reasons. qwen3 produces reasoning tokens before it answers, even for a one-word reply, which is text the model writes while working out its answer and the reader does not see. A single question here is also at least two model calls, one to decide on the tool and one to write the answer once the result comes back. That matters when a person is waiting at a confirmation dialog, because the timeouts below are measured against the whole exchange.
Holding the Request Open for a Browser
The command-line handler works because nextLine() blocks: the thread stops on that line and waits for somebody to type. That is exactly the behaviour we need here, because the thread it stops is the one serving the elicitation request, so the request stays open for as long as the person takes to answer.
A browser makes that harder. The handler runs on a thread inside support-agent, and the person is on the other end of an HTTP connection. The handler has to send the question out, block, and be woken by a separate HTTP request carrying the answer.
In Class 11 we built the first of the pieces needed for that, the channel to the browser. What is missing is a way to know which conversation a question belongs to, and the handler that waits. This is the flow they add up to:
Compare it with the console version above: there, "show the question" and "wait for the answer" were one blocking read on System.in. In the browser the question leaves on one HTTP connection, the answer arrives on another, and the waiting has to be built by hand, which is the last piece below.
Two more methods on the channel
BrowserChannel from Class 11 already holds one open SSE connection per conversation and pushes events to it. Progress used one method, progress. Elicitation needs two more, in support-agent/src/main/java/com/themcpguy/supportdesk/agent/service/BrowserChannel.java:
public void ask(String conversationId, String id, String message, Object schema, long seconds) {
send(conversationId, "confirmation", Map.of("id", id, "message", message, "seconds", seconds));
}
public boolean isWatching(String conversationId) {
return emitters.containsKey(conversationId);
}
ask sends an event named confirmation, which is the second event the frontend listens for. It carries three things the page needs:
| Field | What the page does with it |
|---|---|
id | matches the answer it posts back later to the question that was asked |
message | the text to show in the dialog |
seconds | how long the handler will wait, which the dialog counts down so the person can see how long is left |
It accepts the schema without sending it: our dialog is fixed, with the message, a Yes/No pair and a note field, so the schema stays unused on this path. A client that renders arbitrary forms would forward it to the page. The event itself is small:
{
"id": "3f9c1e2a-7b40-4c65-9a11-2d8e5f6b0c74",
"message": "Cancel order ORD-10002 for Marcus Adeyemi? The total is 34.99 and a refund will be started.",
"seconds": 60
}
The specification also asks the client to make clear which server is asking. Our page answers for one connection, orders, so it can say that in fixed text. A page that answers for several servers has to send the connection name with the question and show it in the dialog.
The countdown deliberately stops at zero without answering. The handler has given up by then, so anything the page sent would arrive too late to be read, and the tool is already being told the question was dismissed. Leaving the buttons disabled and saying so is the honest thing for the page to show.
isWatching answers one question: is a browser connected to this conversation right now? It looks for an entry in the map of open streams. Progress from Class 11 worked without that check, and a question needs it:
| When nobody is connected | A progress notification | An elicitation question |
|---|---|---|
| The event | is dropped, and the job carries on to the end | cannot be dropped: the server is parked inside cancel_order, waiting |
| The handler | sends it and forgets it | checks isWatching first, and declines when it is false |
Without this check, the handler would send the question into an unwatched stream and wait for its own timeout to expire before returning CANCEL. That parks a thread for the full 60 seconds while the answer it is waiting for cannot come. With the check, the handler returns DECLINE straight away, and the tool reports that the order was left alone.
Knowing which conversation to ask
This is the problem we avoided in Class 11. A progress notification carries the token, and the token is the conversation ID, so the handler knew which page to send it to. An ElicitRequest carries the server's message, and does not identify the conversation it belongs to.
The temptation is to hold the conversation ID in a ThreadLocal, a value stored against one thread so that any code running on that thread can read it back without being passed it. The controller would set it before the agent runs, and the handler would read it afterwards. That does not work here, and the failure is silent.
Spring AI wraps a synchronous elicitation handler before giving it to the client. In McpClientFeatures.fromSync:
formElicitationHandler = r -> Mono.fromCallable(() -> syncSpec.formElicitationHandler().apply(r))
.subscribeOn(Schedulers.boundedElastic());
Mono is the Flux from Class 7 with at most one value in it, and subscribeOn chooses the thread the work runs on: boundedElastic, a pool of worker threads Reactor keeps for blocking work. The handler therefore runs on a different thread from the one that served POST /api/chat, so a ThreadLocal set there is invisible and the handler sees null. It then takes its "nobody is watching" branch and declines, the browser does not show a dialog, and the model reports that the cancellation was not confirmed. Nothing in either log says why.
The fix is to put the conversation on the question itself, where the handler is certain to find it. ElicitationSpec has a meta method for exactly this, so the server attaches the progress token it already received with the tool call. Add that line to the elicit call in cancelOrder:
StructuredElicitResult<CancellationConfirmation> answer = context.elicit(
spec -> spec
.message("Cancel order %s for %s? The total is %.2f and a refund will be started."
.formatted(orderId, order.customer().name(), order.totalAmount()))
.meta("conversationId", context.request().progressToken()),
CancellationConfirmation.class);
Only the browser handler needs it, which is why the tool worked without it until now.
In Class 11 we set that token to the conversation ID, so the value is already the right one and nothing new has to be threaded through. meta(key, value) ignores a null value. If the client did not ask for progress, progressToken() returns null, so the entry is left out and the call still works.
The handler reads it back from request.meta().
The handler
Only one elicitation handler may exist per connection. Two components both carrying @McpElicitation(clients = "orders") stop support-agent from starting: the bean factory post-processor names both beans and ends the message with Only one @McpElicitation handler is allowed per client.
We want both, though: the console handler for working at the command line, and the browser one for the frontend. Profiles settle it. The console handler is already annotated @Profile("cli") above, and the browser handler gets the opposite, @Profile("!cli"), so exactly one of them is a bean in any given run:
| How the agent starts | Handler in the context |
|---|---|
-Dspring-boot.run.profiles=cli | ConfirmationHandler, reading from the terminal |
| no profile, serving the browser | BrowserConfirmationHandler |
-Dspring-boot.run.profiles=ollama,cli | ConfirmationHandler, since cli is among the active profiles |
The browser handler holds one entry per unanswered question. Each entry is a SynchronousQueue, a queue of zero capacity: a value put in is handed straight to a thread that is already taking from it. That is the handoff we need between the HTTP thread carrying the answer and the parked handler waiting for it. Create support-agent/src/main/java/com/themcpguy/supportdesk/agent/mcp/BrowserConfirmationHandler.java, alongside the console one:
package com.themcpguy.supportdesk.agent.mcp;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.TimeUnit;
import io.modelcontextprotocol.spec.McpSchema.ElicitFormRequest;
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
import org.springframework.ai.mcp.annotation.McpElicitation;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import com.themcpguy.supportdesk.agent.service.BrowserChannel;
@Component
@Profile("!cli")
public class BrowserConfirmationHandler {
/** How long the person has to answer. Everything else waits longer than this. */
private static final long WAIT_SECONDS = 60;
private final Map<String, SynchronousQueue<ElicitResult>> pending = new ConcurrentHashMap<>();
private final BrowserChannel channel;
BrowserConfirmationHandler(BrowserChannel channel) {
this.channel = channel;
}
@McpElicitation(clients = "orders")
public ElicitResult confirm(ElicitRequest request) {
// The server put the conversation on the question, so it survives the hop to
// whichever thread Spring AI runs this handler on.
Object token = request.meta() == null ? null : request.meta().get("conversationId");
String conversationId = token == null ? null : token.toString();
// Nobody is watching: the command line, or a browser that went away.
// Declining is the safe answer, because the tool behind this cancels an order.
if (conversationId == null || !channel.isWatching(conversationId)) {
return new ElicitResult(ElicitResult.Action.DECLINE, Map.of());
}
String id = UUID.randomUUID().toString();
SynchronousQueue<ElicitResult> slot = new SynchronousQueue<>();
pending.put(id, slot);
try {
Object schema = request instanceof ElicitFormRequest form
? form.requestedSchema()
: null;
channel.ask(conversationId, id, request.message(), schema, WAIT_SECONDS);
ElicitResult answer = slot.poll(WAIT_SECONDS, TimeUnit.SECONDS);
return answer != null
? answer
: new ElicitResult(ElicitResult.Action.CANCEL, Map.of());
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new ElicitResult(ElicitResult.Action.CANCEL, Map.of());
}
finally {
pending.remove(id);
}
}
/** Called by the controller when the browser posts an answer. */
public void answer(String id, boolean confirmed, String note) {
SynchronousQueue<ElicitResult> slot = pending.get(id);
if (slot == null) {
return;
}
slot.offer(confirmed
? new ElicitResult(ElicitResult.Action.ACCEPT,
Map.of("confirmed", true, "note", note == null ? "" : note))
: new ElicitResult(ElicitResult.Action.DECLINE, Map.of()));
}
}
The handler has one path that sends a question and two that answer without sending one:
Both declines are settled before the question leaves the agent. Two decisions in this handler are worth explaining, and the number in the third line deserves a section of its own.
The wait is bounded. We use poll with a time limit instead of take, which would wait forever. A browser tab that closes mid-question would otherwise leave the thread parked permanently. When the minute expires the handler returns CANCEL, which is a real answer the server can react to, so the tool reports that the question was dismissed instead of failing.
The finally removes the entry. The map holds one queue per unanswered question, so leaving entries behind is a memory leak.
How long the person gets
A minute is the longest we let the person take to answer, and two other timeouts have to be longer than that. The question travels as a server-to-client request, so it runs under spring.ai.mcp.server.request-timeout on order-service, which defaults to 20 seconds. The agent's own tools/call runs under spring.ai.mcp.client.request-timeout, 30 seconds since Class 6. Both are shorter than the minute above, so today the question dies before the handler ever gives up, and the tool call comes back with Did not observe any item or terminal signal within 20000ms.
Three limits therefore have to be ordered, shortest first:
| Limit | Where | Value |
|---|---|---|
| How long the person has | BrowserConfirmationHandler | 60 seconds |
How long order-service waits for the answer | spring.ai.mcp.server.request-timeout | 90 seconds |
How long support-agent waits for the whole tool call | spring.ai.mcp.client.request-timeout | 2 minutes |
The person's limit has to be the smallest, so that an unanswered dialog ends in a CANCEL the tool can explain rather than a timeout it can only fail on.
Set the other two now, or the dialog is unusable: the reader gets about twenty seconds to read the question and click, and anything slower fails the tool call. In order-service's application.yaml:
spring:
ai:
mcp:
server:
request-timeout: 90s
and in support-agent's, raising the 30 seconds set in Class 6:
spring:
ai:
mcp:
client:
request-timeout: 2m
That second setting is heavier-handed than we would like, for the reason we gave in Class 6: request-timeout is global, so the filesystem servers from Classes 9 and 10 now get two minutes as well. A stuck npx process will hold a request open for that long before anyone finds out. In Class 13 we replace it with a setting that applies to the orders connection alone and leaves the others at 30 seconds.
Any similar value would do. We chose 60 seconds because we think it gives somebody who is watching the screen enough time to read the question and answer it.
The other half of the decision is what the waiting costs us. While that dialog sits open, two threads are blocked:
| Where the thread is | What it is waiting for | Freed when |
|---|---|---|
order-service, inside cancelOrder | the ElicitResult | the person answers, or the 90 second server request-timeout expires |
support-agent, inside BrowserConfirmationHandler.confirm | something to arrive on the queue | the browser posts an answer, or the 60 second wait expires |
Neither thread can serve anybody else in the meantime, which is what makes a long timeout expensive. Ten people who opened a dialog and then walked away from their desks would hold ten threads on each side. A web server answers requests from a fixed set of threads, its thread pool, so each abandoned dialog takes a little of that capacity out of use until the wait runs out.
Choose a different number if your users need longer, and move the other two up with it so the order stays the same.
The controller
SupportController already has the /api/events endpoint and the channel from Class 11. One more component joins them, so the fields and the constructor become:
private final SupportAgentService agent;
private final RefundEmailService refundEmails;
private final BrowserChannel channel;
private final BrowserConfirmationHandler confirmationHandler;
SupportController(SupportAgentService agent, RefundEmailService refundEmails,
BrowserChannel channel, BrowserConfirmationHandler confirmationHandler) {
this.agent = agent;
this.refundEmails = refundEmails;
this.channel = channel;
this.confirmationHandler = confirmationHandler;
}
Then one new endpoint. It is where the browser posts the answer, and it passes that answer to the handler, which uses it to wake the thread parked inside confirm:
/** Wakes the thread parked inside BrowserConfirmationHandler. */
@PostMapping("/api/confirmations/{id}")
public void answer(@PathVariable String id, @RequestBody AnswerRequest body) {
confirmationHandler.answer(id, body.confirmed(), body.note());
}
public record AnswerRequest(boolean confirmed, String note) {}
chat stays exactly as it is.
id is a random UUID, and nothing else guards this endpoint. It does not check that the answer comes from the browser the question was sent to, or from the person the dialog was shown to. The conversation ID comes from the progress token the client supplied, so a client can name any conversation it likes and have the dialog appear on that person's page.
That is workable for one person running the course on their own machine. The specification asks for more where there are real users: elicitation state has to be bound to the client and the user identity, and a session ID on its own is not enough to identify who is answering. OWASP A01:2021 is the general name for what goes wrong without that check.
Check It
This class needs the browser, because a person has to see the question and answer it. Three terminals:
mvn -pl order-service spring-boot:run
mvn -pl support-agent spring-boot:run
cd frontend
npm run dev
Note the agent starts without the cli profile this time, so it serves the browser rather than reading standard input.
Open http://localhost:5173, click the PENDING tab, and ask:
Cancel ORD-10002
A confirmation dialog appears mid-answer:
Cancel order ORD-10002 for Marcus Adeyemi? The total is 34.99
and a refund will be started.
While it is open, a tool call on order-service is still running and a thread inside the agent is parked waiting. The dialog counts the seconds down, because the agent sends the length of that wait with the question. Three things can happen next:
| What you do in the dialog | What the handler returns | ORD-10002 afterwards |
|---|---|---|
| click Yes, do it | ACCEPT with confirmed true | CANCELLED, and it moves to the CANCELLED tab |
| click No | DECLINE | still PENDING |
| let the countdown finish | CANCEL after 60 seconds, which is the last branch of the switch | still PENDING, and the buttons stop working |
Check the result without leaving the page: click the CANCELLED tab and ORD-10002 is in that list, and it has gone from the PENDING tab. You can also ask in the chat, "What is the status of ORD-10002?" That answer comes from get_order, so it reads the same database the tabs do.
Restarting order-service puts it back to PENDING, because the database is in memory.
When nobody is there to ask
The agent is still serving HTTP, so a question can reach it without a browser. Send one with curl:
curl -s -X POST http://localhost:8081/api/chat -H 'Content-Type: application/json' \
-d '{"conversationId":"curl-1","message":"Cancel ORD-10002"}'
curl-1 is a name we invented for this one call, and any string would do. It matters only because of what the page does with the same field: when the frontend loads it generates a name of its own, something like ui-4f2a, and opens an event stream under it. That is how BrowserChannel comes to hold a connection for a conversation.
A curl request does not open a stream at all, so the exchange is shorter than the browser one above:
No browser appears in that diagram, and the fourth arrow is the answer the handler gives without waiting for one. The agent replies that the order was not cancelled, the order stays PENDING, and you can confirm that on the tabs. That branch is what stops a cancellation when nobody can approve it.
The answer says the confirmation was declined, without saying that nobody could be asked. Answering DECLINE there is our own choice: the specification keeps CANCEL for a question the person dismissed, and CANCEL invites the caller to try again, which cannot help when nobody is connected. Both actions stop the cancellation, and neither one carries a reason with it.
An ElicitResult has three actions and carries a content map and a _meta map, so the first instinct is to decline and attach a reason:
return new ElicitResult(ElicitResult.Action.DECLINE, Map.of(),
Map.of("reason", "nobody-connected"));
The reason reaches the server. It appears on the wire as {"action":"decline","content":{},"_meta":{"reason":"nobody-connected"}} and deserialises with the reason intact. It is then thrown away in the last step, by the elicit overload our tool uses:
if (elicitResult.action() != ElicitResult.Action.ACCEPT) {
return new StructuredElicitResult<>(elicitResult.action(), null, null);
}
Both the content and the metadata become null for any answer that is not ACCEPT. The two elicit overloads that take only a type keep the metadata; the two that take a spec, which are the only ones that let us write the message, discard it. The code is the same on 2.0.0, which the companion pom.xml pins, and on the 2.0.1 patch. So a declining client can send a reason, and the tool cannot read it.
The alternatives are worse than the imprecision. Returning ACCEPT with confirmed = false would say the person answered when nobody did, and throwing an exception from the handler would turn a legitimate outcome into a tool failure that a model reports as a malfunction. So the tool words its answer to cover both cases, and a server that needs to tell them apart has to wait for the metadata to survive.
The command line still works
The console handler from earlier in the class is still there, so start the agent with -Dspring-boot.run.profiles=cli and the same cancellation asks at the terminal again. That profile puts ConfirmationHandler in the context and leaves BrowserConfirmationHandler out, so exactly one of them answers, whichever way you run it.
On Windows: quoting the profile flag (click to expand)
PowerShell splits the unquoted property at the first dot, so the flag has to be quoted, as in Class 7's Run It:
mvn -pl support-agent spring-boot:run "-Dspring-boot.run.profiles=cli"
What This Is For
We used elicitation for a confirmation dialog, and the mechanism is more general: a server can use it to ask the person for any information it is missing, instead of letting the model guess it. For example a missing shipping address, a choice between three customers with the same name, or a reason code that only the person can supply. All three are fine to ask for in a form. Passwords, API keys, access tokens and payment credentials are not, and the specification requires URL mode for those, because a form answer travels back through the MCP client on its way to the server.
There are three ways a server can get a value it is missing, and each one costs something:
| How the value is obtained | Who supplies it | What it costs |
|---|---|---|
| elicitation | the person, answering the server's own question | the tool call waits on a human, so timeouts, threads and dropped connections all apply |
| the model guesses | the model | a guessed shipping address is worse than an admission that the value is missing |
| the tool fails and the model asks | the person, answering the model's rewording | the model puts the question in its own words, so the person may answer something the server did not ask for |
That first cost is why elicitation belongs on the small number of tools where a wrong answer matters.
What We Built
cancel_order cannot cancel an order unless a person agrees to it. The confirmation request goes from order-service to support-agent, is shown as a dialog in the browser, and the answer returns to the tool call that is still waiting.
A client that cannot show a question at all, a batch job or a script for example, leaves the elicitation capability out of the handshake. elicitEnabled() is false, so the tool does not ask: it says so in its answer and points at the admin console, without failing and without cancelling the order.
Class 13 covers the rest of the two-way traffic.
Further Reading
- Elicitation (MCP specification 2025-11-25): the normative rules behind this class, covering the elicitation capability, what a
requestedSchemamay contain, the three response actions, and the rule that form mode must not ask for secrets. - Tools (MCP specification 2025-11-25): the user-interaction warning and the security considerations, where the specification asks applications to present confirmation prompts and servers to control who may call a tool.
- Key Changes (MCP specification 2026-07-28): what the next revision does to elicitation, including the removal of
elicitationIdandnotifications/elicitation/complete. - Multi Round-Trip Requests (MCP specification 2026-07-28): the pattern that replaces the server-initiated request, where the server returns an
InputRequiredResultand the client retries the original call carryinginputResponses. - MCP Annotations: Client (Spring AI reference):
@McpElicitationin full, including theclientsattribute used here and the asynchronous form that returnsMono<ElicitResult>. - MCP Server Boot Starter (Spring AI reference): the three values of
spring.ai.mcp.server.protocol, with SSE marked deprecated since 2.0.0, and the note that the HTTP server transports are unauthenticated by default. - Bean definition profiles (Spring Framework reference): how
@Profiledecides which of the two confirmation handlers becomes a bean, including the!form and several active profiles at once. - Schedulers (Reactor Core javadoc): what
boundedElastic()is, which is the poolsubscribeOnmoves the elicitation handler onto, and the other schedulers Reactor offers. - SynchronousQueue (Java SE 21 API): the zero-capacity handoff queue the browser handler parks on, and why
offerreturns false when no thread is waiting to receive.
Sources
- Elicitation (MCP specification 2025-11-25): that
elicitation/createis a server-to-client request and that the client declares the capability during initialization. It gives three response actions:accept,declinefor an explicit refusal, andcancelfor a dialog dismissed without a choice. It also carries the rule that form mode must not request passwords, API keys, access tokens or payment credentials, and that URL mode exists for those. Elicitation state must be bound to the client and user identity. - Schema Reference (MCP specification 2025-11-25): that the specification's
ToolAnnotationscarries five fields:title,readOnlyHint,destructiveHint,idempotentHintandopenWorldHint. - Transports (MCP specification 2025-11-25): that a stdio client launches the server as a subprocess, and that messages travel over the server's stdin and stdout.
- Key Changes (MCP specification 2026-07-28): that
2026-07-28replaces server-initiated requests such aselicitation/createwith the Multi Round-Trip Requests pattern. - MCP Server Boot Starter (Spring AI reference): that
spring.ai.mcp.server.protocoltakesSSE,STREAMABLEandSTATELESS, and that SSE is deprecated since 2.0.0. - McpServerProperties.java (Spring AI 2.0.1): that
spring.ai.mcp.server.request-timeoutdefaults to 20 seconds. - DefaultMcpSyncRequestContext.java (Spring AI 2.0.1): that
elicitEnabled()reads only the capability the client declared, and thatelicitthrows anIllegalStateExceptionwhen it is false. The two overloads taking a spec discard both the content and the_metamap for any answer other thanACCEPT. - AbstractMcpElicitationMethodCallback.java (Spring AI 2.0.1): that a handler method with any other parameter list is rejected with
Currently only methods with a single ElicitRequest parameter are supported, which is why the annotated method takes a singleElicitRequestparameter. - AbstractClientMcpHandlerRegistry.java (Spring AI 2.0.1): that two
@McpElicitationhandlers for the same client name stop the application at startup, with a message naming both beans and endingOnly one @McpElicitation handler is allowed per client. - McpPredicates.java (Spring AI 2.0.1): the exact wording of the warning a stateless server logs when it skips a method with bidirectional parameters.
- McpSchema.java (MCP Java SDK): that
ElicitRequestis an interface declaringmessage(),meta()andmode().ElicitFormRequestcarriesrequestedSchema,ElicitUrlRequestcarriesurlandelicitationId, andElicitResultcarries an action, a content map and a_metamap. ItsToolAnnotationsrecord adds a sixth component,returnDirect, on top of the five the specification defines. - McpClientFeatures.java (MCP Java SDK): that
fromSyncwraps a synchronous elicitation handler withMono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()), and that the elicitation capability is declared only when a handler exists. - OrderService.java (spring-ai-mcp-course, class_11): the
cancelmethod quoted in this class, which re-reads the order and throws anIllegalStateExceptionwhen it can no longer be cancelled. - DataInitializer.java (spring-ai-mcp-course, class_11): the seeded totals across the 200 orders, 30
PENDINGand 25PROCESSINGamong them, and thatORD-10002is aPENDINGorder for Marcus Adeyemi totalling 34.99. - A01:2021 Broken Access Control (OWASP Top 10): the general name for what goes wrong when
POST /api/confirmations/{id}accepts an answer without checking who is answering.
Next: Class 13: Roots, Notifications and Sampling. Programmatic client configuration, change notifications and sampling.