Skip to main content

Class 10: Streaming, Progress, and Long-Running Tools

Duration: 20 minutes | Difficulty: Intermediate-Advanced | Prerequisites: Class 9.


Companion code

This lesson carries on from Class 9. If you followed along, keep working on the project you already have. If you skipped it, clone the mcp_class_9 branch to start from the same place:

git clone --branch mcp_class_9 https://github.com/graphqlguy/graphQLMovieDB-MCP.git

By the end of this lesson summarizeMovieReviews emits progress notifications during a multi-second call. You will also know what the streamable HTTP transport streams, and where the protocol is headed for work too long for a progress bar. The tools from Classes 6 through 9 answered in milliseconds. Some operations take seconds, and this lesson keeps the agent and the user informed while they run.

Why Long-Running Tools Are a Different Shape​

Consider what summarizeMovieReviews actually does in production. It reads every review for a movie out of the database, asks an LLM to extract themes and overall sentiment, formats the result, and returns it. The database read is fast; the LLM call is slow. A real summarization can take three to ten seconds.

Two problems show up when a tool runs for that long. First, the agent's reasoning loop stalls. The model waits on the tool result before it can say anything, and the user sees a long pause with no sign that work is happening. Second, the tool has information it could share as it goes, such as which review batch it is on, or what fraction of the work is done. That information is lost, because the tool reports once, when it returns.

A third concern is thread use. A synchronous tool holds a servlet request thread for the whole call. Spring AI has an ASYNC server type for reactive stacks where that matters, covered below. For a service the size of this tutorial's, a multi-second tool on a servlet thread is fine. The server type does not change the protocol, and the next rule comes from the protocol.

Many developers misunderstand this rule of MCP: tools/call returns exactly one result. In every revision of the specification, a tool call has a single response. A tool cannot send back partial results or a stream of values. The "streamable" in streamable HTTP refers to the stream the server may open for a request. Before the final result, that stream carries protocol messages: notifications, and requests that the server starts. Progress notifications (this lesson) and elicitation and sampling requests (Class 11) travel on that stream. The tool's answer still arrives once, at the end.

So MCP's answer for long-running tools, during the call, is progress notifications. The server sends these messages during a long call to say "I am still working, and here is how far along I am." The agent receives them and can keep the user informed. For work so long that the call should not stay open, the protocol offers the Tasks primitive, covered at the end of this lesson.

Progress Notifications on the Wire​

The protocol shape is small. A client that wants progress includes a progressToken in the _meta of its tools/call request. A server that has something to report emits notifications/progress messages carrying that token, a progress value, an optional total, and an optional human-readable message. Two rules matter:

  • The client chooses the token. If the client did not send a token, the server must not invent one, and that call does not report progress.
  • The progress value must increase with each notification for a given token, even when total is unknown. A server that cannot estimate a fraction reports a growing count instead, such as reviews processed or batches completed.

Emitting Progress from a Sync Tool​

Spring AI provides this through a special parameter that it recognizes on @McpTool methods. McpSyncRequestContext (from org.springframework.ai.mcp.annotation.context) gives access to the current call. It exposes progress(...), and also the elicitation and sampling methods that Class 11 uses.

The parameter does not appear in the tool's input schema. The framework fills it in, and the agent does not see it.

Add two imports to MovieMcpTools:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (the two new imports)
import org.springframework.ai.mcp.annotation.context.McpSyncRequestContext;

