Skip to main content

Class 11: Elicitation and Sampling

Duration: 40 minutes | Difficulty: Intermediate-Advanced | Prerequisites: Class 10.


Companion code

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

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

By the end of this lesson two tools can talk back to the client during a call:

  • addToWatchlist can pause to ask the user whether they have already watched the title, when the agent did not say.
  • summarizeMovieReviews can ask the client's LLM to write the summary.

Both features work only when the client supports them, so each tool also needs a fallback.

This lesson uses several terms that may be new. Here is a short definition of each. Each one gets a fuller explanation later, where the lesson puts it to work.

  • Elicitation is an MCP feature that lets a server ask the client to collect more input from the user during a tool call.
  • Sampling is an MCP feature that lets a server ask the client to run a prompt through the client's LLM.
  • Capability negotiation is the step at connection time where the client and the server state which optional features each one supports.
  • Server-initiated request is a JSON-RPC request that the server sends to the client during a call, and it waits for the client's answer.
  • McpError is the exception class the MCP Java SDK uses when the other side answers a request with a JSON-RPC error.
  • @Cacheable is the Spring annotation that stores a method's result and returns the stored result for later calls with the same key.
  • Caffeine is the in-memory Java cache library behind the summary cache.
  • AOP proxy is the object Spring wraps around a bean so that annotations such as @Cacheable can run code before and after its methods.
  • Multi Round-Trip Requests is the pattern in the 2026-07-28 revision that replaces server-initiated requests: the tool returns its questions, and the client calls the tool again with the answers.

MCP Is a Two-Way Protocol​

Most MCP material describes the protocol as a single request and response. The agent sends tools/call, the server runs the tool, and the server returns the result. That is true for simple cases, and it was enough for Classes 6 to 9. The protocol can do more. While a tool call is running, the server can send messages back to the client. A notification is sent without waiting for an answer. A server-initiated request waits for the client's answer, and the tool then continues with it. Three of these server messages matter for this tutorial.

Progress notifications, from Class 10, are the simplest of the three, and the only one-way message. The server says "I am still working, and here is my progress". The client updates a UI or ignores the notification, and the exchange ends there.

Elicitation lets the server ask the client to collect more input from the user during a call. The server sends an elicitation request with the schema of the input it needs. The client shows a prompt, the user answers or skips it, and the answer goes back to the tool, which continues with it. This suits tools that need information the agent did not provide. Rejecting the call would force the agent to plan again, which wastes a round.

Sampling lets the server ask the client to run an LLM call for it. The server sends the prompt, the client runs it through its configured model, and the result goes back to the server. This suits a server that prefers the client's model to its own. It saves the server a second LLM bill, a second API key, and the work of running a model from a backend service.

The most important property of these two features is that they depend on the client. Only some clients support them. A tool that uses elicitation or sampling must check the client's capabilities and have a fallback path. The Spring AI starter provides those checks as context.elicitEnabled() and context.sampleEnabled(), on the same McpSyncRequestContext that Class 10 used for progress.

Elicitation: Asking the User for Missing Input​

addToWatchlist accepts an optional status argument. When the argument is missing, WatchlistService records WANT_TO_WATCH, which is a guess. A user who says "add Heat to my list" might want to watch a film for the first time, or might be saving a favorite they have watched five times. The agent often cannot tell which. With elicitation, the server asks the user. The client shows a small prompt, "have you already watched it?", and the user picks a status or dismisses the prompt. The answer goes into the mutation, so the right status is recorded without the agent guessing.

Define a small record for the input we ask for. It reuses the Movie Database app's own enum:

src/main/java/com/graphqlguy/moviedb/mcp/WatchStatusRequest.java
package com.graphqlguy.moviedb.mcp;

import com.graphqlguy.moviedb.watchlist.WatchStatus;

public record WatchStatusRequest(WatchStatus status) {}

Update the tool to use elicitation when the agent did not supply a status and the client supports it. The tool keeps its Class 9 shape: it executes leniently and maps the response through ToolResults. Only the way the status variable gets its value changes:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (two new imports)
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
import org.springframework.ai.mcp.annotation.context.StructuredElicitResult;

