Skip to main content

Class 7: Translating GraphQL Operations Into MCP Tools

Duration: 50 minutes | Difficulty: Intermediate-Advanced | Prerequisites: Class 6.


Companion code

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

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

By the end of this lesson three real MCP tools (recommendMoviesForMood, summarizeMovieReviews, addToWatchlist) appear in MCP Inspector. All three call the local Spring GraphQL execution engine in-process, with typed Java records as inputs and outputs. The ping tool from Class 6 stays in the codebase as a check that the wiring works. By the end, you have a pattern to reuse for the next thirty operations, and you know what the in-process call passes into the GraphQL engine and what it skips.

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

  • ExecutionGraphQlService is Spring for GraphQL's programmatic entry point for running a GraphQL request inside the application.
  • Persisted operation is an operation written and reviewed in advance, which the agent can call and cannot change.
  • Java record is a compact, immutable data class. The tools use records for their inputs and outputs.
  • Text block is Java's triple-quoted string, which can span several lines.
  • Tool annotations are hints such as readOnlyHint that tell a host how a tool behaves.
  • @oneOf marks a GraphQL input object whose caller must provide exactly one of its fields.
  • JSpecify @Nullable is a standard Java annotation that marks a value as possibly null.
  • SecurityContext holds the authenticated user for the current request in Spring Security.
  • WebGraphQlInterceptor is a Spring for GraphQL hook that runs for each GraphQL request that arrives over HTTP.
  • MCP Inspector is the MCP project's developer tool for connecting to a server and calling its tools by hand.

The Pattern, in One Sentence​

Every @McpTool method has the same shape:

  1. Declare a typed input record that matches the GraphQL operation's arguments.
  2. Hold the operation's GraphQL text in a static final String constant.
  3. Build an ExecutionGraphQlRequest with the variables.
  4. Call ExecutionGraphQlService.execute(request), extract the typed result, and return it.

Only the operation text and the record types change between tools, and the code around ExecutionGraphQlService is the same in all of them. Once you know the structure, each new tool is routine.

Each tool should do one thing well

Spring AI's own MCP material repeats this design rule: each tool should do one thing well, with a description that says exactly what that thing is. These two designs both go wrong, in opposite directions:

  • splitting recommendMoviesForMood into one tool per mood (recommendComfortMovies, recommendAdventureMovies, ...)
  • combining addToWatchlist, setWatchStatus and removeFromWatchlist into one large tool with an action argument

The three operations in this lesson each have one clear job: a read with typed input, a read that does work on the server, and a write for the signed-in user. That makes their descriptions easy to write, and helps the agent pick the right tool. It does not mean you should add tools freely. Every tool definition the server advertises uses part of the agent's context window, so keep the list short. When a graph has more operations than that budget allows, Class 20 covers the alternatives to advertising each one as its own tool, including tool search and Anthropic's code-execution pattern.

About the @McpTool annotation's package

As Class 6 explained, on Spring AI 2.0 @McpTool and @McpToolParam live in org.springframework.ai.mcp.annotation. Imports from org.springaicommunity come from 1.1-era material, and the fix is to rename the package.

The Persisted-Operation Constants​

Class 4 (Apollo) put the operation strings in .graphql files. Track B keeps them in Java source as static final String constants. The difference is small:

  • Files let you review the operations apart from the code, and Apollo validates them against the schema at startup.
  • Java constants sit next to the tool method that uses them, and go through code review as one change.

Both are persisted operations in the sense of Class 1, because the agent does not write queries in either case. Constants suit the in-process path, because the operation, the input record, the output record and the tool method belong together and fit on one screen.

Create a class that holds the three operations. It contains only constants. The class and its constants are public, because later lessons reuse the same operation texts from other packages:

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