Then replace the summarizeMovieReviews method:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (the summarize tool, with progress)
@McpTool(
name = "summarizeMovieReviews",
description = """
Summarize user reviews for a specific movie. Always returns a
summary object for a movie that exists; an unknown movie id
fails with a NOT_FOUND error. When there are fewer than three
reviews, the summary field is null and the agent should read
reviewCount to see how many there were. Use this only after
the user has identified a movie they care about; do not call
speculatively across many movies.
""",
annotations = @McpTool.McpAnnotations(
title = "Summarize Movie Reviews",
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false))
public MovieReviewSummary summarizeMovieReviews(
// Class 10: the framework fills in the request context, and the tool's
// input schema leaves it out. context.progress(...) reads the
// client's progress token on its own and skips the notification when
// the client did not send one.
McpSyncRequestContext context,
@McpToolParam(description = "Movie ID, as it appears in the schema.", required = true)
String movieId) {

context.progress(p -> p.progress(0.0).total(1.0).message("Loading reviews"));

ExecutionGraphQlResponse response = executeOperation(
"SummarizeMovieReviews",
MovieOperations.SUMMARIZE_MOVIE_REVIEWS,
Map.of("movieId", movieId));

context.progress(p -> p.progress(0.4).total(1.0).message("Summarizing"));

Object value = response.field("summarizeMovieReviews").getValue();
MovieReviewSummary summary = objectMapper.convertValue(value, MovieReviewSummary.class);

context.progress(p -> p.progress(1.0).total(1.0).message("Done"));

return summary;
}

The body is Class 7's, with one change to make room for the three calls. The single return objectMapper.convertValue(...) becomes a local summary variable, so the final notification can go out before the method returns. The @McpTool block keeps the description and the annotations from Class 7. Keep the annotations block, because readOnlyHint and idempotentHint tell a client that this tool is safe to call without asking.

The method emits three progress events:

  • 0.0, "Loading reviews", at the start, tells the agent the call has begun.
  • 0.4, "Summarizing", after the database read, says the slow summarizing step has started.
  • 1.0, "Done", comes at the end.

Each progress value is higher than the last, as the specification requires. The agent passes each one to the user as a status line, so the user sees "Loading reviews... Summarizing... Done" during the wait.

context.progress(...) reads the token from the request, and skips the notification when the client did not send a token. So the calls above are safe with clients that did not ask for progress, and the tool works either way. A second special parameter, @McpProgressToken (from org.springframework.ai.mcp.annotation), binds the client's token for a tool that needs to check it itself. For example, counting the reviews up front makes the fractions accurate but costs a query, so a tool could skip that work when the client did not ask for progress: if (progressToken != null) { ... }. Declare that parameter as Object, because MCP allows a string or a number as the token, and MCP Inspector sends a number. A String parameter fails on it with argument type mismatch. Our tool does not need the token, so it leaves the parameter out. The Spring AI reference documents both special parameters, linked below.

When the total is unknown, leave out total and report a growing count: context.progress(p -> p.progress(reviewsProcessed).message("Processed batch " + batch)). reviewsProcessed must increase with every notification. A notification that changes only the message and repeats the same progress value is invalid.

For now the 0.4 mark is arbitrary. Both halves of the call are a database read, so the split does not show where the time goes. Class 11 replaces the second half with a sampling request to the client's model, which is the slow step these notifications are for, and then the fractions mean something. The code above also returns the record directly, to keep the progress calls in focus. Routing the result through ToolResults.toCallResult from Class 9 is a small, mechanical change.

After this lesson's change, the complete MovieMcpTools.java is:

The complete MovieMcpTools.java, collapsed because it runs to 267 lines
src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java
package com.graphqlguy.moviedb.mcp;

// Boot 4 ships Jackson 3: the auto-configured mapper lives in tools.jackson.*,
// not com.fasterxml.jackson.* (which Boot 3-era material shows).
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
import com.graphqlguy.moviedb.recommendation.Mood;
import com.graphqlguy.moviedb.watchlist.WatchStatus;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.ai.mcp.annotation.context.McpSyncRequestContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.graphql.ExecutionGraphQlRequest;
import org.springframework.graphql.ExecutionGraphQlResponse;
import org.springframework.graphql.ExecutionGraphQlService;
import org.springframework.graphql.support.DefaultExecutionGraphQlRequest;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.jspecify.annotations.Nullable;

