Class 12: Elicitation
Duration: ~40 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- Holding an MCP request open while a human decides
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 a shipment, starts a refund, and there is no uncancel_order.
Class 3 set destructiveHint = true for exactly this case, and a hint is all it is. Nothing stops a model calling the tool, and the model's judgement is the only thing between a phrase like "yeah cancel that one" and a cancelled order. Where the consequence is real, that is not enough.
Elicitation is the protocol's answer. 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.
Asking From the Server
The shape of the answer is a Java type. Define what we want back:
package com.themcpguy.supportdesk.orders.mcp;
import org.springframework.ai.mcp.annotation.McpToolParam;
public record CancellationConfirmation(
@McpToolParam(description = "Confirm this order should be cancelled", required = true)
boolean confirmed,
@McpToolParam(description = "Optional note recorded against the cancellation")
String note) {
}
Spring AI generates a schema from that record and sends it with the request, so the client knows what to ask for and what shape to return.
Then the tool:
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 (!CANCELLABLE.contains(order.status())) {
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().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 user said no.".formatted(orderId);
case CANCEL -> "Order %s was not cancelled: the user dismissed the question.".formatted(orderId);
};
}
Four things there are worth reading closely.
elicitEnabled() is checked first. Not every client can ask a person. A batch job or a client with no user interface says so during the handshake, and calling elicit anyway would fail. The message returned instead tells the model what to say and gives the user another route.
There are three answers, not two. ACCEPT means the person answered and the payload is in structuredContent(). DECLINE means they said no. CANCEL means they dismissed the question without answering: closed the dialog, walked away, timed out. Treating CANCEL as DECLINE is usually right and is still a decision worth making on purpose, because the two mean different things to anyone reading the logs afterwards.
ACCEPT does not mean yes. The person answered, and what they answered is in the record. Our schema has a confirmed boolean, so an accepted response carrying confirmed = false is a person who filled in the form and said no. The check inside the ACCEPT branch is not redundant.
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.
Stateful Only
elicit and sample need a connection the server can send a request back down. That rules out one of the three protocol values from Class 2.
With spring.ai.mcp.server.protocol: STATELESS, Spring AI does not fail. It filters the method out and logs a warning at startup, so the server runs and cancel_order is simply not in tools/list. Class 15 goes into what stateless costs; this is the sharpest example of it.
STREAMABLE, which we have used since Class 2, is stateful and supports it. So does stdio.
The failure mode is worth naming because it is quiet. A tool that vanishes from the list is harder to notice than one that fails loudly, and the reason appears once, at startup, in a line nobody is reading.
Answering On the Client
A handler decides what to do with the question. Its signature is fixed and there is only one permitted form:
package com.themcpguy.supportdesk.agent;
import java.util.Map;
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
import org.springframework.ai.mcp.annotation.McpElicitation;
import org.springframework.stereotype.Component;
@Component
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 rather than a record, and that matters when you want the schema. It declares message(), meta() and mode(). The schema generated from CancellationConfirmation is on the form variant:
Map<String, Object> schema = request instanceof ElicitFormRequest form
? form.requestedSchema()
: null;
The reason for the split is that elicitation has a second mode, where the server sends a URL for the user to visit rather than a form to fill in. A handler that only understands forms should check, because mode() tells it which arrived. Our dialog asks one question, so a client rendering the full schema would produce a better form than we do here.
clients = "orders" ties this to the connection from Class 6. Without a handler on a matching connection, the server's elicit call has nobody to ask.
Try it:
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.
The Part That Is Actually Hard
The command-line handler works because System.in blocks, and blocking is exactly right: the MCP request should stay open until the person answers.
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.
Three pieces do that. Take them in order.
The channel to the browser
One open SSE connection per conversation, which the progress and log notifications from Class 11 use as well. Create ConfirmationChannel.java:
package com.themcpguy.supportdesk.agent;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@Component
public class ConfirmationChannel {
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
public SseEmitter open(String conversationId) {
SseEmitter emitter = new SseEmitter(0L); // no timeout; the browser closes it
emitter.onCompletion(() -> emitters.remove(conversationId, emitter));
emitter.onTimeout(() -> emitters.remove(conversationId, emitter));
emitter.onError(e -> emitters.remove(conversationId, emitter));
emitters.put(conversationId, emitter);
return emitter;
}
public void ask(String conversationId, String id, String message, Object schema) {
send(conversationId, "confirmation", Map.of("id", id, "message", message));
}
public void progress(String conversationId, int percent) {
send(conversationId, "progress", Map.of("percent", percent));
}
public boolean isWatching(String conversationId) {
return emitters.containsKey(conversationId);
}
private void send(String conversationId, String event, Object payload) {
SseEmitter emitter = emitters.get(conversationId);
if (emitter == null) {
return; // nobody is watching, which is normal on the command line
}
try {
emitter.send(SseEmitter.event().name(event).data(payload));
}
catch (IOException | IllegalStateException e) {
emitters.remove(conversationId, emitter);
}
}
}
Knowing which conversation to ask
An ElicitRequest carries the server's message and nothing about which conversation it belongs to. Everything from the controller down to the handler runs on one thread, so a thread-local bridges it. Create ConversationContext.java:
package com.themcpguy.supportdesk.agent;
import org.springframework.stereotype.Component;
@Component
public class ConversationContext {
private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
public void set(String conversationId) {
CURRENT.set(conversationId);
}
public String current() {
return CURRENT.get();
}
public void clear() {
CURRENT.remove();
}
}
A thread-local is enough here and would not be if the tool call ran on another thread. With spring.ai.mcp.client.type: ASYNC from Class 15 it does, and this would have to become something the reactive context carries.
The handler
A pending-request map, and a wait:
package com.themcpguy.supportdesk.agent;
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.ElicitRequest;
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
import org.springframework.ai.mcp.annotation.McpElicitation;
import org.springframework.stereotype.Component;
@Component
public class BrowserConfirmationHandler {
private final Map<String, SynchronousQueue<ElicitResult>> pending = new ConcurrentHashMap<>();
private final ConfirmationChannel channel;
private final ConversationContext conversations;
BrowserConfirmationHandler(ConfirmationChannel channel, ConversationContext conversations) {
this.conversations = conversations;
this.channel = channel;
}
@McpElicitation(clients = "orders")
public ElicitResult confirm(ElicitRequest request) {
String conversationId = conversations.current();
// 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);
ElicitResult answer = slot.poll(2, TimeUnit.MINUTES);
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, ElicitResult result) {
SynchronousQueue<ElicitResult> slot = pending.get(id);
if (slot != null) {
slot.offer(result);
}
}
}
Three decisions in there matter more than the mechanics.
The timeout has to be shorter than the MCP request timeout. spring.ai.mcp.client.request-timeout is 30 seconds in our configuration, and a person will not answer a dialog in 30 seconds. Either the timeout goes up for this connection, or the elicitation gives up first and returns CANCEL. Returning CANCEL ourselves is better: the server gets a real answer and can say something useful, rather than the whole tool call failing on a timeout.
A dropped browser must not leave the thread parked forever. poll with a bound rather than take is what makes that true.
The finally removes the entry. A pending map that only grows is a leak, and this one holds a queue per unanswered question.
Three endpoints in the controller
Add these to SupportController from Class 7. The first opens the stream, the second wakes the parked thread, and the third is the change to chat that records which conversation the request belongs to:
/** The stream progress, log messages and confirmation requests arrive on. */
@GetMapping(value = "/api/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter events(@RequestParam String conversationId) {
return channel.open(conversationId);
}
/** Wakes the thread parked inside BrowserConfirmationHandler. */
@PostMapping("/api/confirmations/{id}")
public void answer(@PathVariable String id, @RequestBody AnswerRequest body) {
confirmations.answer(id, body.confirmed(), body.note());
}
@PostMapping("/api/chat")
public ChatReply chat(@RequestBody ChatRequest request) {
conversations.set(request.conversationId());
try {
return new ChatReply(agent.chatWithPolicy(request.conversationId(), request.message()));
}
finally {
conversations.clear();
}
}
public record AnswerRequest(boolean confirmed, String note) {}
The try/finally around chat is what makes the thread-local correct. Setting it without clearing it leaves a stale conversation ID on a pooled request thread, and the next confirmation on that thread goes to the wrong browser.
Check It
This is the one class that needs the browser, because the whole point is a person answering. 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
The dialog from Class 1 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. Click Yes, do it and the answer completes. Click No and the order stays PENDING.
Check what actually happened rather than trusting the sentence:
curl -s http://localhost:8080/api/orders/ORD-10002 | grep -o '"status":"[A-Z]*"'
Restarting order-service puts it back to PENDING, because the database is in memory.
Try it from the command line too, with -Dspring-boot.run.profiles=cli. Nothing is watching the browser channel, so the handler declines and the agent reports that the order was not cancelled. That is the safe answer rather than a bug.
What This Is For
It is tempting to read elicitation as a confirmation dialog and stop there. It is more general: it is how a server asks for anything it does not have and the model should not invent.
A missing shipping address. Which of three matching customers was meant. A reason code from a list only the person knows. In each case the alternative is the model guessing, or the tool failing and the model asking in the chat, which puts the question through a component that may reword it.
The cost is that the tool call is now waiting on a human, so everything about timeouts, threads and dropped connections applies. That is why it belongs on the small number of tools where a wrong answer matters.
What We Built
cancel_order cannot cancel an order without a person agreeing, and it degrades sensibly on a client that cannot ask. The confirmation travels from the server, through the agent, to a dialog, and back, inside one tool call.
Class 13 covers the rest of the two-way traffic.
Next: Class 13: Roots, Notifications and Sampling. Programmatic client configuration, change notifications, and one feature the specification is retiring.