public final class MovieOperations {

private MovieOperations() {}

public static final String RECOMMEND_MOVIES_FOR_MOOD = """
query RecommendMoviesForMood($mood: Mood!, $excludeWatched: Boolean = false) {
recommendMoviesForMood(mood: $mood, excludeWatched: $excludeWatched) {
id
title
releaseYear
genre
rating
}
}
""";

public static final String SUMMARIZE_MOVIE_REVIEWS = """
query SummarizeMovieReviews($movieId: ID!) {
summarizeMovieReviews(movieId: $movieId) {
movieId
reviewCount
summary
themes
averageScore
}
}
""";

public static final String ADD_TO_WATCHLIST = """
mutation AddToWatchlist($subject: WatchlistSubjectInput!, $status: WatchStatus) {
addToWatchlist(subject: $subject, status: $status) {
id
status
content {
title
}
}
}
""";
}

The selection sets are deliberately small, as in Class 4. recommendMoviesForMood leaves out directors and cast, because the recommendation tool does not need them. The other two operations follow the same rule. AddToWatchlist selects only title from the returned item's content, which is the Content interface the schema defines over movies and TV shows. That is enough for the agent to confirm "added The Godfather to your list", and it keeps a full Movie object out of the conversation. Adding another field later needs a code change, and that extra review step is intended.

Calling ExecutionGraphQlService​

ExecutionGraphQlService is the programmatic entry point to your local GraphQL execution engine. Spring for GraphQL auto-configures it whenever the framework is on the classpath, and the Movie Database app has always had it. Inject it through the constructor, together with Boot's auto-configured Jackson ObjectMapper:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (the constructor and the request helper)
package com.graphqlguy.moviedb.mcp;

import tools.jackson.databind.ObjectMapper;
import org.springframework.graphql.ExecutionGraphQlRequest;
import org.springframework.graphql.ExecutionGraphQlResponse;
import org.springframework.graphql.ExecutionGraphQlService;
import org.springframework.graphql.support.DefaultExecutionGraphQlRequest;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.UUID;

@Service
public class MovieMcpTools {

private final ExecutionGraphQlService graphql;
private final ObjectMapper objectMapper;

public MovieMcpTools(ExecutionGraphQlService graphql, ObjectMapper objectMapper) {
this.graphql = graphql;
this.objectMapper = objectMapper;
}

private ExecutionGraphQlResponse executeOperation(
String operationName,
String document,
Map<String, Object> variables) {

ExecutionGraphQlRequest request = new DefaultExecutionGraphQlRequest(
document, // GraphQL text
operationName, // matches the named operation in the document
variables, // typed variable map
Map.of(), // extensions, empty for our use case
UUID.randomUUID().toString(), // request id, useful for tracing
null); // locale, null is fine for our use case
return graphql.execute(request).block();
}
}

Some details matter here:

  • The constructor is long, because ExecutionGraphQlRequest holds all the GraphQL request metadata. Most of the arguments are the same for our three tools, which is why the helper method exists.
  • The request id is useful. With OpenTelemetry configured, it goes into your tracing context, so you can find a slow tool call with one query. Class 17 covers production observability in full. For now, generate a request id for every call, because it is cheap.
  • The ObjectMapper is Boot's auto-configured Jackson mapper. The tool methods use it to turn raw execution output into typed records.

The .block() on the returned Mono is intentional. Class 6 configured the MCP server with type: SYNC, so the tool method runs on a synchronous request thread, and blocking inside it is correct. If you switch the server to ASYNC, this is the line that changes. This tutorial keeps the sync model throughout, including the streaming and progress work in Class 10.

Implementing recommendMoviesForMood as a @McpTool​

The first real tool needs its input and output records defined first:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (continued)
import com.graphqlguy.moviedb.recommendation.Mood;
import org.jspecify.annotations.Nullable;

// @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
) {}

Both are small Java records. RecommendInput matches the GraphQL operation's arguments: a required Mood enum and an optional excludeWatched flag. The operation document gives $excludeWatched a default of false, and the tool keeps that behavior. The generated tool schema lists every record component as required unless it carries @Nullable, and a primitive boolean can never be null. So the flag is a nullable Boolean, and the tool method turns a missing flag into false with Boolean.TRUE.equals(...). An agent can leave the flag out, and so can a person filling in the tool form in MCP Inspector. MovieSummary matches the selection set in the operation constant, field for field. id is a String on purpose. GraphQL sends an ID as a string, and the agent does not need to know that the database stores numbers.

Now the tool method:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (continued)
import tools.jackson.core.type.TypeReference;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;

