Class 11: Progress and Logging
Duration: ~65 minutes | Level: Advanced | Prerequisites: Class 10: Several Servers at Once.
What We'll Cover
McpSyncRequestContext, the parameter that lets a tool talk back while it is still running- A tool that refreshes every shipped order's estimate in one call, reporting progress as it goes
@McpProgressand@McpLoggingon the client, the two signatures each of them accepts, and why this course takes the notification object rather than its separate fields- The progress token, without which the server's progress calls are dropped, with one warning line in the server's log and no sign of it on the client
- Why the handler receives
0.5when the server sent50 - Carrying the notifications on to the browser, so our frontend app's progress bar moves
This class carries on from Class 10. If you followed along, keep working in the project you
already have. If you skipped it, clone the class_10
branch to start from the same place:
git clone --branch class_10 https://github.com/the-mcp-guy/spring-ai-mcp-course.git
The Server Can Talk Back
Every exchange so far has been one round trip: the client sends a request, the server sends one reply, and no messages pass between the two while the server is working.
MCP allows more than that one round trip. While a tool is running, the server can send notifications back to the client: how far along it is, and what it is doing. A notification is a JSON-RPC message without an id, so the receiver does not reply to it, and the server can send a hundred of them while one request is still open.
Drawn as a sequence, with the tool this class builds:
One request goes out and one result comes back, the same as every tool call since Class 2. The arrows in between are new: notifications the server sends while the call is still open, which the client can show as they arrive.
The server gets this ability through a method parameter. A tool method that declares an McpSyncRequestContext receives one, and the parameter does not appear in the tool's schema:
import org.springframework.ai.mcp.annotation.context.McpSyncRequestContext;
@McpTool(name = "recheck_shipments", description = "...")
public String recheckShipments(McpSyncRequestContext context) {
context.info("Starting");
context.progress(50);
return "done";
}
Note the package: org.springframework.ai.mcp.annotation.context, one level below the annotations themselves.
What the context offers:
| Method | Purpose |
|---|---|
progress(int) | Send a percentage, 0 to 100 |
progress(Consumer<ProgressSpec>) | Send a progress notification with a message |
debug, info, warn, error | Send a log message at that level |
log(Consumer<LoggingSpec>) | Send a log message with more control |
ping() | Check the client is still there |
elicit(...), elicitEnabled() | Ask a person something. Class 12 |
sample(...), sampleEnabled() | Ask the client's model. Class 13 |
roots(), rootsEnabled() | Ask what the client will let us reach. Class 13 |
progress(int) takes a whole percentage from 0 to 100 and rejects anything outside that range. What goes on the wire is a fraction, which matters on the receiving end and is covered below.
A Job Worth Reporting On
Class 10 ended on tool calls running one after another. The answer to that is a server-side tool that does the whole job in one call, and order-service gets one in this class, recheck_shipments.
The support team wants delivery estimates refreshed for every order that has shipped but not arrived. The seed data has 87 of those, and each one needs a carrier lookup. Refreshing them one tool call at a time would mean a model round trip per order:
| What happens | One tool call per order, the Class 10 shape | One recheck_shipments call, this class |
|---|---|---|
| Tool calls | 87 | 1 |
| Model round trips | 87, one per order | 1 |
| What the user sees while it runs | one answer per order, arriving slowly | one progress notification per order, then one summary |
| What the model is charged for | the conversation so far, 87 times over | the conversation once |
The one call has a drawback: it runs for several seconds and shows its result only at the end. That is what the progress notifications are for.
The course project ships with ShipmentService, which stands in for a carrier integration:
- It pauses briefly on each lookup, the way a tracking API would.
- For about one order in seven it comes back with a later date: a week after the parcel shipped, a few days beyond the estimate the order started with. The other six come back unchanged, which is why the tool reports far fewer changes than orders checked.
- Which orders slip, and the date they slip to, are both derived from the order ID and the shipping date. Neither moves, so a second run over the same orders arrives at the same answers, which matters for one of the hints below.
Give OrderTools a field for ShipmentService, next to the OrderService it already has:
private final OrderService orderService;
private final ShipmentService shipmentService;
OrderTools(OrderService orderService, ShipmentService shipmentService) {
this.orderService = orderService;
this.shipmentService = shipmentService;
}
The import is com.themcpguy.supportdesk.orders.service.ShipmentService, from the same package as OrderService. Then add the tool, in the same class:
import org.springframework.ai.mcp.annotation.context.McpSyncRequestContext;
@McpTool(
name = "recheck_shipments",
description = """
Refresh the delivery estimate for every order that has shipped but not
arrived. Takes a while. Returns a summary of what changed.
Use this when the user asks to update or refresh delivery dates in bulk.
""",
annotations = @McpTool.McpAnnotations(
readOnlyHint = false,
destructiveHint = false,
idempotentHint = true,
openWorldHint = true))
public String recheckShipments(McpSyncRequestContext context) {
List<Order> inTransit = orderService.findByStatus("SHIPPED");
context.info("Rechecking %d shipments".formatted(inTransit.size()));
int changed = 0;
for (int i = 0; i < inTransit.size(); i++) {
Order order = inTransit.get(i);
if (shipmentService.refreshEstimate(order)) {
changed++;
context.debug("Updated estimate for %s".formatted(order.orderId()));
}
context.progress((i + 1) * 100 / inTransit.size());
}
context.info("Finished. %d estimates changed.".formatted(changed));
return "Rechecked %d shipments, %d estimates changed.".formatted(inTransit.size(), changed);
}
The four hints tell a client what kind of tool this is. Two of the four values below match the annotation's own defaults, so an IDE marks them as redundant assignments. We set all four anyway: the server publishes the same four values either way, and spelling them out means the declaration says what the tool claims, without you having to recall which defaults apply.
| Hint | What we set | Why | The annotation's default |
|---|---|---|---|
readOnlyHint | false | it writes each new estimate back into the order's shipment record, through ShipmentService.refreshEstimate, so the change survives the call | false |
destructiveHint | false | the only change it makes is to move a delivery date, and no order and no record is deleted | true |
idempotentHint | true | the date each lookup returns comes from the order ID and the shipping date, so a second call writes the same value it found | false |
openWorldHint | true | refreshing an estimate is meant to reach a carrier's service and not only our own data | true |
openWorldHint is the one that differs from the tools we wrote in Class 3. The carrier call is emulated by ShipmentService, which stays inside the application, but the hint describes what the tool does in the system it stands for.
The progress calculation is integer arithmetic on purpose: (i + 1) * 100 / size gives whole percentages, which is the 0 to 100 range progress(int) accepts, and with 87 orders every percentage is distinct and rising. Past a hundred items the same percentage would be computed several times in a row, and the specification says the progress value must increase with each notification. It also asks both sides to rate limit. For a longer list, remember the last percentage sent and call context.progress(...) only when it changes:
int last = -1;
for (int i = 0; i < items.size(); i++) {
int percent = (i + 1) * 100 / items.size();
if (percent != last) {
context.progress(percent);
last = percent;
}
}
Handling It on the Client
Notifications arrive whether or not anything is listening, and so far support-agent does not listen for them. To use them we write a new class there, with one handler method per kind of notification: @McpProgress marks the method that receives progress updates, and @McpLogging the method that receives log messages. Spring AI finds both by their annotations, in the same way the server side finds @McpTool.
Create support-agent/src/main/java/com/themcpguy/supportdesk/agent/mcp/OrderServerNotifications.java, next to the McpResources class from Class 8:
package com.themcpguy.supportdesk.agent.mcp;
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
import io.modelcontextprotocol.spec.McpSchema.ProgressNotification;
import org.springframework.ai.mcp.annotation.McpLogging;
import org.springframework.ai.mcp.annotation.McpProgress;
import org.springframework.stereotype.Component;
@Component
public class OrderServerNotifications {
@McpProgress(clients = "orders")
public void onProgress(ProgressNotification notification) {
// progress() is a fraction, not the 0-100 the server passed.
// "Calculating the percentage" below explains why.
System.out.printf(" [%s] %d%%%n", notification.progressToken(),
Math.round(notification.progress() * 100));
}
@McpLogging(clients = "orders")
public void onLog(LoggingMessageNotification notification) {
System.out.printf(" %s %s%n", notification.level(), notification.data());
}
}
Two ways to write a handler
A handler takes either the whole notification or its separate fields. The signature is what tells Spring AI which one you meant, so the number of parameters and their types have to match exactly. If they do not, the application fails on startup.
| Annotation | One parameter | Three parameters |
|---|---|---|
@McpProgress | ProgressNotification | Double progress, String progressToken, String total |
@McpLogging | LoggingMessageNotification | LoggingLevel level, String logger, String data |
Here is what you get from a handler declared as (String progressToken, double progress):
Method must have either 1 parameter (ProgressNotification) or 3 parameters
(Double, String, String): onProgress in
com.themcpguy.supportdesk.agent.mcp.OrderServerNotifications has 2 parameters
Get the count right and the order wrong, and the message names the position that is wrong instead: First parameter must be of type Double or double.
Should you use one parameter or three?
Use 1 parameter (ProgressNotification) if:
- You pass the data along. Forwarding one ready-made object to another service, an event bus, or the browser channel we build later in this class is less work than reassembling one.
- You want the signature to survive later releases. Fields added to the record do not change it.
Use 3 parameters (individual fields) if:
- You want the values as local variables, without calling
.progress()or.data()on an object. - You want to keep Spring AI's
McpSchemaclasses out of your own code. Reading the parameters at the method boundary keeps that logic isolated.
| Feature | 1 parameter (ProgressNotification) | 3 parameters (unpacked fields) |
|---|---|---|
| Readability | High: a one-argument signature | Medium: three arguments in a fixed order |
| Ease of use | You call getters on the object | You get local variables directly |
| What arrives | Every field the notification carries | Three of them, without message or _meta |
| Maintenance | Safe from later additions to the record | Misses anything added later, and breaks if the unpacked types change |
| Best for | Forwarding events, and handling that may grow | Quick logging and direct calculations |
It matches ordinary Java object-oriented practice, and it keeps your application flexible while MCP continues to mature. That is what this course does in both handlers.
ProgressNotification is a record with five fields, and a three-parameter handler is given three of them:
| Field | Java type | What it carries | Reaches a three-parameter handler |
|---|---|---|---|
progressToken | Object | the token the client put in the request's _meta | yes, as the second parameter, declared String |
progress | Double | how far along the server is | yes, as the first parameter |
total | Double | the scale the progress value is measured against | yes, as the third parameter, converted to String |
message | String | what the server is doing right now | no |
_meta | Map<String, Object> | anything else the server attached | no |
LoggingMessageNotification has four fields, and its three-parameter form leaves out _meta in the same way. These records also gain fields over time: message was added to ProgressNotification in spec revision 2025-03-26, and _meta to the notification types in 2025-06-18. Take the object, and your handler still compiles after a change like that, and you can read the new field whenever you want it. Take the fields, and reading a new one means waiting for a Spring AI release, then editing your method signature.
The message on a progress notification is a short line of text, "Reticulating splines..." in the specification's own example. A Spring AI server sends one like this:
context.progress(spec -> spec.percentage(40).message("Checking ORD-10005"));
The two forms also declare the token as different Java types. The specification allows a progress token to be a string or a number, so the record declares progressToken as an Object, which holds either. The three-parameter form declares it as a String, which is fine while your client sends a string, and ours does: the next section uses the conversation ID. Send a number instead and every notification fails with IllegalArgumentException: argument type mismatch, wrapped in McpProgressMethodException.
Spring AI's reference documentation for @McpProgress shows a third option, and the README of the mcp-annotations project it is built on shows the same one:
@McpProgress(clients = "orders")
public void onProgress(String progressToken, double progress, Double total, String message) { ... }
No released version accepts it. spring-ai-mcp-annotations 2.0.0 and 2.0.1, and the project's main branch, all check for one parameter or three, so a four-parameter handler stops the application at startup with the message above.
A later release may add it, since it is the only form that hands message to the method. Check the version you have before writing a handler that way. On 2.0.1, the notification object is the only way to reach that field.
Calculating the percentage
The number that arrives is a fraction. This is what context.progress(50) puts on the wire:
{
"progressToken": "cli",
"progress": 0.5,
"total": 1.0
}
That is why our handler multiplies by 100. The two ways of sending progress use different scales:
| What the server calls | progress | total |
|---|---|---|
context.progress(50) | 0.5 | 1.0 |
context.progress(spec -> spec.percentage(50)) | 50.0 | 100.0 |
Our server uses the first, so multiplying by 100 is right here. progress / total * 100 covers both, for a server that uses both.
Which connection a handler serves
Both handlers carry clients = "orders", and that argument matters because by this class the agent is holding three MCP connections at once. They are orders, our own server from Class 6, plus knowledge-base and knowledge-base-archive, the two filesystem servers from Class 10. Any of the three can send progress and log notifications, and all of them arrive at the same client inside support-agent. Without something to separate them, a handler would run for every server's messages.
clients is that something. Its value is a connection name, which is the key you chose under connections: in the agent's application.yaml. Class 6 named ours orders:
spring:
ai:
mcp:
client:
streamable-http:
connections:
orders:
url: http://localhost:8080
So @McpProgress(clients = "orders") reads as: run this method when a progress notification arrives from the orders connection. Spring AI keeps the handlers in a map, keyed by connection name, and every incoming notification goes through the same lookup:
The no branch is where a misspelled connection name ends up: the notification arrives, and is then dropped.
clients is a String[], so one handler can serve several connections at once:
@McpLogging(clients = { "orders", "knowledge-base" })
The clients element must always be given, so leaving the argument out fails to compile:
error: annotation @McpProgress is missing a default value for the element 'clients'
A name that does not match any configured connection compiles, and the handler then does not run. That is the usual explanation when a handler looks like it is being ignored, and one log line tells the two cases apart. Set org.springframework.ai.mcp to DEBUG in the agent's logging block, and every notification that reaches the client writes a line like this:
Handling progress notification for client orders
Spring AI writes that line before it looks for a handler. So if the line appears and your handler stays quiet, the notification did arrive, and the name in clients is the thing to check. If that line is missing, the notification did not arrive, and the cause is further back: usually the progress token, which the next section covers.
The Progress Token
A client only receives progress notifications for a request it attached a progress token to. Without one, the server's progress(...) calls are dropped. The server's log says so, once per dropped call: WARN ... Progress notification not supported by the client!, written by DefaultMcpSyncRequestContext. The client side does not report it, so nothing appears in the agent's terminal. Nothing we built so far sends a token, so this is the last piece before the demo works.
The token starts in our own code and has to reach the server, and Spring AI gives us one place to put it: the tool context, a map you attach to a prompt call. Anything in that map is sent along with the tool call, so the token goes in there. In SupportAgentService, add toolContext to each of the prompt chains (chat, chatWithPolicy and chatStream), and import java.util.Map;:
chatClient.prompt()
.advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, conversationId))
.toolContext(Map.of("progressToken", conversationId))
.user(userMessage)
.call()
.content();
That one line is the whole change. The token then travels like this:
The second hop is the one worth knowing about. When the model asks for a tool, Spring AI copies every entry of the tool context that has a value into the _meta field of the outgoing tools/call request, through a ToolContextToMcpMetaConverter. _meta is where the MCP specification says a progress token belongs, so order-service finds it there without any further work on our side. This is what arrives:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "recheck_shipments",
"arguments": {},
"_meta": { "progressToken": "cli" }
}
}
The conversation ID is a deliberate choice for the token. It is the only identifier a notification carries, so each notification can be traced back to the conversation that started the job. The browser section at the end of this class depends on that: it is how a notification finds the page that asked the question.
The tool context reaches _meta through a ToolContextToMcpMetaConverter. Class 13 replaces the default one to pass an audit field through, and a converter that copies only its own keys drops the progress token with everything else.
The progress display then stays at zero while the server keeps calling context.progress(...). The agent's terminal does not say anything about it. The server's terminal does: one WARN ... Progress notification not supported by the client! line per call. Class 13's converter passes progressToken through for this reason.
Reading the token inside the tool
recheckShipments does not touch the token itself. context.progress(...) reads it from the request and addresses each notification for us, which is the whole reason the tool takes an McpSyncRequestContext rather than doing this by hand.
A tool can still read the value if it wants it. There are two ways, and they return the same thing. One puts it in a parameter:
import org.springframework.ai.mcp.annotation.McpProgressToken;
public String recheckShipments(McpSyncRequestContext context,
@McpProgressToken String progressToken) { ... }
The other asks the context for the request it is serving:
Object progressToken = context.request().progressToken();
Leave recheckShipments as it is. Neither line is part of this class, and they are here so the annotation is recognisable when you meet it in someone else's server. The reason to reach for it is your own logging. Writing the token into the server's logs means a report of "the bar stopped at 40%" can be matched against the log of that exact call, which is otherwise one recheck_shipments among many.
Three things to know before using it:
- The parameter stays out of the schema. Spring AI skips any parameter carrying
@McpProgressTokenwhen it generates the tool's input schema, in the same way it skipsMcpSyncRequestContext. The model cannot see it or fill it in. - It is
nullwhen the client did not send a token. Progress tokens are optional, so a client that never asks for progress leaves it empty. Check it before using it. - The
Stringin that signature is an assumption. The value arrives as anObject, because the specification allows a token to be a string or a number, andStringholds only while every client sends strings.Objectis the safer declaration for a server facing clients you do not control.
Watch It Run
For now we test this in the terminal. Start the agent with the cli profile from Class 7: it runs SupportCli, which reads questions from standard input, so what we type and what the server sends back appear in the same window. Without the profile the agent serves HTTP and waits for the browser.
The frontend's progress bar comes at the end of the class. The terminal comes first because nothing else has to be built to carry the notifications there.
mvn -pl order-service spring-boot:run
mvn -pl support-agent spring-boot:run -Dspring-boot.run.profiles=cli
On Windows: quoting the profile flag (click to expand)
PowerShell splits the unquoted -D 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"
Then ask for the job:
Refresh the delivery estimates for everything that's still in transit
INFO Rechecking 87 shipments
[cli] 1%
[cli] 2%
...
[cli] 100%
INFO Finished. 13 estimates changed.
Agent: The delivery estimates for all in-transit orders have been refreshed. The
system rechecked 87 shipments, and 13 delivery estimates were updated.
The agent printed one progress notification per shipped order, each addressed to the conversation ID we used as the progress token. The model made a single tool call: the percentages arrived while that call was still open, and the final sentence came back as the ordinary tool result when the loop finished.
Each of the 87 carrier lookups pauses briefly, so the job takes a few seconds, long enough to watch the percentages arrive.
Ask for the same job again and the tool does the same work, reports the same progress, and leaves every date where it is:
INFO Rechecking 87 shipments
[cli] 1%
...
[cli] 100%
INFO Finished. 0 estimates changed.
That second run is the evidence for idempotentHint = true: the lookups wrote back the values they found, so every date stayed where the first run put it. A version that moved the date on every call would have to declare idempotentHint = false, and a client that retried after a timeout would then push every estimate out twice.
Showing Progress in the Browser
The notifications arrive inside support-agent, on the connection it holds to order-service. The browser is not involved in that. It sent one HTTP request, POST /api/chat, and it is waiting for one answer, so the agent cannot send it anything until the whole job has finished. Progress that arrives after the job has finished is useless.
The browser needs a second connection, opened before the job starts and kept open while it runs. A server-sent event stream, usually shortened to SSE, is exactly that: the browser opens it once, and the agent writes messages to it whenever it has one.
Our frontend app opens that stream when the page loads. We do not change the frontend, so these two lines decide what we have to build:
source = new EventSource(`/api/events?conversationId=${conversationId.current}`)
source.addEventListener('progress', (e) => setProgress(JSON.parse(e.data)))
The agent needs GET /api/events, holding one connection open per conversation, and it has to push events named progress carrying a percent field, because the bar's width is progress.percent.
The channel
SseEmitter is a Spring MVC class that stands for an HTTP response left open. We create one ourselves and return it from a controller method, which we write in the next section. Spring MVC sees that return type and, instead of completing the response, keeps it open and hands the request thread back to the pool. Every later call to send on the object we created writes one event into that still-open response, and the browser receives it straight away.
The stream is opened once, when the page loads, and written to much later by the thread that is handling an MCP notification:
The last two arrows happen after SupportController has returned. Our reference to the emitter stays valid, so a notification arriving ten seconds later can still write to the page.
The emitter has to be stored somewhere, and a single field is not enough: two browser tabs mean two conversations and two open streams. A notification names one conversation, so the conversation ID is the key and the emitter is the value. That is a map, and it is a ConcurrentHashMap because more than one thread reaches it, each browser opening its stream on its own request thread while other requests are being served.
One class holds that map and the two operations we need on it: store a new connection, and write an event to the connection for one conversation. Create support-agent/src/main/java/com/themcpguy/supportdesk/agent/service/BrowserChannel.java:
package com.themcpguy.supportdesk.agent.service;
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 BrowserChannel {
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 progress(String conversationId, int percent) {
send(conversationId, "progress", Map.of("percent", percent));
}
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);
}
}
}
SseEmitter is Spring MVC's own type for this, so no library is added. Five details in that class are worth knowing:
- The map is keyed by conversation ID, the same value we chose for the progress token in the previous section, so sending a notification to the right page is one map lookup.
new SseEmitter(0L)turns the timeout off. The default is 30 seconds, after which Spring completes the response and the browser reconnects. Zero leaves the connection open until the page closes it.- The three callbacks remove the entry. A stream ends when the browser closes the page, or when a write to it fails. Spring MVC calls the matching callback, and we use it to delete that conversation from the map. Without that, the map keeps every emitter ever opened, and that is a memory leak.
onTimeoutstays quiet while the timeout is zero. sendreturns early when nobody is watching. On the command line we do not have a browser, so nobody has opened a stream for that conversation. The lookup comes back empty, and the method returns without sending anything. That is why the same handler works in the terminal and in the browser.- A second
openfor the same conversation replaces the first. Theputcall overwrites the entry, and the emitter it displaces is left with its HTTP response still open. A page reload does exactly that, so a deployed version should callcomplete()on the displaced emitter before storing the new one.
The endpoint
SupportController needs the channel injected. Add the field and widen the constructor:
private final SupportAgentService agent;
private final RefundEmailService refundEmails;
private final BrowserChannel channel;
SupportController(SupportAgentService agent, RefundEmailService refundEmails,
BrowserChannel channel) {
this.agent = agent;
this.refundEmails = refundEmails;
this.channel = channel;
}
Then the endpoint itself:
/** Opens the event stream for one conversation. The browser calls this when the page loads. */
@GetMapping(value = "/api/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter events(@RequestParam String conversationId) {
return channel.open(conversationId);
}
The imports are org.springframework.http.MediaType, org.springframework.web.bind.annotation.GetMapping, org.springframework.web.bind.annotation.RequestParam and org.springframework.web.servlet.mvc.method.annotation.SseEmitter.
channel.open(conversationId) creates the emitter, stores it under that conversation, and returns it, so this method is one line. produces = TEXT_EVENT_STREAM_VALUE sets the text/event-stream content type, which is what tells the browser's EventSource that this response is a stream of events.
It opens the stream for whatever conversation ID it is given. On your own machine, running without a login, that is fine. On a deployed desk anyone who guessed another conversation's ID would receive that conversation's progress events. That is broken object level authorization, and the fix is to check the ID against the signed-in user before opening the stream.
Sending each notification
OrderServerNotifications gets the channel and one more line:
private final BrowserChannel channel;
OrderServerNotifications(BrowserChannel channel) {
this.channel = channel;
}
@McpProgress(clients = "orders")
public void onProgress(ProgressNotification notification) {
Object token = notification.progressToken();
int percent = (int) Math.round(notification.progress() * 100);
System.out.printf(" [%s] %d%%%n", token, percent);
if (token != null) {
channel.progress(token.toString(), percent);
}
}
The import is com.themcpguy.supportdesk.agent.service.BrowserChannel.
The token is the conversation ID, and the channel is keyed by conversation ID, so channel.progress(token.toString(), percent) reaches the page that asked the question. If we had used a random string as the token, we would need a second map here to look up which conversation it belonged to.
Log messages stay in the terminal. A progress notification carries the token, so its handler knows which page it belongs to. A log message carries a level, a logger name and some text, and none of those says which conversation caused it, so onLog cannot tell which page to send it to. Class 12 has the same problem with elicitation, where the question also arrives without a conversation attached, and solves it by having the server put one on the question.
Watch the bar move
Three terminals this time, and the agent starts without the cli profile so that it serves HTTP:
mvn -pl order-service spring-boot:run
mvn -pl support-agent spring-boot:run
cd frontend && npm run dev
Open http://localhost:5173, and ask the same thing in the chat panel:
Refresh the delivery estimates for everything that's still in transit
The bar fills as the percentages arrive, and the agent's terminal keeps printing the same lines it printed before, because the handler still does both.
Logging Levels
@McpLogging receives whatever level the server sent. A client can also tell the server the lowest level it wants. The call sits on McpSyncClient, so McpInspector's loop is a natural place to add it, next to the other capability checks:
if (capabilities.logging() != null) {
client.setLoggingLevel(LoggingLevel.INFO);
}
The import is io.modelcontextprotocol.spec.McpSchema.LoggingLevel. The MCP Java SDK starts every session at INFO already, which is why the two INFO lines appeared in the run above and the debug ones did not:
| The call in the tool | Level sent | Arrives at the SDK default of INFO | Arrives after setLoggingLevel(DEBUG) |
|---|---|---|---|
context.debug(...) | debug | no | yes |
context.info(...) | info | yes | yes |
context.warn(...) | warning | yes | yes |
context.error(...) | error | yes | yes |
So the call is how a client asks for something other than that default: DEBUG while you are developing, or WARNING for a client that wants only the problems. The debug line in recheckShipments reports each changed order, which is useful while developing and noise in a support conversation, so the INFO default suits it. The filtering happens on the server, and a message below the level does not go on the wire at all.
A log message travels to a client the server may not control. The specification says it must not carry:
- credentials or secrets,
- personal identifying information,
- internal system details that would help an attacker.
That rule is separate from the server's own log file, which stays on the server. The wider risk is catalogued as OWASP LLM02:2025, Sensitive Information Disclosure.
Spec revision 2026-07-28 deprecates Logging under SEP-2577, which does not change anything on the wire. A separate change, SEP-2575, removes logging/setLevel. In that revision a client asks for log messages one request at a time, by putting io.modelcontextprotocol/logLevel in the request's _meta, and a server must not send log messages for a request that leaves the field out. Progress notifications are not affected.
This course targets 2025-11-25, which Spring AI 2.0.0 implements, so both work today, and SEP-2596 guarantees at least twelve months before anything deprecated is removed. For a server that wants its own diagnostics rather than the client's, ordinary application logging is unaffected by any of this. Class 13 covers the wider change.
What We Built
A tool that does the whole job in one call and reports progress and log messages while it runs. Handlers in the agent print both as they arrive, and an SSE channel carries the progress on to the browser. Every class up to now sent one request and waited for one reply, and this is the first time a server sends anything back before the reply.
Class 12 keeps the request open for a different reason: the server stops partway through a tool call to ask the person a question, and waits for the answer before it carries on.
Further Reading
- MCP specification: Progress: the whole utility page, including which tokens a notification may reference and how much a receiver is free to decide for itself.
- MCP specification: Logging: the eight severity levels with an example use for each, and the shape of a
notifications/messageon the wire. - MCP specification: Overview, the
_metafield: what_metais for and the naming rules for its keys. - MCP Client Annotations, Spring AI reference: Spring AI's own page for
@McpProgressand@McpLogging, alongside@McpSampling,@McpElicitationand the list-changed annotations. - Tool Calling, Spring AI reference: the tool context, how to attach one to a prompt call, and what a tool can read from it.
- Using server-sent events, MDN: the browser side of the channel we build here, including the event format on the wire and how
EventSourcereconnects on its own. - Asynchronous Requests, Spring Framework reference:
DeferredResult,Callableand the other async return types Spring MVC accepts, what happens when a client disconnects, and where the async timeout is configured. - Key Changes in spec revision 2026-07-28: everything that revision changed, from dropping the
initializehandshake to the newsubscriptions/listenstream.
Sources
- MCP specification: Progress: the token travels in the request's
_metaand must be a string or an integer, the progress value must increase with each notification, both sides should rate limit, and the "Reticulating splines..." message example. - MCP specification: Logging:
logging/setLevelexists in2025-11-25, and a log message must not carry credentials, personal identifying information or internal system details. - MCP specification: Overview:
_metais where a progress token belongs. - MCP specification: Tools: the four behavioural hints
readOnlyHint,destructiveHint,idempotentHintandopenWorldHint. - Changelog, spec revision 2025-03-26:
messagewas added toProgressNotificationin that revision. - Changelog, spec revision 2025-06-18:
_metawas added to the notification types in that revision. - Key Changes in spec revision 2026-07-28:
logging/setLevelis removed, and a client sets the level per request withio.modelcontextprotocol/logLevelin_meta. - SEP-2577, Deprecate Roots, Sampling and Logging: the deprecation is advisory and leaves the wire protocol unchanged.
- SEP-2575, Make MCP Stateless: the change that removes
logging/setLeveland introduces the per-request log level. - SEP-2596, feature lifecycle and deprecation policy: the twelve-month minimum between deprecating a feature and the earliest revision that may remove it.
- MCP Client Annotations, Spring AI reference: the four-parameter
@McpProgresshandler shown there, which2.0.0,2.0.1andmainall reject at startup. mcp-annotationsproject: the handler validation that accepts one parameter or three, the orderDouble progress,String progressToken,String total, and the two startup error messages quoted above.SseEmitterjavadoc, Spring Framework: the constructor that takes a timeout, and the completion, timeout and error callbacks.- Asynchronous Requests, Spring Framework reference: Spring MVC keeps the response open and returns the request thread to the pool, and the default timeout for an async request comes from the Servlet container unless it is set explicitly.
- API1:2023 Broken Object Level Authorization, OWASP: why an endpoint keyed only by a caller-supplied ID needs an authorization check once it is deployed.
- LLM02:2025 Sensitive Information Disclosure, OWASP: the wider risk behind the specification's rule on what a log message may carry.
Next: Class 12: Elicitation. A tool that stops and asks a person before it cancels an order.