Then replace addToWatchlist, and add the resolveStatus helper below it:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (the addToWatchlist tool and its resolveStatus helper)
@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(
McpSyncRequestContext context,
@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);
WatchStatus resolved = resolveStatus(context, status);
if (resolved != null) {
variables.put("status", resolved.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");
}

/**
* Class 11: three branches. If the agent supplied a status, respect it. If
* not and the client cannot elicit, resolve to null so the variable is
* omitted and the WatchlistService records its WANT_TO_WATCH default. If
* the client can elicit, ask the user, who is the only one who knows. Decline,
* cancel, no capability, and an empty form all lead to the same fallback; the
* mutation proceeds either way, because the user asked for the add.
*/
private WatchStatus resolveStatus(McpSyncRequestContext context, WatchStatus status) {
if (status != null) {
return status;
}
if (!context.elicitEnabled()) {
return null; // omit the variable; the service records WANT_TO_WATCH
}
StructuredElicitResult<WatchStatusRequest> result = context.elicit(
e -> e.message("Have you already watched this title, or is it one to watch later?"),
WatchStatusRequest.class);

if (result.action() != ElicitResult.Action.ACCEPT
|| result.structuredContent() == null
|| result.structuredContent().status() == null) {
return null; // decline, cancel, or an empty form: same fallback
}
return result.structuredContent().status();
}

The resolveStatus helper has three branches:

  • If the agent supplied a status, use it. The agent already decided, so the tool skips elicitation.
  • If the agent left out the status and the client does not support elicitation, return null, so the request omits the variable. WatchlistService treats a missing status as WANT_TO_WATCH, the default that Class 7 left in the service. The tool therefore works unchanged with clients that lack elicitation.
  • If the client supports elicitation, ask the user.

The context.elicit(...) call sends an MCP elicitation/create request to the client. The specification requires the message string. The client shows it to the user as the prompt, so write it as a question the user can answer. The requested schema comes from WatchStatusRequest, and it stays within the limited set of schemas that the specification allows for elicitation. Those schemas must be flat objects with primitive properties, and a single-select string enum is one of the four allowed property shapes. The client sees status as an enum of WANT_TO_WATCH and WATCHED, so it can offer the two values as a choice in place of a free-text box. MCP Inspector 2.6.0 shows them in a dropdown.

The answer comes back as a StructuredElicitResult. Its action is ACCEPT, DECLINE (the user said no) or CANCEL (the user dismissed the prompt). Only ACCEPT carries content, and even then the user may have submitted the form with the field empty. That is why the null checks send every case without an answer to the same fallback. The mutation runs whatever happens in the dialogue. The user asked to add the title, and skipping a side question about status should still add it. A decline, a cancel, a client without the capability and an empty form all end the same way: the item is saved as WANT_TO_WATCH.

On the sync server, elicit(...) blocks the tool's thread while the user decides. That is fine for a service of this size, and it is the cost of the simple programming model. The ASYNC server type from Class 10 exists for systems that cannot hold a servlet thread while a person thinks.

Capability Negotiation Matters Here​

The if (!context.elicitEnabled()) check is required. Many clients do not negotiate the elicitation capability, such as minimal SDK integrations, older hosts and purpose-built agent harnesses. A tool that calls context.elicit(...) with one of those clients fails or hangs. Checking the capability, and falling back when it is missing, lets the tool work with every client.

The same pattern applies to sampling, covered next. Only some clients offer each feature, so a tool that must work with every client needs a fallback for both. Treat them as optional improvements that make the experience better when the client supports them.

Sampling: Borrowing the Client's Model​

Sampling is the other two-way feature. The server sends a prompt, the client runs it through its configured model, and the result goes back to the server. The server then works without its own API key or billing account for a model. It also respects the user's privacy choices, because the user's own client makes the call, with the model the user pays for and the providers the user trusts.

summarizeMovieReviews from Class 10 had a placeholder in place of a real summarizer. With sampling, the server can write a prompt and ask the client to run it:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (the summarize tool, with sampling)
@McpTool(
name = "summarizeMovieReviews",
description = """
Summarize user reviews for a specific movie. Always returns a
summary object; 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"));

List<Review> reviews = loadReviews(movieId);
if (reviews.isEmpty()) {
return new MovieReviewSummary(movieId, 0, null, List.of(), null);
}
double averageScore = reviews.stream().mapToInt(Review::getScore).average().orElseThrow();
if (reviews.size() < MIN_REVIEWS_FOR_SUMMARY) {
return new MovieReviewSummary(movieId, reviews.size(), null, List.of(), averageScore);
}

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

MovieReviewSummary summary = samplingReviewSummarizer.summarize(context, movieId, reviews, averageScore);

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

return summary;
}

The signature keeps Class 10's parameters, (context, movieId), and the three context.progress(...) checkpoints stay. The 0.4 checkpoint now marks the start of the slow summarizing step. MIN_REVIEWS_FOR_SUMMARY is a new constant on the class, with the same threshold of three that Class 2's schema description set for summary. This lesson keeps that number, and the handling of movies with fewer than three reviews. It only moves where the computation happens.

loadReviews runs a new curated GraphQL operation. The tool now reads the raw reviews itself. It stops calling the schema's summarizeMovieReviews field, whose service has been a stub since Class 2. The operation goes next to the others in MovieOperations from Class 7:

src/main/java/com/graphqlguy/moviedb/mcp/MovieOperations.java (the new operation)
// Class 11: raw material for the summarizer. The summary itself is computed
// in the tool layer (sampling or the server-side fallback), so the tool
// reads the reviews instead of the summarizeMovieReviews GraphQL field,
// which computes its own summary.
public static final String MOVIE_REVIEWS = """
query MovieReviews($movieId: ID!) {
movie(id: $movieId) {
reviews {
score
comment
}
}
}
""";

The helper that runs it goes next to the other private methods on MovieMcpTools. The class also imports com.graphqlguy.moviedb.review.Review for it:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (the review loader)
// Class 11: the raw material for the summary, read through the same
// in-process GraphQL engine as everything else.
private List<Review> loadReviews(String movieId) {
ExecutionGraphQlResponse response = executeOperation(
"MovieReviews",
MovieOperations.MOVIE_REVIEWS,
Map.of("movieId", movieId));
Object value = response.field("movie.reviews").getValue();
if (value == null) {
return List.of();
}
return objectMapper.convertValue(value, new TypeReference<List<Review>>() {});
}

Two details matter here:

  • response.field("movie.reviews") uses a dotted path. ResponseField finds a nested field by its path through the response, so we skip walking two levels of Map by hand.
  • The target type is com.graphqlguy.moviedb.review.Review, the app's own JPA entity. We reuse it only for its shape, with getScore() and getComment(). A dedicated record would work just as well. Reusing the entity saves a second type with the same meaning, and this code path stays away from persistence.

The null guard covers an unknown movie id. There movie resolves to null, so reading the dotted path returns null. The tool description changes to match: an unknown movie id now returns a summary with reviewCount: 0, so the description drops the sentence about a NOT_FOUND error. The tool method computes averageScore from the raw scores and handles the two cases with few reviews itself, as MovieReviewSummaryService always did:

  • zero reviews returns a MovieReviewSummary with reviewCount: 0, and both summary and averageScore null
  • one or two reviews returns reviewCount and averageScore filled in, with summary still null

Both branches return before the summarizer runs, so a movie with fewer than three reviews costs zero model calls. With three or more reviews, the tool passes the work to samplingReviewSummarizer.summarize(...), a new bean described below. That bean uses the client's model through sampling, or falls back to a server-side summarizer.

src/main/java/com/graphqlguy/moviedb/mcp/SamplingReviewSummarizer.java
package com.graphqlguy.moviedb.mcp;

import com.graphqlguy.moviedb.review.Review;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import org.springframework.ai.mcp.annotation.context.McpSyncRequestContext;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import reactor.core.Exceptions;

import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeoutException;

/**
* Class 11: borrows the client's model through sampling when the capability
* was negotiated. When the client lacks sampling, refuses the request, or
* does not answer in time, it falls back to the server-side summarizer, so
* the tool works with every client. The sampling call is slow and costs
* money, so summarize is cached for a short window, keyed on the movie id,
* and a repeat call for the same movie skips it entirely.
*
* This lives in its own bean instead of as a private method on MovieMcpTools
* for two reasons. First, @Cacheable only intercepts calls that arrive
* through the Spring AOP proxy; a call made through `this` inside the same
* bean bypasses the proxy and the annotation would not do anything, and a
* private helper called from the tool method would have exactly that shape.
* Second, MovieMcpTools is the bean SyncMcpToolProvider reflects over to find
* @McpTool methods, and once Spring proxies a bean (which a @Cacheable
* method on it would force), the proxy's overriding methods stop carrying
* the original method annotations, so the tool scanner would stop finding
* recommendMoviesForMood, summarizeMovieReviews, and addToWatchlist.
* Keeping MovieMcpTools itself free of @Cacheable methods keeps it
* unproxied, and the caching lives on this collaborator instead, called from
* MovieMcpTools the ordinary way, through the injected reference.
*/
@Service
public class SamplingReviewSummarizer {

private final ServerSideSummarizer serverSideSummarizer = new ServerSideSummarizer();

@Cacheable(value = "reviewSummaries", key = "#movieId")
public MovieMcpTools.MovieReviewSummary summarize(
McpSyncRequestContext context, String movieId, List<Review> reviews, double averageScore) {

String prompt = buildSummarizationPrompt(reviews);

if (context.sampleEnabled()) {
try {
CreateMessageResult result = context.sample(s -> s
.message(prompt)
.systemPrompt("You summarize movie reviews into a few sentences and recurring themes.")
.maxTokens(1024));
String text = result.content() instanceof TextContent tc ? tc.text() : "";
return parseSummary(movieId, reviews.size(), averageScore, text);
} catch (McpError refused) {
// The client answered with an error: the user denied the request
// (the spec suggests code -1) or the client could not run its
// model. Treat it like a missing capability.
} catch (RuntimeException unanswered) {
// The client did not answer within spring.ai.mcp.server.request-timeout
// (20 seconds by default). The SDK's timeout raises a TimeoutException,
// and the blocking sample(...) call wraps it in a Reactor exception.
// Treat it like a refusal. Any other exception is a bug, so it
// still reaches the caller.
if (!(Exceptions.unwrap(unanswered) instanceof TimeoutException)) {
throw unanswered;
}
}
}
return serverSideSummarizer.summarize(movieId, reviews, averageScore);
}

// Lays out each review's 1-to-10 score plus its comment, so the model can
// ground its synthesis against the same scale the schema documents for
// Review.score, and pins a reply format parseSummary can read back.
private String buildSummarizationPrompt(List<Review> reviews) {
StringBuilder prompt = new StringBuilder("""
Summarize the following movie reviews. Scores are integers from 1 (worst)
to 10 (best). Reply with exactly two lines:
SUMMARY: one or two sentences synthesizing what reviewers said
THEMES: theme one; theme two; theme three

Reviews:
""");
for (Review review : reviews) {
prompt.append("- score ").append(review.getScore());
if (review.getComment() != null && !review.getComment().isBlank()) {
prompt.append(": ").append(review.getComment());
}
prompt.append('\n');
}
return prompt.toString();
}

private MovieMcpTools.MovieReviewSummary parseSummary(
String movieId, int reviewCount, double averageScore, String text) {
String summary = null;
List<String> themes = List.of();
for (String line : text.split("\\R")) {
String trimmed = line.trim();
if (trimmed.regionMatches(true, 0, "SUMMARY:", 0, 8)) {
summary = trimmed.substring(8).trim();
} else if (trimmed.regionMatches(true, 0, "THEMES:", 0, 7)) {
themes = Arrays.stream(trimmed.substring(7).split(";"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
}
return new MovieMcpTools.MovieReviewSummary(movieId, reviewCount, summary, themes, averageScore);
}
}

The prompt asks for a two-line reply in a set format: a SUMMARY: line and a THEMES: line with semicolons between the themes. parseSummary reads exactly those two lines back. The SUMMARY: line becomes the summary field unedited, without classifying, scoring or reducing it to an enum. parseSummary is forgiving on purpose. A reply with extra lines, or with the lines in a different order, still parses. A reply that ignores the format produces a null summary and empty themes, and the tool keeps running. That is the right way to fail for a field that the schema already documents as nullable.

To call summarize, MovieMcpTools needs a field and a constructor parameter, and the class also gets the MIN_REVIEWS_FOR_SUMMARY constant. The three-argument constructor from Class 9 gets a fourth parameter, SamplingReviewSummarizer, next to ExecutionGraphQlService, ObjectMapper and ToolResults. The two-argument convenience constructor passes new SamplingReviewSummarizer():

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (the constant, fields and constructors)
private static final int MIN_REVIEWS_FOR_SUMMARY = 3;

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

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

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

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

With these changes, the complete MovieMcpTools.java for this lesson is:

The complete MovieMcpTools.java, collapsed because it runs to 322 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.review.Review;
import com.graphqlguy.moviedb.watchlist.WatchStatus;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
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.ai.mcp.annotation.context.StructuredElicitResult;
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 static final int MIN_REVIEWS_FOR_SUMMARY = 3;

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

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

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

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

List<Review> reviews = loadReviews(movieId);
if (reviews.isEmpty()) {
return new MovieReviewSummary(movieId, 0, null, List.of(), null);
}
double averageScore = reviews.stream().mapToInt(Review::getScore).average().orElseThrow();
if (reviews.size() < MIN_REVIEWS_FOR_SUMMARY) {
return new MovieReviewSummary(movieId, reviews.size(), null, List.of(), averageScore);
}

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

MovieReviewSummary summary = samplingReviewSummarizer.summarize(context, movieId, reviews, averageScore);

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

return summary;
}

// Class 11: the raw material for the summary, read through the same
// in-process GraphQL engine as everything else.
private List<Review> loadReviews(String movieId) {
ExecutionGraphQlResponse response = executeOperation(
"MovieReviews",
MovieOperations.MOVIE_REVIEWS,
Map.of("movieId", movieId));
Object value = response.field("movie.reviews").getValue();
if (value == null) {
return List.of();
}
return objectMapper.convertValue(value, new TypeReference<List<Review>>() {});
}

@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(
McpSyncRequestContext context,
@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);
WatchStatus resolved = resolveStatus(context, status);
if (resolved != null) {
variables.put("status", resolved.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");
}

/**
* Class 11: three branches. If the agent supplied a status, respect it. If
* not and the client cannot elicit, resolve to null so the variable is
* omitted and the WatchlistService records its WANT_TO_WATCH default. If
* the client can elicit, ask the user, who is the only one who knows. Decline,
* cancel, no capability, and an empty form all lead to the same fallback; the
* mutation proceeds either way, because the user asked for the add.
*/
private WatchStatus resolveStatus(McpSyncRequestContext context, WatchStatus status) {
if (status != null) {
return status;
}
if (!context.elicitEnabled()) {
return null; // omit the variable; the service records WANT_TO_WATCH
}
StructuredElicitResult<WatchStatusRequest> result = context.elicit(
e -> e.message("Have you already watched this title, or is it one to watch later?"),
WatchStatusRequest.class);

if (result.action() != ElicitResult.Action.ACCEPT
|| result.structuredContent() == null
|| result.structuredContent().status() == null) {
return null; // decline, cancel, or an empty form: same fallback
}
return result.structuredContent().status();
}

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

When sampling is unavailable, or the client refuses the request, the fallback runs. It is a deliberately simple class, because its only job is to give a truthful answer when no model is available:

src/main/java/com/graphqlguy/moviedb/mcp/ServerSideSummarizer.java
package com.graphqlguy.moviedb.mcp;

import com.graphqlguy.moviedb.review.Review;

import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;

/**
* Class 11: the fallback summarizer, used when the client did not negotiate the
* sampling capability or refused a sampling request. The summary line comes from integer score bands on the
* 1-to-10 scale the schema documents for Review.score; themes are the most
* frequent meaningful words across the comments. Deliberately simple: the
* interesting path is sampling, and this one only has to be accurate.
*/
public class ServerSideSummarizer {

private static final Set<String> STOP_WORDS = Set.of(
"the", "and", "was", "this", "that", "with", "for", "movie", "film",
"but", "not", "its", "very", "just", "have", "has", "are", "you");

public MovieMcpTools.MovieReviewSummary summarize(String movieId, List<Review> reviews, double averageScore) {
String summary = averageScore > 7.0
? "Reviewers respond favorably overall, averaging " + Math.round(averageScore * 10.0) / 10.0 + " out of 10."
: averageScore < 5.0
? "Reviewers respond unfavorably overall, averaging " + Math.round(averageScore * 10.0) / 10.0 + " out of 10."
: "Reviewers are split, averaging " + Math.round(averageScore * 10.0) / 10.0 + " out of 10.";
return new MovieMcpTools.MovieReviewSummary(
movieId, reviews.size(), summary, themes(reviews), averageScore);
}

private List<String> themes(List<Review> reviews) {
Set<String> themes = new LinkedHashSet<>();
for (Review review : reviews) {
if (review.getComment() == null) continue;
for (String word : review.getComment().toLowerCase(Locale.ROOT).split("[^a-z]+")) {
if (word.length() > 3 && !STOP_WORDS.contains(word)) {
themes.add(word);
}
if (themes.size() >= 5) return List.copyOf(themes);
}
}
return List.copyOf(themes);
}
}

The wording comes from score bands, and the themes are the first five longer words that pass a stop-word list. The output is weak on purpose. Comparing it with the sampled output shows most clearly what the client's model adds. Both fill the same two schema fields, and a client without sampling still gets a valid, truthful answer.

Caching the Sampling Call​

A sampling call is a real LLM call, with the latency and cost that come with it. summarizeMovieReviews is the one tool in this tutorial slow enough for that to matter. Two calls in a row for the same movie usually cover the same reviews, so the second call should reuse the first answer. A cache backed by Caffeine does exactly that.

This is the one lesson in Track B that needs a build change. The moviedb app already has Caffeine on the classpath, because the base app uses it directly to cache TMDB ratings and country lookups. Spring's CaffeineCacheManager connects Caffeine to Spring's caching abstraction, and it lives in a module that the project has been missing until now:

pom.xml (inside the existing dependencies block)
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
</dependency>

Without it, compilation fails with package org.springframework.cache.caffeine does not exist, so you notice the mistake at once. The other mistake, a missing @EnableCaching, fails silently, and we cover it after the listing below.

The base app's CacheConfig already builds two Caffeine caches, so the class gains an annotation, a bean and four imports:

The complete CacheConfig.java, with the summary cache added
src/main/java/com/graphqlguy/moviedb/config/CacheConfig.java
package com.graphqlguy.moviedb.config;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.graphqlguy.moviedb.country.Country;
import com.graphqlguy.moviedb.tmdb.CommunityRating;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Duration;
import java.util.concurrent.TimeUnit;

@Configuration
@EnableCaching
public class CacheConfig {

@Bean
public Cache<Integer, CommunityRating> tmdbRatingCache() {
return Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.maximumSize(500)
.build();
}

@Bean
public Cache<String, Country> countryCache() {
// Country data is effectively static; cache generously so person lists
// don't refetch the same code from the external API on every request.
return Caffeine.newBuilder()
.expireAfterWrite(12, TimeUnit.HOURS)
.maximumSize(300)
.build();
}

/**
* Sixty seconds so the effect is visible within one lesson. A production
* deployment would use hours, because a summary only changes when the
* review set does.
*/
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager("reviewSummaries");
manager.setCaffeine(Caffeine.newBuilder().expireAfterWrite(Duration.ofSeconds(60)));
return manager;
}
}

@EnableCaching installs the AOP interceptor that makes @Cacheable work. Without it, Spring ignores the annotation. The class compiles, the app starts, CacheConfig builds its CacheManager, and every call goes straight to the model. Only the verification step at the end of this lesson would show the problem: call the same tool twice, and check that the second call skips the sampling panel. So run that step.

Sixty seconds is short enough to watch the cache expire during the lesson. A production deployment would set the expiry time in hours, because a review summary changes only when the reviews change, and a movie's reviews change slowly.

Where the @Cacheable annotation goes matters. It sits on SamplingReviewSummarizer.summarize(...), with movieId as the cache key, and stays off MovieMcpTools.summarizeMovieReviews(...). The placement is required. Putting @Cacheable on any method of MovieMcpTools breaks tool discovery. The app still compiles and starts cleanly, and you notice only when you look for the tools that should have registered.

Here is why. @Cacheable uses Spring AOP. To check the cache before the method runs, Spring wraps the bean in a proxy. MovieMcpTools is a class without an interface, so the proxy is a subclass generated at startup. The MCP tool scanner, SyncMcpToolProvider, finds @McpTool methods by using reflection on the bean instance it receives. It looks for the annotation on that instance's own class. The methods in a CGLIB proxy do not carry the original annotations. The proxy subclass has its own copies of recommendMoviesForMood, summarizeMovieReviews and addToWatchlist, and none of them has @McpTool.

If you put @Cacheable on any method of MovieMcpTools, the scanner receives that proxy at startup and misses all three real tools. Other @McpTool beans in the app still register, so the tool list shrinks to those. The startup log stays clean, because every step succeeded: the bean was built, the server started, and the annotation processor did what it was told. The scanner looked at a class whose methods lack @McpTool.

Keeping @Cacheable off MovieMcpTools keeps that bean unproxied. We move the cached logic into SamplingReviewSummarizer, a separate bean that MovieMcpTools calls through an ordinary injected reference. Spring proxies SamplingReviewSummarizer, MovieMcpTools stays a plain bean, and the scanner finds all three tools, as it did in Class 7. The general rule is that caching, security or transaction annotations that need an AOP proxy belong on a bean that the @McpTool bean calls. Class 15 shows the alternative for @PreAuthorize: a scanner that reads the class behind the proxy.

The context.sample(...) call sends an MCP sampling/createMessage request with the prompt and any options. The builder in the listing sets a system prompt and a token budget. It can also state model preferences: priorities for cost, speed and intelligence, plus hints about the model name. The client sends the request to the LLM configured for that client, such as the user's Claude, OpenAI or local Ollama, and returns the model's message. The result's content is the same kind of content item that tool results use, and for text models it is a TextContent. The instanceof pattern match checks that type before the cast.

parseSummary on the server then turns that text into a structured MovieReviewSummary. Like elicit(...), the sync sample(...) blocks the tool's thread while the client runs the model. The specification also adds a trust and safety rule. Clients SHOULD keep a human in the loop for sampling, so the user can review both the prompt and the response. The wait can therefore include the user's approval as well as the model's response time. The user can also deny the request, and the spec suggests error code -1 for that. In the MCP Java SDK, a denial reaches summarize as an McpError thrown by sample(...), and so does any other error the client sends back. The catch in SamplingReviewSummarizer treats that like a missing capability and sends the call to the server-side fallback.

A client can also leave the request unanswered, for example when the user ignores the approval prompt. The server waits for an answer for at most spring.ai.mcp.server.request-timeout, which is 20 seconds by default. When that time passes, the SDK raises a TimeoutException, and the blocking sample(...) call throws it wrapped in a Reactor exception. The second catch unwraps it with Reactor's Exceptions.unwrap and sends a timeout to the same fallback. Every other exception still reaches the caller, because it points to a bug in the tool. @Cacheable stores the fallback summary like any other result, so for the next 60 seconds the same movie returns that summary without asking the client again.

The timeout path runs only when a client stays silent, which manual checks in Inspector rarely do, so a unit test covers it. The test mocks the request context so that sample(...) throws the same wrapped exception the SDK throws on a timeout, and checks that the summary comes from the server-side bean. A second test checks that any other exception still reaches the caller.

The complete SamplingReviewSummarizerTest.java, a new test class
src/test/java/com/graphqlguy/moviedb/mcp/SamplingReviewSummarizerTest.java
package com.graphqlguy.moviedb.mcp;

import com.graphqlguy.moviedb.mcp.MovieMcpTools.MovieReviewSummary;
import com.graphqlguy.moviedb.review.Review;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.ai.mcp.annotation.context.McpRequestContextTypes.SamplingSpec;
import org.springframework.ai.mcp.annotation.context.McpSyncRequestContext;
import reactor.core.Exceptions;

import java.util.List;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
* Class 11: the sampling fallback. When a client does not answer a sampling
* request, sample(...) throws once spring.ai.mcp.server.request-timeout has
* passed, and the summarizer answers with the server-side summary. Any other
* exception is a bug, so it has to reach the caller.
*/
class SamplingReviewSummarizerTest {

private final List<Review> reviews = List.of(
Review.builder().score(9).comment("Beautiful cinematography").build(),
Review.builder().score(8).comment("Charming characters").build(),
Review.builder().score(7).comment("A cozy watch").build());

@Test
void summarize_whenSamplingTimesOut_shouldReturnServerSideSummary() {
// The exception the blocking sample(...) call throws when the SDK's
// timeout fires: Reactor wraps the checked TimeoutException.
McpSyncRequestContext context = samplingContextThrowing(
Exceptions.propagate(new TimeoutException("Did not observe any item or terminal signal within 20000ms")));

MovieReviewSummary summary = new SamplingReviewSummarizer().summarize(context, "1", reviews, 8.0);

assertThat(summary).isEqualTo(new ServerSideSummarizer().summarize("1", reviews, 8.0));
}

@Test
void summarize_whenSamplingFailsWithAnotherException_shouldThrowIt() {
McpSyncRequestContext context = samplingContextThrowing(new IllegalStateException("a bug in the tool"));

assertThatThrownBy(() -> new SamplingReviewSummarizer().summarize(context, "1", reviews, 8.0))
.isInstanceOf(IllegalStateException.class)
.hasMessage("a bug in the tool");
}

private McpSyncRequestContext samplingContextThrowing(RuntimeException exception) {
McpSyncRequestContext context = Mockito.mock(McpSyncRequestContext.class);
Mockito.when(context.sampleEnabled()).thenReturn(true);
// sample(...) is overloaded; the type witness picks the Consumer
// overload that the summarizer calls. thenAnswer throws the exception
// object as it is. thenThrow would throw what fillInStackTrace()
// returns, and Reactor's wrapper returns its TimeoutException cause
// there, so the test would see a different exception than production.
Mockito.when(context.sample(Mockito.<Consumer<SamplingSpec>>any())).thenAnswer(invocation -> {
throw exception;
});
return context;
}
}

The test stubs sample(...) with thenAnswer, which throws the exception object unchanged. Mockito's thenThrow throws whatever fillInStackTrace() returns, and Reactor's wrapper returns its TimeoutException cause there, so a thenThrow stub would test a different exception from the one the summarizer sees in production.

The fallback path uses a server-side summarizer. Each team decides whether to keep a fallback at all. Some servers work only with clients that support sampling, and others always work and use sampling when it is available. This tutorial keeps the fallback, so the tool works with MCP Inspector and other simple clients. In a production deployment behind a known agent framework that always supports sampling, you might remove the fallback and rely on sampling.

When To Use Which​

Elicitation is for missing user input. Use it when the tool needs information that only the user can give, and the agent did not supply it. Examples:

  • whether a saved title has already been watched
  • a free-text confirmation
  • a choice from a set that depends on the user's preference

The tool asks the user through the client, and the user decides whether to answer.

Sampling is for model calls that should run on the client's side. Three reasons point to it:

  • Privacy. The user's reviews are sensitive, and they should stay away from a server-side LLM.
  • Cost. The user already pays for a model, so billing them twice makes little sense.
  • Simpler operations. The server has one less LLM provider to manage.

Most tools work without either feature. A simple read tool returns data, and a simple write tool saves data. Use elicitation when the agent's input would otherwise be incomplete. Use sampling when the model call is the only reason your server would need an LLM provider. Because of the capability checks above, you can add either feature step by step, and existing client integrations keep working.

Two newer features build on elicitation. The 2025-11-25 revision, which this course targets, defines a URL mode for elicitation (SEP-1036). In URL mode the client shows a browser flow in place of a form, and the user completes it . URL mode is meant for OAuth consent and payment pages. It is new, and frameworks may support it later than the spec, so check what your Spring AI version offers before you rely on it. This course uses the form mode shown above. The experimental Tasks primitive from the end of Class 10 also works with elicitation, through its input_required state. A long-running task can pause for user input the same way a live call does.

The 2026-07-28 revision, the current one, changes both features more than any earlier revision did. Both use server-initiated requests: elicitation/create and sampling/createMessage go from server to client. That revision retires this mechanism and replaces it with the Multi Round-Trip Requests pattern. The tool call returns a result with resultType: "input_required" and the questions, and the client calls the tool again with the answers attached.

That revision also formally deprecates sampling, with the spec's minimum of twelve months before removal, and suggests calling an LLM provider's API directly from the server as the migration. Elicitation stays in the protocol, and only its request pattern changes. What you build today on the JVM stays the same, because the Java SDK and Spring AI use 2025-11-25, where the mechanics above are correct. When the Java ecosystem moves to the new revision, the ideas stay the same and the mechanics change.

Verifying​

Elicitation and sampling are both client-side features. MCP Inspector implements both, so we use it to verify them. We verified these steps with Inspector 2.6.0. addToWatchlist needs a signed-in user, so keep the Authorization header from Class 7 in the server card's Settings, and sign in again if the token is older than 24 hours.

Elicitation. Call addToWatchlist without a status. Inspector opens an Elicitation Request dialog with the question and a status dropdown that offers WANT_TO_WATCH and WATCHED. Choose a value and Submit, then read the watchlist back to confirm that the chosen status was saved. Answer within 20 seconds. If you leave the dialog open longer, the call ends with a Tool Error that starts with Error invoking method: addToWatchlist Did not observe any item or terminal signal within 20000ms. Inspector keeps the dialog open, so choose Cancel to close it.

To test the other branch, call addToWatchlist again for a title that is not on the watch list yet, choose Decline, and confirm that the item was added with the default WANT_TO_WATCH. Use a new title, because the dialog appears before the mutation runs: repeating the first title ends with This movie is already in your watch list after you decline.

Sampling. Call summarizeMovieReviews with a movie that has three or more reviews. Inspector opens a Sampling Request dialog that shows the sampling/createMessage request. Act as the model yourself: type a reply in the Response box, in the SUMMARY: and THEMES: format that the prompt asks for, and choose Send Response. The tool's result should return your text in summary, and your semicolon-separated list in themes. The server waits for your reply for at most 20 seconds, the default request-timeout, so have the two lines ready to paste before you call the tool.

Call the same tool again for the same movie right away. The dialog stays closed, because the cache returns the first answer. After sixty seconds, a third call opens the dialog again.

To test a refusal, call summarizeMovieReviews for a different movie with three or more reviews, and choose Reject in the dialog. Inspector answers with the error Sampling request rejected by user.. The tool call still succeeds, and the summary comes from the server-side bean.

To test the timeout, call summarizeMovieReviews for a third movie with three or more reviews, and leave the dialog unanswered. After 20 seconds the tool call succeeds, and the summary comes from the server-side bean. Inspector keeps the dialog open after the server stops waiting, so choose Reject to close it. The server has already finished the call, so it logs Unexpected response for unknown id and ignores the answer.

Clients without the capabilities take the fallback paths, and so do clients that refuse a sampling request or leave it unanswered. The same addToWatchlist call goes ahead and records the default, and the summarizer uses the server-side bean. Class 18 shows the matching client-side annotations (@McpElicitation, @McpSampling) in the Spring AI client. The Spring AI client reference documents how to write full handlers for them.

Summary​

Elicitation and sampling turn a tool call into a short dialogue, and each call still returns one result. During a call, the server can ask the user for missing input or use the client's model. The sync request context offers both as plain blocking calls, elicit(...) and sample(...), behind capability checks.

The fallbacks make both features safe to ship. When the client lacks the capability, addToWatchlist falls back to the WANT_TO_WATCH default. When the client lacks sampling, refuses the sampling request or leaves it unanswered, summarizeMovieReviews falls back to a server-side summarizer. So both tools work with every client. This lesson also added a cache for the sampling call. The @Cacheable annotation goes on a separate SamplingReviewSummarizer bean and stays off MovieMcpTools, because a proxy around the tool bean stops the MCP tool scanner from finding its @McpTool methods.

Further Reading​

What's Next​

Class 12 closes Track B with tests at four levels:

  • unit tests for tool methods
  • integration tests against the live MCP HTTP endpoint
  • contract tests that save the tool schemas, so a rename breaks the build before it silently breaks an agent integration
  • property-based tests for tools with bounded inputs such as the Mood enum

Tools need the same testing care as any other Spring bean. The integration tests teach the most, because they go through the JSON-RPC handshake and the streamable HTTP transport.