/**
* Class 7: each curated GraphQL operation becomes one @McpTool method. Every
* method has the same shape: typed input record, operation text held as a
* constant in {@link MovieOperations}, an ExecutionGraphQlRequest executed
* in-process through ExecutionGraphQlService, and a Jackson convertValue into
* a typed record.
*/
@Service
public class MovieMcpTools {

private final ExecutionGraphQlService graphql;
private final ObjectMapper objectMapper;
private final ToolResults toolResults;

@Autowired
public MovieMcpTools(ExecutionGraphQlService graphql, ObjectMapper objectMapper, ToolResults toolResults) {
this.graphql = graphql;
this.objectMapper = objectMapper;
this.toolResults = toolResults;
}

// Convenience constructors used by unit tests: a real ObjectMapper and a
// ToolResults built on it are cheap and exercise the production paths.
public MovieMcpTools(ExecutionGraphQlService graphql, ObjectMapper objectMapper) {
this(graphql, objectMapper, new ToolResults(objectMapper));
}

public MovieMcpTools(ExecutionGraphQlService graphql) {
this(graphql, new ObjectMapper());
}

// @Nullable keeps excludeWatched out of the generated input schema's required
// list, so a caller can leave it out. The tool treats a missing flag as false.
public record RecommendInput(
Mood mood,
@Nullable Boolean excludeWatched
) {}

public record MovieSummary(
String id,
String title,
Integer releaseYear,
String genre,
Double rating
) {}

public record MovieReviewSummary(
String movieId,
Integer reviewCount,
@Nullable String summary,
List<String> themes,
@Nullable Double averageScore
) {}

// @Nullable on both components keeps them out of the generated schema's
// required list; the exactly-one rule is enforced by GraphQL's @oneOf and
// advertised in the tool description.
public record WatchlistSubject(
@Nullable String movieId,
@Nullable String tvShowId
) {}

public record AddedContent(String title) {}

public record WatchlistItemSummary(
String id,
WatchStatus status,
AddedContent content
) {}

@McpTool(
name = "recommendMoviesForMood",
description = """
Recommend movies that fit a given mood. Suitable for low-stakes
recommendation flows where an agent is asking on behalf of a user.
Results are ranked by rating, highest first, with a stable
tiebreak on id, so repeated calls return the same order and an
agent that retries does not see the list reshuffle under it.
""",
annotations = @McpTool.McpAnnotations(
title = "Recommend Movies For Mood",
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false))
public List<MovieSummary> recommendMoviesForMood(
@McpToolParam(
description = "Recommendation input: mood (one of COMFORT, ADVENTURE, ROMANCE, HORROR, THOUGHTFUL, COMEDY) and an optional excludeWatched flag, which defaults to false. Set it to true only when the user wants movies they have already marked WATCHED on their watchlist filtered out.",
required = true)
RecommendInput input) {

Map<String, Object> variables = Map.of(
"mood", input.mood().name(),
"excludeWatched", Boolean.TRUE.equals(input.excludeWatched()));

ExecutionGraphQlResponse response = executeOperation(
"RecommendMoviesForMood",
MovieOperations.RECOMMEND_MOVIES_FOR_MOOD,
variables);

Object value = response.field("recommendMoviesForMood").getValue();
return objectMapper.convertValue(value, new TypeReference<List<MovieSummary>>() {});
}

@McpTool(
name = "summarizeMovieReviews",
description = """
Summarize user reviews for a specific movie. Always returns a
summary object for a movie that exists; an unknown movie id
fails with a NOT_FOUND error. When there are fewer than three
reviews, the summary field is null and the agent should read
reviewCount to see how many there were. Use this only after
the user has identified a movie they care about; do not call
speculatively across many movies.
""",
annotations = @McpTool.McpAnnotations(
title = "Summarize Movie Reviews",
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false))
public MovieReviewSummary summarizeMovieReviews(
// Class 10: the framework fills in the request context, and the tool's
// input schema leaves it out. context.progress(...) reads the
// client's progress token on its own and skips the notification when
// the client did not send one.
McpSyncRequestContext context,
@McpToolParam(description = "Movie ID, as it appears in the schema.", required = true)
String movieId) {

context.progress(p -> p.progress(0.0).total(1.0).message("Loading reviews"));

ExecutionGraphQlResponse response = executeOperation(
"SummarizeMovieReviews",
MovieOperations.SUMMARIZE_MOVIE_REVIEWS,
Map.of("movieId", movieId));

context.progress(p -> p.progress(0.4).total(1.0).message("Summarizing"));

Object value = response.field("summarizeMovieReviews").getValue();
MovieReviewSummary summary = objectMapper.convertValue(value, MovieReviewSummary.class);

context.progress(p -> p.progress(1.0).total(1.0).message("Done"));

return summary;
}

@McpTool(
name = "addToWatchlist",
description = """
Add a movie or TV show to the signed-in user's watch list. Provide
exactly one of subject.movieId or subject.tvShowId, never both.
Status is optional; when omitted the server records WANT_TO_WATCH.
Adding a title that is already on the list fails with an "already
in your watch list" error; treat that as already done, not as a
failure worth retrying.
""",
annotations = @McpTool.McpAnnotations(
title = "Add To Watchlist",
readOnlyHint = false,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false))
public CallToolResult addToWatchlist(
@McpToolParam(description = "The title to add: exactly one of movieId or tvShowId.", required = true)
WatchlistSubject subject,
@McpToolParam(description = "Optional initial status, WANT_TO_WATCH or WATCHED. Omit for the default WANT_TO_WATCH.", required = false)
WatchStatus status) {

// @oneOf is strict about presence, not merely value: an explicit null
// entry still counts as a provided field, so the absent field must be
// absent from the variables map entirely.
Map<String, Object> subjectMap = new HashMap<>();
if (subject.movieId() != null) {
subjectMap.put("movieId", subject.movieId());
}
if (subject.tvShowId() != null) {
subjectMap.put("tvShowId", subject.tvShowId());
}

Map<String, Object> variables = new HashMap<>();
variables.put("subject", subjectMap);
if (status != null) {
variables.put("status", status.name());
}

// Class 9: execute leniently and let ToolResults decide how the outcome
// reaches the agent (isError, structuredContent, compatibility text).
ExecutionGraphQlResponse response = executeOperationLenient(
"AddToWatchlist",
MovieOperations.ADD_TO_WATCHLIST,
variables);

return toolResults.toCallResult(response, "addToWatchlist");
}

// Fail fast (Class 7 policy, kept for the read tools): throw a runtime
// exception when the response has errors and let the MCP runtime wrap it as
// a tool error.
private ExecutionGraphQlResponse executeOperation(
String operationName, String document, Map<String, Object> variables) {
ExecutionGraphQlResponse response = executeOperationLenient(operationName, document, variables);
if (response != null && !response.getErrors().isEmpty()) {
throw new IllegalStateException(
"GraphQL execution returned errors: " + response.getErrors());
}
return response;
}

// Class 9: no error policy of its own; the caller routes the response
// through ToolResults.toCallResult.
//
// Bug fix: graphql.execute(...) starts graphql-java's own
// CompletableFuture-based engine directly; it never subscribes through a
// Reactor pipeline tied to this call's thread, so the JWT filter's
// thread-local SecurityContext never reaches field resolution on its own.
// Spring for GraphQL restores security state per field from the
// ExecutionInput's GraphQLContext, keyed by SecurityContext.class.getName()
// (the same key SecurityContextThreadLocalAccessor reads). Seeding that key
// here is what carries this thread's Authentication down to
// WatchlistController's Principal argument and its
// @PreAuthorize("isAuthenticated()"); an unauthenticated caller does
// not have an Authentication to seed, so it is still refused there.
private ExecutionGraphQlResponse executeOperationLenient(
String operationName, String document, Map<String, Object> variables) {
ExecutionGraphQlRequest request = new DefaultExecutionGraphQlRequest(
document, operationName, variables, Map.of(),
UUID.randomUUID().toString(), null);
SecurityContext securityContext = SecurityContextHolder.getContext();
if (securityContext.getAuthentication() != null) {
request.configureExecutionInput((executionInput, builder) -> {
executionInput.getGraphQLContext().put(SecurityContext.class.getName(), securityContext);
return executionInput;
});
}
return graphql.execute(request).block();
}
}
There is also an ASYNC server type