import java.util.List;

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

The description block on @McpTool does the same job as the schema descriptions from Class 3. The text now exists twice, in this Java string and in the schema. Class 8 fixes that by reading the schema descriptions at startup and registering the tools with them, so the schema stays the single source. In this lesson the duplication is deliberate, because it makes the pattern easy to see.

Tool annotations: read-only, destructive, idempotent hints

The @McpTool annotation accepts an optional annotations parameter. It passes hints defined by the MCP spec to the host UI: readOnlyHint, destructiveHint, idempotentHint, openWorldHint and title. Hosts act on these hints. A well-built host, such as Claude Desktop, Cursor or your own agent, reads them to decide whether to ask the user before it calls a tool. recommendMoviesForMood and summarizeMovieReviews are both readOnlyHint = true, idempotentHint = true. addToWatchlist also gets idempotentHint = true, in the sense the MCP spec gives the hint: a repeated call with the same arguments leaves the environment as the first call left it. The reason is that WatchlistService rejects a duplicate add, so the list keeps a single row (Class 2 covered that check).

The response to the repeat call differs (an error in place of the item), but the state stays the same. Class 16 introduces the stage-and-confirm pattern for removeFromWatchlist, which really is destructive. There, destructiveHint = true tells the host to ask the user for confirmation automatically. Add the hints to every tool you ship. They take one line each, and they help the host protect your user.

The variables map converts the input record into the shape the GraphQL request needs. The Mood enum is converted to its .name() because GraphQL enum values are sent as strings on the wire. Spring for GraphQL handles the deserialization back to the enum on the resolver side automatically.

The last two lines produce the typed result, so here is exactly what they do. ResponseField is Spring for GraphQL's view of one part of the response, selected by path. response.field("recommendMoviesForMood").getValue() returns the raw value at that path. For a list field, that is a List of Map<String, Object> entries, exactly as the execution engine produced them. The server-side ResponseField cannot convert values to your types. The toEntity-style methods you may have seen belong to GraphQlClient's ClientResponseField, a different API for calling a remote server.

So the conversion to records is plain Jackson: objectMapper.convertValue(value, new TypeReference<List<MovieSummary>>() {}) maps each entry onto MovieSummary's components. The agent sees the result as the tool's output: a JSON array shaped like the record, without the { "data": { ... } } wrapper of a raw GraphQL response. That clean result is the main reason to use ExecutionGraphQlService, compared with parsing the response text yourself.

Implementing summarizeMovieReviews​

This tool follows the same pattern, with one difference: summary, a field inside the result, can be null. Add the output record:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (continued)
import org.jspecify.annotations.Nullable;

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

summary and averageScore carry @Nullable for the same reason as the WatchlistSubject components later in this lesson. Without it, the generated tool schema marks both as required, and an agent that follows the schema expects values that may be missing. movieId, reviewCount and themes stay unannotated, because the schema types them as non-null. themes is always present, as an empty list when the summarizer found zero themes.

