Skip to main content

Class 9: Structured Outputs and Errors

Duration: 45 minutes | Difficulty: Intermediate-Advanced | Prerequisites: Class 8.


Companion code

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

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

By the end of this lesson you will understand MCP's CallToolResult shape, the structured output contract, and the isError flag. You will also have a pattern that turns GraphQL execution outcomes into results an agent can read. Class 7 returned typed Java records and let the framework encode them as JSON. This lesson makes the content and error decisions explicit, so the agent gets data it can act on whether the call succeeds or fails.

This lesson uses a few MCP and GraphQL 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.

  • CallToolResult is the object an MCP server returns for a tools/call request: a list of content items, an isError flag and optional structured data.
  • Content item is one typed entry in that list, such as a text block or an image.
  • structuredContent is the field in a tool result that carries machine-readable JSON data.
  • outputSchema is the JSON Schema a tool can advertise in tools/list to describe the shape of its structuredContent.
  • isError is the flag in a tool result that marks the tool call as failed, while the JSON-RPC exchange itself completes normally.
  • Partial data is a GraphQL response that carries both data and errors, because some fields resolved and others failed.
  • Null propagation is the GraphQL rule that turns a failed non-null field into a null on its nearest nullable parent.
  • ErrorClassification is the category Spring for GraphQL attaches to an error, such as NOT_FOUND or BAD_REQUEST, in the error's extensions.

MCP CallToolResult Briefly​

In MCP, a tool's response is a CallToolResult. It contains an array of Content items, an isError boolean, and an optional top-level structuredContent object. Each content item has one of five types:

  • text carries written or unstructured strings.
  • image and audio carry binary data.
  • resource_link points at something the client can fetch from the server.
  • resource embeds resource content inline.

The set deliberately leaves out a json content type. Machine-readable JSON goes in the top-level structuredContent field. The 2025-06-18 revision added that field so servers would stop sending JSON inside text blocks, and the 2025-11-25 revision our stack uses keeps it unchanged. Most tool calls return a single content item. The array is for tools that return several pieces at once, such as a chart and its caption, or a result list and a summary.

Spring AI's annotation-driven path chooses the result shape from your method's return type:

  • A String goes out as a text content item.
  • A Java record, with default settings, is encoded as JSON, and the JSON string goes out inside a text content item. This is the older, backward-compatible shape.
  • A record with generateOutputSchema = true on the @McpTool annotation makes the framework derive a from the return type. The framework advertises it as the tool's outputSchema, and sends the value as structuredContent.
  • A CallToolResult goes out exactly as you built it. Use this path when you need explicit control over the content items, structuredContent, or the isError flag.

Structured Content vs Text Content​

Since the 2025-06-18 revision, tool output has two channels. structuredContent is a single JSON object with the machine-readable data. A tool can declare an outputSchema in its tools/list entry to describe that object's shape. The spec sets precise rules for the two:

  • A tool that declares an outputSchema MUST return a structuredContent object that conforms to it.
  • It SHOULD also return the same JSON serialized into a text content block, so older clients still see the data.
  • A tool without an outputSchema may still return structuredContent.

The schema gives clients something to validate against. It also shows the model the exact result shape before it calls the tool.

Use structured content when the agent will pick specific fields out of the response. For example, the agent reads themes[] from summarizeMovieReviews, looks at reviewCount, and checks whether summary is set, to decide whether it knows enough to recommend the movie. For a tool that returns a typed record, as in Class 7, opting in takes one attribute:

