Skip to main content

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
  • @McpElicitation on 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
Companion code

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.

MechanismWhat it isWho reads itCan it stop the call?
destructiveHint = truean annotation on the toolthe client and the modelNo. A client may warn and a model may be careful, and the tool can still be called.
elicitationa question the server sends while the tool runsthe person at the clientYes. 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.

What the 2026-07-28 revision changes here

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:

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:

order-service/src/main/java/com/themcpguy/supportdesk/orders/mcp/OrderTools.java
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:

ActionWhat the client is sayingWhat cancel_order does
ACCEPT, confirmed truethe person filled in the form and agreedcancels the order, logs it, reports the refund
ACCEPT, confirmed falsethe person filled in the form and said noleaves the order alone: "the confirmation was declined"
ACCEPT, structuredContent() nullthe client accepted without sending the form backthe same as confirmed false
DECLINEthe person refused, or the client could not present the questionleaves the order alone and says both are possible
CANCELthe dialog was closed, or the wait ran outleaves 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:

order-service/src/main/java/com/themcpguy/supportdesk/orders/service/OrderService.java
@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:

elicitation/create, as the client receives it
{
"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 unrelated

The 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:

ValueWhat the connection doesCan the server ask a question mid-call?
STREAMABLEone connection stays open per clientYes, and this is what we have used since Class 2
SSEone connection stays open per clientYes, and it is deprecated as of Spring AI 2.0.0
STATELESSeach request is answered on its own and the connection closesNo, 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:

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:

support-agent/src/main/resources/application.yaml
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:

You run this, in one terminal
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:

You run this, in another
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":

You run this, in PowerShell
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.

If it cancelled without asking you anything

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:

order-service/src/main/java/com/themcpguy/supportdesk/orders/service/OrderService.java
@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:

order-service/src/main/java/com/themcpguy/supportdesk/orders/mcp/OrderTools.java
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:

tools/call update_order_status, with newStatus CANCELLED
{
"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.

Trying it again without restarting

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 asksWhere it appearsWhat happens if you say no
the modelinside its prose answer in the chatthe model decides what to do next, and it can still call the tool
the toolprinted by ConfirmationHandler, our own code, while a tool call is still runningcancelOrder 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 liveDoes it say "this tool asks the user first"?
ToolAnnotationsNo. The five fields are title, readOnlyHint, destructiveHint, idempotentHint and openWorldHint.
the tool descriptionYes, as one more sentence among the others, which is where we put it.
the system promptYes, 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:

support-agent/src/main/java/com/themcpguy/supportdesk/agent/service/SupportAgentService.java
- 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 livesWhat it is made ofWhat can get past it
in the modela line of the system prompta different phrasing of the request, a longer conversation, or a different model
in the toolJava in cancelOrder: elicitEnabled(), elicit(...) and a switch on the actionthe 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:

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:

FieldWhat the page does with it
idmatches the answer it posts back later to the question that was asked
messagethe text to show in the dialog
secondshow 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:

The confirmation event, as the page receives it
{
"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 connectedA progress notificationAn elicitation question
The eventis dropped, and the job carries on to the endcannot be dropped: the server is parked inside cancel_order, waiting
The handlersends it and forgets itchecks 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:

order-service/src/main/java/com/themcpguy/supportdesk/orders/mcp/OrderTools.java
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 startsHandler in the context
-Dspring-boot.run.profiles=cliConfirmationHandler, reading from the terminal
no profile, serving the browserBrowserConfirmationHandler
-Dspring-boot.run.profiles=ollama,cliConfirmationHandler, 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:

support-agent/src/main/java/com/themcpguy/supportdesk/agent/mcp/BrowserConfirmationHandler.java
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:

LimitWhereValue
How long the person hasBrowserConfirmationHandler60 seconds
How long order-service waits for the answerspring.ai.mcp.server.request-timeout90 seconds
How long support-agent waits for the whole tool callspring.ai.mcp.client.request-timeout2 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:

order-service/src/main/resources/application.yaml
spring:
ai:
mcp:
server:
request-timeout: 90s

and in support-agent's, raising the 30 seconds set in Class 6:

support-agent/src/main/resources/application.yaml
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 isWhat it is waiting forFreed when
order-service, inside cancelOrderthe ElicitResultthe person answers, or the 90 second server request-timeout expires
support-agent, inside BrowserConfirmationHandler.confirmsomething to arrive on the queuethe 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:

support-agent/src/main/java/com/themcpguy/supportdesk/agent/web/SupportController.java
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:

support-agent/src/main/java/com/themcpguy/supportdesk/agent/web/SupportController.java
/** 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.

The endpoint takes an answer from anyone

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:

You run this, in one terminal
mvn -pl order-service spring-boot:run
You run this, in another
mvn -pl support-agent spring-boot:run
You run this, in a third
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 dialogWhat the handler returnsORD-10002 afterwards
click Yes, do itACCEPT with confirmed trueCANCELLED, and it moves to the CANCELLED tab
click NoDECLINEstill PENDING
let the countdown finishCANCEL after 60 seconds, which is the last branch of the switchstill 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:

You run this, with the agent still running
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:

Spring AI 2.0.0 and 2.0.1, DefaultMcpSyncRequestContext
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:

You run this, in PowerShell
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 obtainedWho supplies itWhat it costs
elicitationthe person, answering the server's own questionthe tool call waits on a human, so timeouts, threads and dropped connections all apply
the model guessesthe modela guessed shipping address is worse than an admission that the value is missing
the tool fails and the model asksthe person, answering the model's rewordingthe 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

Sources

  • Elicitation (MCP specification 2025-11-25): that elicitation/create is a server-to-client request and that the client declares the capability during initialization. It gives three response actions: accept, decline for an explicit refusal, and cancel for 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 ToolAnnotations carries five fields: title, readOnlyHint, destructiveHint, idempotentHint and openWorldHint.
  • 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-28 replaces server-initiated requests such as elicitation/create with the Multi Round-Trip Requests pattern.
  • MCP Server Boot Starter (Spring AI reference): that spring.ai.mcp.server.protocol takes SSE, STREAMABLE and STATELESS, and that SSE is deprecated since 2.0.0.
  • McpServerProperties.java (Spring AI 2.0.1): that spring.ai.mcp.server.request-timeout defaults to 20 seconds.
  • DefaultMcpSyncRequestContext.java (Spring AI 2.0.1): that elicitEnabled() reads only the capability the client declared, and that elicit throws an IllegalStateException when it is false. The two overloads taking a spec discard both the content and the _meta map for any answer other than ACCEPT.
  • 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 single ElicitRequest parameter.
  • AbstractClientMcpHandlerRegistry.java (Spring AI 2.0.1): that two @McpElicitation handlers for the same client name stop the application at startup, with a message naming both beans and ending Only 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 ElicitRequest is an interface declaring message(), meta() and mode(). ElicitFormRequest carries requestedSchema, ElicitUrlRequest carries url and elicitationId, and ElicitResult carries an action, a content map and a _meta map. Its ToolAnnotations record adds a sixth component, returnDirect, on top of the five the specification defines.
  • McpClientFeatures.java (MCP Java SDK): that fromSync wraps a synchronous elicitation handler with Mono.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 cancel method quoted in this class, which re-reads the order and throws an IllegalStateException when it can no longer be cancelled.
  • DataInitializer.java (spring-ai-mcp-course, class_11): the seeded totals across the 200 orders, 30 PENDING and 25 PROCESSING among them, and that ORD-10002 is a PENDING order 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.