The tool method:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (continued)
@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(
@McpToolParam(description = "Movie ID, as it appears in the schema.", required = true)
String movieId) {

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

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

The null sits one level down, inside the object. data.summarizeMovieReviews itself stays non-null: the schema types the field as MovieReviewSummary!, and an unknown movie id fails the operation with a NOT_FOUND error. What can be null is summary, one field inside the object. With fewer than three reviews, the resolver returns a MovieReviewSummary whose summary is JSON null. reviewCount and themes (possibly an empty list) still come back, and so does averageScore, unless the movie has zero reviews.

Jackson's convertValue maps that nested null directly onto the record's @Nullable String summary component. The annotation already on the record is all it needs. The tool description tells the agent what a null summary means, so the agent tells the user "not enough reviews yet", and it does not retry or report a failure. This is the explicit nullability from Class 3, carried through to the tool layer.

Implementing addToWatchlist​

This is the mutation, and the one tool that wraps an operation designed for humans. addToWatchlist(subject: WatchlistSubjectInput!, status: WatchStatus) takes a subject input marked with GraphQL's @oneOf directive. The input has two fields, movieId and tvShowId, and the caller must provide exactly one. That input gets its own section before the code, because it is the first place in this tutorial where the GraphQL type system and meet directly.

@oneOf Meets JSON Schema oneOf​

GraphQL's @oneOf directive and JSON Schema's oneOf keyword express the same idea in two type systems: a value that must take exactly one of several allowed shapes. The accurate translation of WatchlistSubjectInput into a tool input schema is a oneOf over two single-field variants:

{
"type": "object",
"properties": {
"movieId": { "type": "string" },
"tvShowId": { "type": "string" }
},
"oneOf": [
{ "required": ["movieId"] },
{ "required": ["tvShowId"] }
]
}

A hand-written MCP tool would advertise that schema, and MCP clients handle it, because oneOf is ordinary JSON Schema. Spring AI's generator, which works from the record, produces something worse. Besides losing the exactly-one constraint, it marks every record component as required unless the component has a nullability annotation. We ran the 2.0.0 generator against this exact record to check: the output is an object with both properties in the required list.

A schema that requires both movieId and tvShowId tells an agent that follows it to send both on every call, which breaks @oneOf every time. The MCP Java SDK also validates tool inputs against the advertised inputSchema by default, so it can reject a correct call that carries only movieId before the tool method runs.

The fix is one annotation per component. Mark both record components with JSpecify's @Nullable, which is already on a Boot 4 classpath because Spring Framework 7 includes it. With the annotations in place, the generator drops the required entries. It produces the shape the rest of this section discusses: an object with two optional string properties, still without an exactly-one constraint. The GraphQL engine then does the enforcement, because it validates @oneOf before any resolver runs. A request with both fields or with zero fields fails validation with a precise error, and the fail-fast helper below turns that into a tool error.

The generated schema no longer states the exactly-one rule, so the agent cannot learn it before its first attempt. The rule therefore goes where the agent does read it: the tool and parameter descriptions below say "exactly one of movieId or tvShowId" in those words. If you want the input schema itself to carry a real oneOf, the registration code that Class 8 introduces lets you rewrite the tool's inputSchema by hand. For three tools, the descriptions are simpler, and they work.

The Records and the Tool Method​

The input record mirrors WatchlistSubjectInput. The output records mirror the selection set over the real WatchlistItem type. The tool reuses the base app's own WatchStatus enum from the watchlist package.

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (continued)
import com.graphqlguy.moviedb.watchlist.WatchStatus;
import org.jspecify.annotations.Nullable;

// @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
) {}

The schema's WatchlistItem exposes id, content and status, and it keeps the owning user private. The tool result does the same. The agent already knows whose watchlist it is working on, and repeating the identity in the conversation could confuse it.

The tool method:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (continued)
import java.util.HashMap;

@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 WatchlistItemSummary 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) {

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

ExecutionGraphQlResponse response = executeOperation(
"AddToWatchlist",
MovieOperations.ADD_TO_WATCHLIST,
variables);

Object value = response.field("addToWatchlist").getValue();
return objectMapper.convertValue(value, WatchlistItemSummary.class);
}

The conditional map-building affects the meaning of the request. Map.of rejects null values, but that is only part of the reason. For the subject, @oneOf checks whether a field is present, as well as its value. Sending {"movieId": "3", "tvShowId": null} is invalid, because an explicit null still counts as a provided field. @oneOf requires exactly one field, provided and non-null. So the unused field has to be missing from the variables, and the if checks make sure it is. The same applies to status. The schema declares it nullable, without a schema-level default, and WatchlistService treats a missing status as WANT_TO_WATCH. When the agent leaves out status, the tool leaves out the variable too, and the service applies its default.

Next comes the security context, and the obvious guess about how it reaches the resolver is wrong. When MCP Inspector, or any MCP client, calls this tool, the request arrives at the Spring MVC controller that the MCP starter exposes. That controller sits behind the same Spring Security filter chain as the rest of your app, including the filter the Spring GraphQL tutorial built. By the time the tool method runs, SecurityContextHolder.getContext() on that thread already holds the authenticated user. executeOperation calls ExecutionGraphQlService.execute(...) from the same thread, so it is natural to assume the GraphQL execution inherits the context. In fact it does not, and calling addToWatchlist as an authenticated user fails with an authorization exception.