Opting a Class 7-style tool into an advertised outputSchema (illustration)
@McpTool(
name = "recommendMoviesForMood",
generateOutputSchema = true,
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 only change from the Class 7 method is the generateOutputSchema = true line. This snippet is only an illustration, and the project does not contain it. The mcp_class_9 branch leaves the attribute out, because this lesson moves the tools to the explicit CallToolResult path, where generateOutputSchema does not apply.

Use plain text content when the result goes straight to the user. A tool that returns "the user has 47 movies in their watchlist; here are five of them: ..." produces wording that the agent will repeat or lightly rephrase. Structured output would only make the agent assemble that sentence itself.

A useful rule: if the agent will read more than two fields out of the result before answering the user, return structured content. If the agent will repeat what the tool said, return text. Most of our movie tools fit the structured case, because the agent writes its own sentences around the data. A future tool that returns a one-line description of a movie would fit the text case.

GraphQL Errors and the isError Flag​

GraphQL execution returns a data object and an errors array, and a response can contain both. An operation can return non-null data while it reports an error on a field that failed to resolve. MCP only says that isError: true marks a failed tool execution. The spec does not say how to report partial success. This course maps "the operation did not fully succeed" to isError: true, plus content that describes what went wrong. That is one reasonable reading of the spec.

We have to build this mapping ourselves. Spring AI's annotation-driven path turns a RuntimeException thrown from the tool method into an isError: true result. A declared checked exception, or an Error, fails the tool call completely, and the model does not see it. Neither case covers a partial-data GraphQL response, where execution completed and the framework returned normally, without an exception.

Add a small helper that produces a CallToolResult from an ExecutionGraphQlResponse. Here is its first version. The section on mapping errors later in this lesson replaces one of its methods:

src/main/java/com/graphqlguy/moviedb/mcp/ToolResults.java (first version)
package com.graphqlguy.moviedb.mcp;

import tools.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import org.springframework.graphql.ExecutionGraphQlResponse;
import org.springframework.graphql.ResponseError;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Component
public class ToolResults {

private final ObjectMapper mapper;

public ToolResults(ObjectMapper mapper) {
this.mapper = mapper;
}

public CallToolResult toCallResult(ExecutionGraphQlResponse response, String fieldName) {
List<ResponseError> errors = response.getErrors();
Object data = response.field(fieldName).getValue();

if (!errors.isEmpty() && data == null) {
return error("GraphQL operation failed: " + summarizeErrors(errors));
}

Map<String, Object> payload = new HashMap<>();
payload.put("data", data);
payload.put("errors", errors.stream().map(this::errorAsMap).toList());

try {
return CallToolResult.builder()
.structuredContent(payload)
.addTextContent(mapper.writeValueAsString(payload))
.isError(!errors.isEmpty())
.build();
} catch (Exception e) {
return error("Result serialization failed: " + e.getMessage());
}
}

public CallToolResult error(String message) {
return CallToolResult.builder()
.addTextContent(message)
.isError(true)
.build();
}

private Map<String, Object> errorAsMap(ResponseError err) {
Map<String, Object> result = new HashMap<>();
result.put("message", err.getMessage());
result.put("path", err.getParsedPath());
result.put("extensions", err.getExtensions() == null ? Map.of() : err.getExtensions());
return result;
}

private String summarizeErrors(List<ResponseError> errors) {
return errors.stream()
.map(ResponseError::getMessage)
.collect(Collectors.joining("; "));
}
}

The helper handles three cases:

  • Total failure (errors present, data null) returns isError: true and a text summary of the errors.
  • Partial success (errors present, data non-null) returns both the data and the errors, with isError: true, so the agent looks at both.
  • Full success (errors empty) returns the same shape with isError: false.

data can also be null without errors, when the field a tool wraps is nullable. All three of this course's tools wrap non-null fields: recommendMoviesForMood, summarizeMovieReviews and addToWatchlist. So for them, an unknown id or a failed lookup comes back as an error.

A future tool over a nullable field would hit that case, which is why the helper builds the result with HashMap: Map.of throws an exception on null values. Unless the call fails completely, the result goes out twice, once as structuredContent and once serialized into a text block, as the specification recommends for compatibility. The agent reads isError first and the content second, which is the right order for an LLM deciding whether to retry.

This design has one cost. When a tool method returns CallToolResult, the framework cannot derive an outputSchema from a record type, so generateOutputSchema does not apply to the refactored tools. The agent still gets the structured content to parse. You give up the advertised schema in exchange for explicit control over errors.

Partial Data: When Some Fields Resolved and Some Did Not​

The middle case needs the most thought, and the schema decides where it can happen. GraphQL puts a null in place of a failed field only where the schema declares that field nullable. Take a temporary failure while resolving rating for one movie in a recommendMoviesForMood result. rating is a nullable Float with its own @SchemaMapping resolver since the rename in Class 3. Because the field is nullable, the failure stays local. The response carries the full list, with that one movie's rating set to null. It also carries an errors entry whose path, ["recommendMoviesForMood", 2, "rating"], points to the exact position that failed. The agent gets nine complete recommendations and one with a missing rating.

summarizeMovieReviews is harder, and the field to watch is summary. summary is nullable by design. A null there is the contract's way of saying "fewer than three reviews", and it is a normal result. On the wire, that normal null looks exactly like a null caused by a failure.

Suppose a temporary failure stopped the summarizer from computing summary for a movie that has three or more reviews. The response would have the same shape as the normal case: { "data": { "summarizeMovieReviews": { "movieId": "1", "reviewCount": 5, "summary": null, "themes": ["pacing", "acting"], "averageScore": 7.2 } }, "errors": [{ "message": "summary generation timed out", "path": ["summarizeMovieReviews", "summary"] }] }. Only the errors array tells the two apart, which is what the description work in Class 2 prepared for.

themes behaves differently. It is non-null ([String!]!), and so is summarizeMovieReviews itself (MovieReviewSummary!). A failure while computing themes therefore cannot leave a null anywhere along that chain. GraphQL's null propagation carries the failure up past the object and the field, all the way to the top: { "data": null, "errors": [{ "message": "themes computation timed out", "path": ["summarizeMovieReviews", "themes"] }] }.

The helper handles both cases correctly. A null data with errors counts as total failure. A summary: null with an errors entry naming summary reaches the agent with the error attached, so the agent can see what the null means.

There are two reasonable ways to handle a partial result.

Pattern A treats a partial result as success. Return the data, log the error on the server, and let the agent use whatever it can. This suits queries where partial data still helps. A recommendation missing one field is still useful, and reporting the error only prompts a retry that cannot help.

Pattern B treats a partial result as isError: true, with both the data and the errors in the result. Return everything and let the agent decide. This suits mutations, and queries where a missing field changes the meaning of the result. The agent can then choose to show the partial result to the user, ask the user to retry, or call a different tool.

The helper above implements pattern B. isError(!errors.isEmpty()) marks every partial result as an error and gives the agent both the data and the reasons. This course uses that default for queries and mutations alike, because the agent can rely on a single rule. If a specific read tool should behave like pattern A, give toCallResult a boolean parameter that sets isError to false on the partial path for that tool. Either way, choose deliberately for each operation, based on what the agent needs to know.

Mapping DataFetcher Errors​

Spring for GraphQL produces structured errors through the GraphQLError type, which carries an ErrorClassification in extensions.classification. The Spring GraphQL tutorial used this deliberately, and its GlobalExceptionHandler is worth rereading from an agent's point of view. Domain exceptions map to precise classifications:

  • EntityNotFoundException becomes NOT_FOUND, with the entity type in extensions.
  • InvalidInputException becomes BAD_REQUEST, with the name of the invalid field.
  • DuplicateReviewException becomes BAD_REQUEST, with a message the agent can quote.

Violations of the schema's validation directives are handled the same way:

  • A ConstraintViolationException, such as an agent sending a review score of 11 against score: Int! @Range(min: 1, max: 10), maps to BAD_REQUEST with the invalid field in extensions.
  • A DataIntegrityViolationException from a database constraint becomes a generic BAD_REQUEST, and the stack trace stays on the server.

Security exceptions take a separate path on purpose. The handler leaves AccessDeniedException and AuthenticationException to Spring Security's own resolver. That resolver classifies them as UNAUTHORIZED when the caller is anonymous, and as FORBIDDEN when the caller is signed in but lacks permission. Every other exception goes to a catch-all, which logs it with a correlation reference and returns INTERNAL_ERROR with that reference in extensions. Internal details stay on the server, and every failure can be traced.

The mapping from these typed errors to MCP error content decides whether the agent can act on them. A simple mapping copies the GraphQL error message into text content. A better one moves the classification into its own field and keeps the other extensions. Replace the errorAsMap from the listing above with this version, which the companion branch uses:

src/main/java/com/graphqlguy/moviedb/mcp/ToolResults.java (the error mapper)
private Map<String, Object> errorAsMap(ResponseError err) {
Map<String, Object> result = new HashMap<>();
result.put("message", err.getMessage());
result.put("path", err.getParsedPath());

Map<String, Object> extensions = new HashMap<>(
err.getExtensions() == null ? Map.of() : err.getExtensions());
Object classification = extensions.remove("classification");
if (classification != null) {
result.put("classification", classification);
}
if (!extensions.isEmpty()) {
result.put("extensions", extensions);
}
return result;
}

The agent now sees something like {"message": "Entity Movie not found for id: 999", "classification": "NOT_FOUND", "path": ["addToWatchlist"], "extensions": {"entityType": "Movie"}}. With the classification, a well-prompted agent can choose its next step:

ClassificationWhat the agent should do next
NOT_FOUNDthe id is wrong, so search before retrying
BAD_REQUESTfix the input; the field extension says which one
UNAUTHORIZEDask the user to sign in
FORBIDDENstop and say so
INTERNAL_ERRORreport the reference so a human can find the log line

One design choice in the base app affects what the agent sees. Watchlist lookups are limited to the calling user, so a request for another user's WatchlistItem returns NOT_FOUND, not FORBIDDEN. The API deliberately hides whether the item exists. The agent should treat that NOT_FOUND as final, and should not escalate it as a permissions problem.

With the new error mapper in place, ToolResults.java is complete:

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

import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import org.springframework.graphql.ExecutionGraphQlResponse;
import org.springframework.graphql.ResponseError;
import org.springframework.stereotype.Component;
import tools.jackson.databind.ObjectMapper;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
* Class 9: central mapping from GraphQL execution outcomes onto MCP
* CallToolResult. Three cases: total failure (errors, data null) becomes a
* textual isError result; partial success (errors and data) and full success
* both become a data-plus-errors payload delivered as structuredContent plus
* a serialized text block for older clients, with isError marking partiality.
*/
@Component
public class ToolResults {

private final ObjectMapper mapper;

public ToolResults(ObjectMapper mapper) {
this.mapper = mapper;
}

public CallToolResult toCallResult(ExecutionGraphQlResponse response, String fieldName) {
List<ResponseError> errors = response.getErrors();
Object data = response.field(fieldName).getValue();

if (!errors.isEmpty() && data == null) {
return error("GraphQL operation failed: " + summarizeErrors(errors));
}

// HashMap, not Map.of: data can legitimately be null without errors
// (a nullable field, such as movie(id) for an unknown id, resolves
// to null on purpose).
Map<String, Object> payload = new HashMap<>();
payload.put("data", data);
payload.put("errors", errors.stream().map(this::errorAsMap).toList());

try {
return CallToolResult.builder()
.structuredContent(payload)
.addTextContent(mapper.writeValueAsString(payload))
.isError(!errors.isEmpty())
.build();
} catch (Exception e) {
return error("Result serialization failed: " + e.getMessage());
}
}

public CallToolResult error(String message) {
return CallToolResult.builder()
.addTextContent(message)
.isError(true)
.build();
}

// Promote the classification to a first-class field so the agent can pick
// its next move (NOT_FOUND: search first; BAD_REQUEST: fix the input;
// UNAUTHORIZED: ask the user to sign in; FORBIDDEN: stop and say so).
private Map<String, Object> errorAsMap(ResponseError err) {
Map<String, Object> result = new HashMap<>();
result.put("message", err.getMessage());
result.put("path", err.getParsedPath());

Map<String, Object> extensions = new HashMap<>(
err.getExtensions() == null ? Map.of() : err.getExtensions());
Object classification = extensions.remove("classification");
if (classification != null) {
result.put("classification", classification);
}
if (!extensions.isEmpty()) {
result.put("extensions", extensions);
}
return result;
}

private String summarizeErrors(List<ResponseError> errors) {
return errors.stream()
.map(ResponseError::getMessage)
.collect(Collectors.joining("; "));
}
}

The Refactored Tool Method​

First, the wiring. ToolResults becomes the third parameter of the MovieMcpTools constructor, after ExecutionGraphQlService and ObjectMapper, and the constructor stores it in a toolResults field. Spring injects the @Component automatically. Add two convenience constructors next to it. They create default values for the other dependencies, so a test can create MovieMcpTools with fewer arguments:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (the constructor)
@Autowired
public MovieMcpTools(ExecutionGraphQlService graphql, ObjectMapper objectMapper, ToolResults toolResults) {
this.graphql = graphql;
this.objectMapper = objectMapper;
this.toolResults = toolResults;
}

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

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

The @Autowired on the first constructor is required. Spring picks a constructor automatically only when a class has exactly one. With three, Spring has to be told which one to use. Without the annotation, the code compiles and then fails at startup. Class 12's unit tests use the single-argument constructor, which is why these exist. A unit test needs a MovieMcpTools around one stubbed ExecutionGraphQlService, and does not need the other two dependencies.

MovieMcpTools gains two imports for the work in this lesson, one for the annotation above and one for the return type in the next listing:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (continued)
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import org.springframework.beans.factory.annotation.Autowired;

With the field in place, update addToWatchlist to use the new helper instead of throwing an exception on errors:

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

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

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

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

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

Here is what changed:

  • The return type is now CallToolResult, replacing WatchlistItemSummary. The framework sends it exactly as built, including the isError flag, the structuredContent and the compatibility text block.
  • The last two statements call executeOperationLenient and hand its response to ToolResults.toCallResult. The variable assembly above them is exactly Class 7's, including the conditional map-building that satisfies @oneOf.
  • executeOperationLenient is a new helper. It returns the raw execution response and does not throw an exception on errors, because ToolResults.toCallResult now handles errors.

The original fail-fast executeOperation stays for the read tools that keep Class 7's shape. The lenient helper is Class 7's helper without the error check, and everything else is the same. In particular, the security-context seeding stays exactly as it was:

src/main/java/com/graphqlguy/moviedb/mcp/MovieMcpTools.java (the lenient executor)
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();
}