Spring AI's MCP starter has a second server type, type: ASYNC, for reactive stacks. Its tool methods return Mono<T>, and the request context arrives as McpAsyncRequestContext. The two types do not mix. A SYNC server registers only tool methods that return plain values, and silently ignores reactive ones. An ASYNC server does the reverse. On a reactive stack, an async tool frees the servlet thread during slow calls. The messages on the wire stay the same, and an async tool still returns exactly one result. This course uses the SYNC server from Class 6 throughout.

Beyond Progress: Tasks​

Progress keeps the user informed during a call that stays open. Some work is too long for that, such as a bulk catalog re-index or a report that takes ten minutes. For that kind of work, the conversation should continue while the job runs. An HTTP request held open that long can break, however many notifications it carries.

The protocol's answer is the Tasks primitive (SEP-1686), an experimental feature of the 2025-11-25 revision this course targets. The client calls a tool as a task and gets a task handle back at once. It then polls the task's status, which moves through values such as working, input_required and completed. When the task is complete, the client fetches the result in a separate request.

A feature in the spec may still be missing from the frameworks. Tasks are experimental in 2025-11-25. The newer 2026-07-28 revision, published in July 2026, moves them out of the experimental core into their own io.modelcontextprotocol/tasks extension. Framework support can lag behind the spec, so check what your Spring AI version supports before you depend on Tasks. This course uses progress notifications only. If you need to start a job now and fetch its result later without Tasks, build it in the application with two narrow tools:

  • a mutation tool that starts the job and returns an id
  • a query tool that reports the job's status

