Skip to main content

Class 11: Progress and Logging

Duration: ~30 minutes | Level: Intermediate → Advanced | Prerequisites: Class 10: Several Servers at Once.


What We'll Cover

  • McpSyncRequestContext, and what a tool can do with it
  • A tool that works through every pending order and says how far it has got
  • @McpProgress and @McpLogging on the client, and their exact signatures
  • The progress token, which a client has to set before it receives anything
  • Watching it from the command line, and in the browser

The Server Can Talk Back

Every exchange so far has been one round trip. The client asks, the server answers, and nothing happens in between.

MCP allows more than that. While a tool is running, the server can send notifications back to the client: how far along it is, and what it is doing. Neither is a reply to the request, and both arrive before the request finishes.

The way in is a parameter. A tool method that declares an McpSyncRequestContext gets one, and it 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:

MethodDoes
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, and the usual answer being a server-side tool that does the work in one call. Here is one.

The support team wants delivery estimates refreshed for every order still in transit. There are around 200 orders, each needs a carrier lookup, and the whole thing takes a while. As a series of tool calls it would be hopeless. As one tool it is fine, as long as it says something while it works.

Add it to OrderTools:

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);
}

openWorldHint is true here, unlike every tool in Class 3, because refreshing an estimate reaches a carrier's service rather than only our own database.

The progress calculation is integer arithmetic on purpose: (i + 1) * 100 / size gives whole percentages, which is what progress(int) wants.


Handling It on the Client

Notifications arrive whether or not anything is listening. To do something with them, annotate a handler.

package com.themcpguy.supportdesk.agent;

import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;

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(Double progress, String progressToken, String total) {
// What arrives is a fraction, not the 0-100 the server passed. See below.
System.out.printf(" [%s] %d%%%n", progressToken, Math.round(progress * 100));
}

@McpLogging(clients = "orders")
public void onLog(LoggingLevel level, String logger, String data) {
System.out.printf(" %s %s%n", level, data);
}
}

Both signatures are fixed, and getting them wrong fails at startup rather than silently. Each handler takes either one parameter or three, and nothing else:

AnnotationOne parameterThree parameters
@McpProgressProgressNotificationDouble progress, String progressToken, String total
@McpLoggingLoggingMessageNotificationLoggingLevel level, String logger, String data

The three-parameter progress form is worth reading twice. The progress value comes first, the token second, and total arrives as a String rather than a number. A handler declared as (String progressToken, double progress) is rejected with a message naming the expected shape.

The number is a fraction, not a percentage. context.progress(50) on the server asserts the value is between 0 and 100, then sends 50 / 100.0 with a total of 1.0. So the handler receives 0.5 and a total of "1.0", and has to multiply by 100 to get back what the server meant. Printing the raw value gives a progress bar that never leaves 0 or 1, which looks like nothing is happening.

clients says which connection this handler serves. It matches the connection name from application.yaml: orders, from Class 6. It is a String[], so one handler can serve several connections:

@McpLogging(clients = { "orders", "knowledge-base" })

A handler with no matching connection name is never called, and that is the usual reason a handler appears to do nothing.


Watch It Run

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

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. 14 estimates changed.
Agent: The delivery estimates for all in-transit orders have been refreshed. The
system rechecked 87 shipments, and 14 delivery estimates were updated.

Eighty-seven notifications, one per order, addressed to the conversation ID we used as the progress token.

The model called one tool. The percentages arrived while that call was still open, and the final sentence came back the ordinary way when it finished.

There are 87 shipped orders in the seed data and each carrier lookup pauses briefly, so the job takes a few seconds. That is long enough to watch the percentages arrive, which is the point of the exercise.

In the browser, the same notifications drive a progress bar in the chat panel and a log line underneath it. Start the frontend as well and ask the same question there:

You run this, in a third terminal
cd frontend
npm run dev

The frontend is doing nothing clever: it receives what the handlers above receive and renders it. Class 12 builds the endpoint the browser subscribes to.


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 have nowhere to go, and nothing reports that they were dropped.

The token travels in the request's _meta map, under the key progressToken. McpSchema.Request.progressToken() is a default method that reads exactly that key. Spring AI's tool callback fills _meta from the tool context, so supplying one means putting it there:

chatClient.prompt()
.advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, conversationId))
.toolContext(Map.of("progressToken", conversationId))
.user(userMessage)
.call()
.content();

Using the conversation ID as the token is what lets the handler above route a notification straight back to the right browser, since the token is the only thing that comes with it.

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 result is silent: the server still calls context.progress(...), the request carries no token, the client is sent nothing, and the progress bar never moves. Class 13's converter passes progressToken through for this reason.

A server can also see the token directly, with @McpProgressToken on a parameter:

import org.springframework.ai.mcp.annotation.McpProgressToken;

public String recheckShipments(McpSyncRequestContext context,
@McpProgressToken String progressToken) { ... }

That parameter does not appear in the schema either. It is useful when the server wants to correlate its own logging with what the client will see, and context.request().progressToken() gives the same value.

If progress never appears on the client, the token is the first thing to check, before the handler signature.


Logging Levels

@McpLogging receives whatever level the server sent. A client can also tell the server how much it wants:

orders.setLoggingLevel(LoggingLevel.INFO);

After that, context.debug(...) calls are not delivered. The debug line in recheckShipments reports each changed order, which is useful while developing and noise in a support conversation, so setting the level to INFO at startup is reasonable.

This is a server-side filter rather than a client-side one: the messages are not sent, so they cost nothing to ignore.

Logging is being retired

Spec revision 2026-07-28 deprecates Logging under SEP-2577 and removes logging/setLevel outright, so setLoggingLevel above has no replacement in that revision. 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 and the metrics in Class 16 are unaffected by any of this. Class 13 covers the wider change.


What We Built

A tool that does bulk work in one call and reports on itself while doing it, and a client that renders what it says. The request-response shape from Classes 2 to 10 now has traffic going the other way inside a single call.

Class 12 uses the same mechanism for something that changes the outcome rather than describing it.


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