Those seven lines are easy to lose when you copy the helper, and losing them breaks the tool. addToWatchlist is the one tool behind @PreAuthorize("isAuthenticated()"). Without the seeding, every call returns GraphQL operation failed: Unauthorized, even with a valid bearer token that works against /graphql. This lesson's error handling would still look correct, because the tool would be reporting a real GraphQL error. The cause would be the missing seeding step from Class 7. The fail-fast executeOperation becomes a thin wrapper that calls this helper and adds the error check, so the seeding lives in one place.

The companion mcp_class_9 branch applies this change only to addToWatchlist, the mutation, where partial results and errors matter most. Applying it to recommendMoviesForMood and summarizeMovieReviews is a good mechanical exercise. Each read tool then does three things: build the variable map, execute through executeOperationLenient, and hand the response to ToolResults.toCallResult. The error handling, which used to happen through exceptions, then sits in one place. To change the error policy, you edit ToolResults, and the tool methods stay the same.

The schema-derived description from Class 8 keeps working. The Class 8 customizer rewrites the description on the McpSchema.Tool it rebuilds, and that code does not depend on the tool method's return type or its error handling.

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

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

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

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

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

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

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

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

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

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

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

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

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

public record AddedContent(String title) {}

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

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

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

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

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

@McpTool(
name = "summarizeMovieReviews",
description = """
Summarize user reviews for a specific movie. Always returns a
summary object for a movie that exists; an unknown movie id
fails with a NOT_FOUND error. When there are fewer than three
reviews, the summary field is null and the agent should read
reviewCount to see how many there were. Use this only after
the user has identified a movie they care about; do not call
speculatively across many movies.
""",
annotations = @McpTool.McpAnnotations(
title = "Summarize Movie Reviews",
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false))
public MovieReviewSummary summarizeMovieReviews(
@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 CallToolResult addToWatchlist(
@McpToolParam(description = "The title to add: exactly one of movieId or tvShowId.", required = true)
WatchlistSubject subject,
@McpToolParam(description = "Optional initial status, WANT_TO_WATCH or WATCHED. Omit for the default WANT_TO_WATCH.", required = false)
WatchStatus status) {

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

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

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

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

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

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

Verifying with MCP Inspector​

Restart the Boot app. Reconnect MCP Inspector with the Authorization header from Class 7, and sign in again if the token is older than 24 hours. Make a successful call to addToWatchlist, the tool refactored in this lesson, with a subject.movieId for a movie that is not on the list yet. Each movie's id is in the recommendMoviesForMood result. Inspector shows the serialized text copy under Results and the structured content under Structured Output. The response in the Protocol tab has isError: false.

Now cause an error: call addToWatchlist with a subject.movieId that is not in the catalog, or without authenticating. To call it without authenticating, remove the Authorization header in the card's Settings, and turn the server off and on again. addToWatchlist returns a non-null WatchlistItem, so any error on it makes data null, and the result goes through the total-failure branch. Inspector shows a Tool Error with a single plain-text message, such as GraphQL operation failed: Entity Movie not found for id: 99999. The response in the Protocol tab has isError: true, and structuredContent is missing from it. With the header back in place, add the same movie twice, and the second call ends the same way: isError: true with the text GraphQL operation failed: This movie is already in your watch list. That is the helper's prefix in front of the resolver's message.

The GraphQL error underneath is still classified as BAD_REQUEST, which you can see directly at /graphql. The classification does not reach the agent through this tool, because the total-failure branch reduces the errors array to a message string. The agent does not need it here, because the tool description already tells the agent to treat that outcome as "already done". A tool over a nullable field passes the classification through, as the partial-data example below shows.

Watching the partial branch needs two things. First, apply the exercise above and move summarizeMovieReviews to the toCallResult path. On the Class 7 fail-fast path, the resolver failure would come back as a tool error from an exception, not as the partial result described here. Second, make a small temporary change, on the right field:

  • summary and averageScore are already nullable. A failure on either leaves a null that looks like the ordinary "not enough reviews" value, so the result is hard to read.
  • themes is non-null, and so is summarizeMovieReviews itself. With the schema as it is, a failure computing themes removes data entirely and goes through the total-failure branch.

To see the partial branch, temporarily redeclare themes as [String!] (nullable) in the SDL, and add a @SchemaMapping resolver for it on MovieReviewSummary that throws an exception. Inspector then shows:

  • data with themes: null, while summary and the rest of the object stay intact
  • the errors array with the resolver failure
  • isError: true, so the agent knows it has a partial result

Undo the change afterward. Its only purpose is to show that the branch works.

Summary​

This lesson made the MCP result shape explicit. CallToolResult carries content items, an optional structuredContent object and the isError flag. MCP's structured output contract has three parts: an advertised outputSchema, a matching structuredContent, and a serialized text copy for older clients. The ToolResults helper maps GraphQL execution outcomes onto that shape:

  • Total failure becomes a text result with isError: true.
  • Partial and full success become a result with data and errors, sent on both channels.

In partial results the app's error classifications reach the agent, so the agent can tell an authorization failure from a temporary one.

Further Reading​

What's Next​

Class 10 picks up progress reporting for long-running tools. summarizeMovieReviews is the example, because in production the server calls an LLM to answer it, and that takes seconds while the other tools take milliseconds. Progress notifications during that call give the agent something to report, and the user sees a status line. Class 10 covers MCP's progress notifications, what the streamable HTTP transport actually streams, and where the protocol is going for work too long for a progress bar.