ExecutionGraphQlService.execute(...) returns a Mono. graphql.execute(request).block() does not subscribe to it through a Reactor pipeline tied to this thread. It starts graphql-java's own CompletableFuture-based engine directly, and that engine does not read SecurityContextHolder while it resolves fields. Spring for GraphQL restores the context for each field from the ExecutionInput's GraphQLContext, under the key SecurityContext.class.getName(). Only requests that arrive through the WebGraphQlHandler chain behind /graphql get that key filled in automatically. A direct ExecutionGraphQlService.execute(...) call skips that chain, so the key stays empty. WatchlistController.addToWatchlist's Principal argument and its @PreAuthorize("isAuthenticated()") therefore see an execution without any security state, and the check refuses the call, whoever makes it.

The fix is one seeding step in executeOperation. It reads the thread-local SecurityContext that the JWT filter already filled in, and writes it into the GraphQLContext under the key that Spring for GraphQL reads. Both types come from Spring Security's core context package, so the file needs two more imports before the seeding code compiles:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (continued)
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
SecurityContext securityContext = SecurityContextHolder.getContext();
if (securityContext.getAuthentication() != null) {
request.configureExecutionInput((executionInput, builder) -> {
executionInput.getGraphQLContext().put(SecurityContext.class.getName(), securityContext);
return executionInput;
});
}

configureExecutionInput runs before execution starts. It puts the calling thread's SecurityContext into the engine, under the exact key that Spring for GraphQL restores on every field. With that in place:

  • WatchlistController.addToWatchlist's Principal argument resolves to the authenticated user.
  • @PreAuthorize("isAuthenticated()") passes.
  • WatchlistService limits its work to that username, which is how ownership is enforced.

An unauthenticated caller does not have an Authentication to seed, so the if check above skips writing the key. That call is still refused, correctly, at the same @PreAuthorize check.

One caution goes with that fix. A call to ExecutionGraphQlService.execute(...) enters the GraphQL engine below the web transport layer. So tool calls skip the WebGraphQlInterceptor chain that runs for HTTP requests to /graphql, together with anything that chain passes along. The security context is the only state this path carries, and only because the seeding step puts it there by hand.

The moviedb app has a real example of what gets skipped. Its RequestContextInterceptor is a WebGraphQlInterceptor that adds a new request id to the GraphQL context and returns it in an X-Request-Id response header. Tool calls skip it, and everything still works, because that interceptor only adds observability detail, and the tool layer already supplies its own request id through DefaultExecutionGraphQlRequest. The caution matters for apps where an interceptor does real work. Anything it sets up is missing on this path, such as a locale read from a header or a tenant id copied into the GraphQL context. The helper that builds the request has to seed those values by hand, the same way it seeds security.

Where the GraphQL Errors Go​

The current executeOperation helper does not check response.getErrors() before it returns. That is fine when the call succeeds. When it fails, field(...).getValue() returns null or partial data, and the agent gets a confusing result. For this lesson, the simplest approach is to fail fast: throw a runtime exception when the response has errors, and let the MCP runtime turn it into a tool error.

Add this to the helper, next to the security seeding from earlier in this lesson:

private ExecutionGraphQlResponse executeOperation(
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;
});
}
ExecutionGraphQlResponse response = graphql.execute(request).block();
if (response != null && !response.getErrors().isEmpty()) {
throw new IllegalStateException(
"GraphQL execution returned errors: " + response.getErrors());
}
return response;
}

Successful calls work as before, and unexpected errors now show up clearly. The error you are most likely to see from these three tools also arrives through this path. A repeated addToWatchlist call triggers the duplicate check in WatchlistService. The base app's GlobalExceptionHandler maps it to a BAD_REQUEST GraphQL error with the message "This movie is already in your watch list", and the helper turns that into a tool error. The tool description already told the agent to treat that outcome as "already done". Class 9 covers error handling properly: partial data, structured tool errors with isError: true, and mapping DataFetcherResult errors to MCP error content that an agent can act on without parsing exception text. This lesson covers successful calls.

After these steps, MovieMcpTools.java holds the records, the three tools, and the helper with its security seeding and its error check:

