Skip to main content

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
  • @McpProgress and @McpLogging on 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.5 when the server sent 50
  • Carrying the notifications on to the browser, so our frontend app's progress bar moves
Companion code

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:

MethodPurpose
progress(int)Send a percentage, 0 to 100
progress(Consumer<ProgressSpec>)Send a progress notification with a message
debug, info, warn, errorSend 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 happensOne tool call per order, the Class 10 shapeOne recheck_shipments call, this class
Tool calls871
Model round trips87, one per order1
What the user sees while it runsone answer per order, arriving slowlyone progress notification per order, then one summary
What the model is charged forthe conversation so far, 87 times overthe 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:

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

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

HintWhat we setWhyThe annotation's default
readOnlyHintfalseit writes each new estimate back into the order's shipment record, through ShipmentService.refreshEstimate, so the change survives the callfalse
destructiveHintfalsethe only change it makes is to move a delivery date, and no order and no record is deletedtrue
idempotentHinttruethe date each lookup returns comes from the order ID and the shipping date, so a second call writes the same value it foundfalse
openWorldHinttruerefreshing an estimate is meant to reach a carrier's service and not only our own datatrue

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:

Illustrative, for a list longer than a hundred items
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:

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

AnnotationOne parameterThree parameters
@McpProgressProgressNotificationDouble progress, String progressToken, String total
@McpLoggingLoggingMessageNotificationLoggingLevel 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 McpSchema classes out of your own code. Reading the parameters at the method boundary keeps that logic isolated.
Feature1 parameter (ProgressNotification)3 parameters (unpacked fields)
ReadabilityHigh: a one-argument signatureMedium: three arguments in a fixed order
Ease of useYou call getters on the objectYou get local variables directly
What arrivesEvery field the notification carriesThree of them, without message or _meta
MaintenanceSafe from later additions to the recordMisses anything added later, and breaks if the unpacked types change
Best forForwarding events, and handling that may growQuick logging and direct calculations
If you are unsure, start with 1 parameter

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:

FieldJava typeWhat it carriesReaches a three-parameter handler
progressTokenObjectthe token the client put in the request's _metayes, as the second parameter, declared String
progressDoublehow far along the server isyes, as the first parameter
totalDoublethe scale the progress value is measured againstyes, as the third parameter, converted to String
messageStringwhat the server is doing right nowno
_metaMap<String, Object>anything else the server attachedno

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.

The reference documentation shows a form with four parameters

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:

The params of notifications/progress
{
"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 callsprogresstotal
context.progress(50)0.51.0
context.progress(spec -> spec.percentage(50))50.0100.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:

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

The tools/call request order-service receives
{
"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.

A custom meta converter can switch this off

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 @McpProgressToken when it generates the tool's input schema, in the same way it skips McpSyncRequestContext. The model cannot see it or fill it in.
  • It is null when 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 String in that signature is an assumption. The value arrives as an Object, because the specification allows a token to be a string or a number, and String holds only while every client sends strings. Object is 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.

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

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

frontend/src/components/ChatPanel.jsx, which we do not edit
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:

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. onTimeout stays quiet while the timeout is zero.
  • send returns 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 open for the same conversation replaces the first. The put call 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 call complete() on the displaced emitter before storing the new one.

The endpoint

SupportController needs the channel injected. Add the field and widen the constructor:

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

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

The endpoint does not check who is asking

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:

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

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

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 toolLevel sentArrives at the SDK default of INFOArrives after setLoggingLevel(DEBUG)
context.debug(...)debugnoyes
context.info(...)infoyesyes
context.warn(...)warningyesyes
context.error(...)erroryesyes

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.

Logging is being retired

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

Sources


Next: Class 12: Elicitation. A tool that stops and asks a person before it cancels an order.