feat: Add progress notification support for MCP operations (#407)

Implement progress tracking for long-running operations with:
- New ProgressNotification schema and client/server support
- Progress consumer handlers in sync/async client builders
- Server exchange methods for sending progress updates
- Comprehensive integration tests
- Backwards compatibility maintained
- Add additional tests

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
JiHwan Oh 2025-07-17 10:31:34 +09:00 • committed by Christian Tzolov
commit b04c30b60e
7 changed files with 535 additions and 8 deletions

View file

@ -100,6 +100,9 @@ public class McpAsyncClient {
public static final TypeReference<LoggingMessageNotification> LOGGING_MESSAGE_NOTIFICATION_TYPE_REF = new TypeReference<>() {
};
public static final TypeReference<McpSchema.ProgressNotification> PROGRESS_NOTIFICATION_TYPE_REF = new TypeReference<>() {
};
/**
* Client capabilities.
*/
@ -253,6 +256,16 @@ public class McpAsyncClient {
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_MESSAGE,
asyncLoggingNotificationHandler(loggingConsumersFinal));
// Utility Progress Notification
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumersFinal = new ArrayList<>();
progressConsumersFinal
.add((notification) -> Mono.fromRunnable(() -> logger.debug("Progress: {}", notification)));
if (!Utils.isEmpty(features.progressConsumers())) {
progressConsumersFinal.addAll(features.progressConsumers());
}
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_PROGRESS,
asyncProgressNotificationHandler(progressConsumersFinal));
this.initializer = new LifecycleInitializer(clientCapabilities, clientInfo,
List.of(McpSchema.LATEST_PROTOCOL_VERSION), initializationTimeout,
ctx -> new McpClientSession(requestTimeout, transport, requestHandlers, notificationHandlers,
@ -846,6 +859,28 @@ public class McpAsyncClient {
});
}
/**
* Create a notification handler for progress notifications from the server. This
* handler automatically distributes progress notifications to all registered
* consumers.
* @param progressConsumers List of consumers that will be notified when a progress
* message is received. Each consumer receives the progress notification.
* @return A NotificationHandler that processes progress notifications by distributing
* the message to all registered consumers
*/
private NotificationHandler asyncProgressNotificationHandler(
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers) {
return params -> {
McpSchema.ProgressNotification progressNotification = transport.unmarshalFrom(params,
PROGRESS_NOTIFICATION_TYPE_REF);
return Flux.fromIterable(progressConsumers)
.flatMap(consumer -> consumer.apply(progressNotification))
.then();
};
}
/**
* This method is package-private and used for test only. Should not be called by user
* code.

View file

@ -177,6 +177,8 @@ public interface McpClient {
private final List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers = new ArrayList<>();
private final List<Consumer<McpSchema.ProgressNotification>> progressConsumers = new ArrayList<>();
private Function<CreateMessageRequest, CreateMessageResult> samplingHandler;
private Function<ElicitRequest, ElicitResult> elicitationHandler;
@ -377,6 +379,36 @@ public interface McpClient {
return this;
}
/**
* Adds a consumer to be notified of progress notifications from the server. This
* allows the client to track long-running operations and provide feedback to
* users.
* @param progressConsumer A consumer that receives progress notifications. Must
* not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if progressConsumer is null
*/
public SyncSpec progressConsumer(Consumer<McpSchema.ProgressNotification> progressConsumer) {
Assert.notNull(progressConsumer, "Progress consumer must not be null");
this.progressConsumers.add(progressConsumer);
return this;
}
/**
* Adds a multiple consumers to be notified of progress notifications from the
* server. This allows the client to track long-running operations and provide
* feedback to users.
* @param progressConsumers A list of consumers that receives progress
* notifications. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if progressConsumer is null
*/
public SyncSpec progressConsumers(List<Consumer<McpSchema.ProgressNotification>> progressConsumers) {
Assert.notNull(progressConsumers, "Progress consumers must not be null");
this.progressConsumers.addAll(progressConsumers);
return this;
}
/**
* Create an instance of {@link McpSyncClient} with the provided configurations or
* sensible defaults.
@ -385,7 +417,8 @@ public interface McpClient {
public McpSyncClient build() {
McpClientFeatures.Sync syncFeatures = new McpClientFeatures.Sync(this.clientInfo, this.capabilities,
this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers,
this.promptsChangeConsumers, this.loggingConsumers, this.samplingHandler, this.elicitationHandler);
this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers, this.samplingHandler,
this.elicitationHandler);
McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures);
@ -435,6 +468,8 @@ public interface McpClient {
private final List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers = new ArrayList<>();
private final List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers = new ArrayList<>();
private Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler;
private Function<ElicitRequest, Mono<ElicitResult>> elicitationHandler;
@ -654,6 +689,37 @@ public interface McpClient {
return this;
}
/**
* Adds a consumer to be notified of progress notifications from the server. This
* allows the client to track long-running operations and provide feedback to
* users.
* @param progressConsumer A consumer that receives progress notifications. Must
* not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if progressConsumer is null
*/
public AsyncSpec progressConsumer(Function<McpSchema.ProgressNotification, Mono<Void>> progressConsumer) {
Assert.notNull(progressConsumer, "Progress consumer must not be null");
this.progressConsumers.add(progressConsumer);
return this;
}
/**
* Adds a multiple consumers to be notified of progress notifications from the
* server. This allows the client to track long-running operations and provide
* feedback to users.
* @param progressConsumers A list of consumers that receives progress
* notifications. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if progressConsumer is null
*/
public AsyncSpec progressConsumers(
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers) {
Assert.notNull(progressConsumers, "Progress consumers must not be null");
this.progressConsumers.addAll(progressConsumers);
return this;
}
/**
* Create an instance of {@link McpAsyncClient} with the provided configurations
* or sensible defaults.
@ -663,8 +729,8 @@ public interface McpClient {
return new McpAsyncClient(this.transport, this.requestTimeout, this.initializationTimeout,
new McpClientFeatures.Async(this.clientInfo, this.capabilities, this.roots,
this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers,
this.promptsChangeConsumers, this.loggingConsumers, this.samplingHandler,
this.elicitationHandler));
this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers,
this.samplingHandler, this.elicitationHandler));
}
}

View file

@ -59,6 +59,7 @@ class McpClientFeatures {
* @param resourcesChangeConsumers the resources change consumers.
* @param promptsChangeConsumers the prompts change consumers.
* @param loggingConsumers the logging consumers.
* @param progressConsumers the progress consumers.
* @param samplingHandler the sampling handler.
* @param elicitationHandler the elicitation handler.
*/
@ -68,6 +69,7 @@ class McpClientFeatures {
List<Function<List<McpSchema.ResourceContents>, Mono<Void>>> resourcesUpdateConsumers,
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers,
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers,
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers,
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler,
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler) {
@ -79,6 +81,7 @@ class McpClientFeatures {
* @param resourcesChangeConsumers the resources change consumers.
* @param promptsChangeConsumers the prompts change consumers.
* @param loggingConsumers the logging consumers.
* @param progressConsumers the progress consumers.
* @param samplingHandler the sampling handler.
* @param elicitationHandler the elicitation handler.
*/
@ -89,6 +92,7 @@ class McpClientFeatures {
List<Function<List<McpSchema.ResourceContents>, Mono<Void>>> resourcesUpdateConsumers,
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers,
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers,
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers,
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler,
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler) {
@ -106,10 +110,28 @@ class McpClientFeatures {
this.resourcesUpdateConsumers = resourcesUpdateConsumers != null ? resourcesUpdateConsumers : List.of();
this.promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of();
this.loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of();
this.progressConsumers = progressConsumers != null ? progressConsumers : List.of();
this.samplingHandler = samplingHandler;
this.elicitationHandler = elicitationHandler;
}
/**
* @deprecated Only exists for backwards-compatibility purposes.
*/
public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots,
List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers,
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers,
List<Function<List<McpSchema.ResourceContents>, Mono<Void>>> resourcesUpdateConsumers,
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers,
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers,
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler,
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler) {
this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers,
resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler,
elicitationHandler);
}
/**
* Convert a synchronous specification into an asynchronous one and provide
* blocking code offloading to prevent accidental blocking of the non-blocking
@ -149,6 +171,12 @@ class McpClientFeatures {
.subscribeOn(Schedulers.boundedElastic()));
}
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers = new ArrayList<>();
for (Consumer<McpSchema.ProgressNotification> consumer : syncSpec.progressConsumers()) {
progressConsumers.add(l -> Mono.<Void>fromRunnable(() -> consumer.accept(l))
.subscribeOn(Schedulers.boundedElastic()));
}
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler = r -> Mono
.fromCallable(() -> syncSpec.samplingHandler().apply(r))
.subscribeOn(Schedulers.boundedElastic());
@ -159,7 +187,7 @@ class McpClientFeatures {
return new Async(syncSpec.clientInfo(), syncSpec.clientCapabilities(), syncSpec.roots(),
toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers,
loggingConsumers, samplingHandler, elicitationHandler);
loggingConsumers, progressConsumers, samplingHandler, elicitationHandler);
}
}
@ -174,6 +202,7 @@ class McpClientFeatures {
* @param resourcesChangeConsumers the resources change consumers.
* @param promptsChangeConsumers the prompts change consumers.
* @param loggingConsumers the logging consumers.
* @param progressConsumers the progress consumers.
* @param samplingHandler the sampling handler.
* @param elicitationHandler the elicitation handler.
*/
@ -183,6 +212,7 @@ class McpClientFeatures {
List<Consumer<List<McpSchema.ResourceContents>>> resourcesUpdateConsumers,
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
List<Consumer<McpSchema.ProgressNotification>> progressConsumers,
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler,
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler) {
@ -196,6 +226,7 @@ class McpClientFeatures {
* @param resourcesUpdateConsumers the resource update consumers.
* @param promptsChangeConsumers the prompts change consumers.
* @param loggingConsumers the logging consumers.
* @param progressConsumers the progress consumers.
* @param samplingHandler the sampling handler.
* @param elicitationHandler the elicitation handler.
*/
@ -205,6 +236,7 @@ class McpClientFeatures {
List<Consumer<List<McpSchema.ResourceContents>>> resourcesUpdateConsumers,
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
List<Consumer<McpSchema.ProgressNotification>> progressConsumers,
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler,
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler) {
@ -222,9 +254,26 @@ class McpClientFeatures {
this.resourcesUpdateConsumers = resourcesUpdateConsumers != null ? resourcesUpdateConsumers : List.of();
this.promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of();
this.loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of();
this.progressConsumers = progressConsumers != null ? progressConsumers : List.of();
this.samplingHandler = samplingHandler;
this.elicitationHandler = elicitationHandler;
}
/**
* @deprecated Only exists for backwards-compatibility purposes.
*/
public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots, List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers,
List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers,
List<Consumer<List<McpSchema.ResourceContents>>> resourcesUpdateConsumers,
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler,
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler) {
this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers,
resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler,
elicitationHandler);
}
}
}

View file

@ -177,6 +177,19 @@ public class McpAsyncServerExchange {
});
}
/**
* Sends a notification to the client that the current progress status has changed for
* long-running operations.
* @param progressNotification The progress notification to send
* @return A Mono that completes when the notification has been sent
*/
public Mono<Void> progressNotification(McpSchema.ProgressNotification progressNotification) {
if (progressNotification == null) {
return Mono.error(new McpError("Progress notification must not be null"));
}
return this.session.sendNotification(McpSchema.METHOD_NOTIFICATION_PROGRESS, progressNotification);
}
/**
* Sends a ping request to the client.
* @return A Mono that completes with clients's ping response

View file

@ -209,7 +209,7 @@ public class McpServerFeatures {
* represents a specific capability.
*
* @param tool The tool definition including name, description, and parameter schema
* @param call Deprecated. Uset he {@link AsyncToolSpecification#callHandler} instead.
* @param call Deprecated. Use the {@link AsyncToolSpecification#callHandler} instead.
* @param callHandler The function that implements the tool's logic, receiving a
* {@link McpAsyncServerExchange} and a
* {@link io.modelcontextprotocol.spec.McpSchema.CallToolRequest} and returning

View file

@ -107,6 +107,15 @@ public class McpSyncServerExchange {
this.exchange.loggingNotification(loggingMessageNotification).block();
}
/**
* Sends a notification to the client that the current progress status has changed for
* long-running operations.
* @param progressNotification The progress notification to send
*/
public void progressNotification(McpSchema.ProgressNotification progressNotification) {
this.exchange.progressNotification(progressNotification).block();
}
/**
* Sends a synchronous ping request to the client.
* @return

View file

@ -61,6 +61,8 @@ public final class McpSchema {
public static final String METHOD_PING = "ping";
public static final String METHOD_NOTIFICATION_PROGRESS = "notifications/progress";
// Tool Methods
public static final String METHOD_TOOLS_LIST = "tools/list";
@ -1388,7 +1390,8 @@ public final class McpSchema {
* tools/list.
* @param arguments Arguments to pass to the tool. These must conform to the tool's
* input schema.
* @param meta See specification for notes on _meta usage
* @param meta Optional metadata about the request. This can include additional
* information like `progressToken`
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonIgnoreProperties(ignoreUnknown = true)