The complete MovieMcpTools.java, collapsed because it runs to 228 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 org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
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;

public MovieMcpTools(ExecutionGraphQlService graphql, ObjectMapper objectMapper) {
this.graphql = graphql;
this.objectMapper = 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(
@McpToolParam(description = "Movie ID, as it appears in the schema.", required = true)
String movieId) {

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

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

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

ExecutionGraphQlResponse response = executeOperation(
"AddToWatchlist",
MovieOperations.ADD_TO_WATCHLIST,
variables);

Object value = response.field("addToWatchlist").getValue();
return objectMapper.convertValue(value, WatchlistItemSummary.class);
}

// Fail fast: throw a runtime exception when the response has errors and let
// the MCP runtime wrap it as a tool error. Class 9 replaces this policy with
// the structured mapping in ToolResults.
//
// 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 executeOperation(
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;
});
}
ExecutionGraphQlResponse response = graphql.execute(request).block();
if (response != null && !response.getErrors().isEmpty()) {
throw new IllegalStateException(
"GraphQL execution returned errors: " + response.getErrors());
}
return response;
}
}

Running and Inspecting​

Restart the Boot app and reconnect MCP Inspector to http://localhost:8080/mcp. Four tools now appear: ping and the three new ones. Check each new tool:

  • recommendMoviesForMood: enter a RecommendInput, run the tool, and confirm that a list of movies comes back. Inspector builds a form from the record's JSON Schema, with a mood dropdown and an excludeWatched checkbox.
  • summarizeMovieReviews: enter the id of a movie with one or two reviews, below the three-review threshold. Run it, and confirm that the response is a non-null object whose summary is null, while reviewCount and averageScore have values. A movie with zero reviews also returns a non-null object, with reviewCount: 0, and with summary and averageScore both null, because it has zero scores to average.
  • addToWatchlist: this tool needs a signed-in user, because the watchlist mutation requires an authenticated caller. A call without a token ends with a Tool Error: Error invoking method: addToWatchlist GraphQL execution returned errors: [Unauthorized]. Sign in first, then call the tool:
    1. Open GraphiQL at http://localhost:8080/graphiql and run mutation { login(input: {username: "user", password: "user123"}) { token } }. Copy the token value from the response. The token is valid for 24 hours, so sign in again on a later day.
    2. In Inspector, open the server card's Settings, then Custom Headers, and choose + Add Header. Enter Authorization in the Key field, and Bearer followed by a space and the token in the Value field. Close the dialog, and turn the server off and on again.
    3. Enter a movieId, run the tool, and confirm that the item was added. Take the id from the recommendMoviesForMood result, where every movie has an id, for example 60 for Casablanca. Run the watchlist query at /graphql as the same user, or look at the watchlist_items table in the H2 console.

Compared with Track A (Apollo), the tools, input shapes, output shapes and security model are the same. Track B works without a second process, a YAML file or an operations directory, and the code for all three tools fits in one Java file.

Summary​

This lesson built the core pattern of Track B. Each GraphQL operation lives in a Java text-block constant. Each tool is an @McpTool method that:

  1. builds an ExecutionGraphQlRequest
  2. executes it in-process through ExecutionGraphQlService
  3. checks for errors
  4. converts the raw response value into a typed record with Jackson

Three tools now work this way: a read with typed input, a read that does work on the server, and a write for the signed-in user. Through the seeding step, the write uses the same Spring Security context as every other request in the app. The structure is deliberately repetitive, so adding the next thirty operations is routine work.

Further Reading​

What's Next​

The tool descriptions are currently hand-written in the @McpTool annotations, and they duplicate the schema descriptions from Class 3. Class 8 fixes that. At startup, we read the schema's field descriptions through GraphQlSource and register the tools with them, so the schema is the single source of documentation for frontends and agents. After that:

  • Class 9 handles structured outputs and content types: JSON content and text content, partial data, and structured tool errors.
  • Class 10 adds progress notifications for long-running tools.
  • Class 11 covers MCP's elicitation and sampling features.
  • Class 12 finishes the Track B foundation with testing.

The remaining lessons build on this code.