The Spring GraphQL tutorial teaches both of these GraphQL patterns.

When To Use Which​

Plain sync tools without progress are right for operations that take less than a second. The MCP layer adds very little overhead to fast database reads, simple lookups, and idempotent writes that finish in tens of milliseconds. recommendMoviesForMood stays exactly as it is, and most tools belong in this group.

Progress notifications are useful when the operation takes seconds and has clear checkpoints. The agent gets a signal, and the user sees a status line during the wait. summarizeMovieReviews is the standard example. Tasks, once your framework supports them, are for work measured in minutes, where the conversation should carry on during the job. Sometimes a tool's output is really a sequence that the agent should read piece by piece. Progress notifications cannot deliver that, because a tool call returns one result. Either return the full list at once, or split the operation into narrower tools that the agent calls one after another.

Verifying​

Restart the Boot app. Reconnect MCP Inspector and call summarizeMovieReviews. Inspector 2.6.0 adds a progress token to every tool call, so you do not have to turn anything on. It shows the notifications in a Tool progress message at the bottom right of the page. The Protocol tab on the right lists each notifications/progress message, with the texts "Loading reviews", "Summarizing" and "Done", and the Results panel shows the single final result. The call still takes as long as the slow operation, but the user can see what is happening.

Inspector always sends a token, so it cannot show the path without one. A client that leaves the token out gets only the result, because context.progress(...) sends a notification only when the call carries a progress token.

Summary​

MCP has one way to report on long-running work during a call, and this lesson built it. A synchronous tool method sends progress notifications through McpSyncRequestContext, at meaningful checkpoints, with a value that increases every time, as the specification requires. When the client did not send a progress token, the notifications are skipped. A tool call returns exactly one result in every revision, and the streamable transport streams only protocol messages. For long jobs, use the experimental Tasks primitive, or, until your framework supports it, a pair of tools that start a job and report its status.

Further Reading​

What's Next​

Class 11 introduces MCP's two-way features: elicitation (the server asks the client to prompt the user for additional input mid-call) and sampling (the server asks the client to run an LLM call on its behalf). Both work only when the client supports them. Both let a tool ask for more during a call, while the agent stays in charge of the conversation. The progress notifications from this lesson are the simplest kind of message that a server starts.