Class 3: Schema Descriptions as Agent Documentation
Duration: 50 minutes | Difficulty: Intermediate | Prerequisites: Class 2.
This lesson carries on from Class 2. If you followed along, keep working on the project you already have. If you skipped it, clone the mcp_class_2 branch to start from the same place:
git clone --branch mcp_class_2 https://github.com/graphqlguy/graphQLMovieDB-MCP.git
target/classes after a branch switchChecking out a new class branch on top of an already-built local clone leaves the previous branch's compiled resources sitting in target/classes, because git manages src and leaves the build output alone. The app then fails to start, from the IDE or with ./mvnw spring-boot:run, before the Spring banner appears. ./mvnw test also fails inside context_shouldLoad. Both failures show an unrelated ClassNotFoundException for net.logstash.logback.encoder.LogstashEncoder.
Spring Boot finds the logback-spring.xml that Class 17 adds for JSON logging, while the branch you are on does not declare the encoder dependency that file needs. Run ./mvnw clean after a branch switch, or ./mvnw clean test when you are about to run the suite. This is a known issue in the companion repository. It also happens on an untouched main, and it can come back after any later branch switch.
By the end of this lesson we will be able to write schema descriptions an agent will actually use. We will run them through a CI lint gate that fails the build when a field is missing one, and we will avoid the mistakes agents make when a schema is under-described. The schema you finished in Class 2 is functionally complete. By the end of this one it will also be agent-ready, a stricter standard, because an agent cannot ask the follow-up questions a human reader would.
Why Schema Descriptions Just Got a Promotion
For a decade, descriptions were tooltips: a hover hint a frontend developer might or might not read while writing a query. Agents change this, because an MCP client receives the full text of every tool description in its tools/list response and reads it once before deciding whether to call. A human developer who finds a description unclear can ignore it and read the resolver, or ping a backend engineer on Slack to ask what the field really returns. An agent lacks both of those options, and acts on the description alone.
You already have something to start from. Class 2 wrote good first drafts above the two new operations, and the schema you inherited from the Spring GraphQL tutorial is partly documented: a couple dozen elements carry descriptions. Some of them are already good. The communityRating description, for example, explains when the field is null.
Others are written for the wrong reader. The Movie type's description explains that Triple quotes allow multiline documentation, which is a note to the human reading the SDL file, and reviewCount's description names the Java annotation that resolves it. Documentation written for humans assumes a reader who can ask follow-up questions. This lesson raises it to the standard an agent needs, applies that standard to the Movie type, and adds a test that keeps it consistent.
The Four Questions Every Description Should Answer
Every field description in your schema, agent-facing or not, should answer four questions. They are the same four questions a careful frontend developer would ask in a code review, except now an agent asks them at runtime and either gets answers or guesses.
-
What does the data mean? The semantic question. The type tells the agent
releaseYearis anInt. It does not say whether that is the year of the first public screening, the year of US distribution, or a placeholder year for an upcoming film. Same forcast: speaking parts only, or also extras and voice-only? Only the description can answer this. -
When is it null, and what does null signify? The nullability question. GraphQL marks a field nullable but does not say what null means. "No value yet," "withheld," "precondition failed," and "silent failure" all serialize identically. An agent told what null means behaves correctly; an agent left guessing retries or invents a meaning.
-
What are the units, ranges, or enumerable values? The constraints question. A
Floatis a number; aFloatbetween 0.0 and 10.0 where 0.0 means "real zero, not missing" is actionable. Times need a timezone and a format, money needs a currency and a minor-units convention, and each of those belongs in the description itself. -
What does the agent need to know that the type alone does not say? The caveats question. Duplicate-call contracts, determinism windows, side effects on related entities, rate-limit hints. Anything a careful engineer would mention to a new teammate. If the answer to "would a first-time reader benefit from knowing X?" is yes, write X.
Treat the four questions as a minimum checklist. They cannot cover every convention your domain needs, so read a description that answers all four once more, looking for anything specific to your data. A description that leaves any of them unanswered is almost always too thin for an agent to act on. After you write a description, read it once for each question and ask whether the answer is in there.
Before and After: The Movie Type
Open src/main/resources/graphql/schema.graphqls. This schema came from the Spring GraphQL tutorial, whose goal was teaching GraphQL itself. That is why some of the comments below explain SDL syntax or that course's progression, and say little about the data. The current Movie type, exactly as it arrives in the base app, looks like this:
"""
Custom Movie type
All movie details are taken from The Movie Database (TMDB)
Triple quotes allow multiline documentation.
"""
# We will expand the Movie type as we progress through the tutorial
type Movie implements Content {
id: ID!
title: String!
releaseYear: Int!
genre: Genre
"Average rating from 0 to 10"
rating: Float
"Runtime in minutes"
runtime: Int
plot: String
posterUrl: String
"The Movie Database (TMDB) external identifier"
tmdbId: Int
"""Live community rating from TMDB; null when the movie does not have a tmdbId or TMDB is unavailable"""
communityRating: CommunityRating
directors: [Person!]!
cast: [MovieCast!]!
reviews: [Review!]!
"""Number of reviews - resolved as a grouped COUNT via @BatchMapping"""
reviewCount: Int!
}
Half the fields have descriptions, and the described half is uneven. Read it as an agent would. Is id the catalog's own identifier or the TMDB one two lines below? Average rating from 0 to 10 and Live community rating from TMDB describe two different Float-shaped ratings on the same 0-to-10 scale. Neither says which one answers "what do people think of this movie?" Is genre the only genre, or the primary one out of several, and what does its null mean? Does a future releaseYear mean bad data or an announced film? Does an empty directors list mean "no director" or "we don't know"? Are reviews ordered? And reviewCount's mention of @BatchMapping tells the agent about your resolver internals, which it cannot act on.
The agent cannot ask any of these as follow-up questions; the four-question pass below makes each answer explicit.
The Rating Ambiguity
The sharpest problem in that block is rating versus communityRating. An agent asked to report how a movie is rated sees three candidates in this schema: Movie.rating, Movie.communityRating.voteAverage, and the 1-to-10 score values on individual reviews. The first two are the dangerous pair: both floats, both 0 to 10, and the names do not say whose number each is. In the app's reality, rating is the catalog's own stored figure, captured when the movie was added, while communityRating is fetched live from TMDB per request. An agent that picks arbitrarily will sometimes quote a stale stored number as "the community rating," and nothing in the schema tells it that it guessed wrong.
The fix is to make the two descriptions cross-reference each other explicitly, so each one says where its number comes from and names the other as the alternative. That turns two similar-looking fields into two documented options with a decision rule the agent can apply. A clearer name would help as well, and it is a larger change than it first appears. rating is declared on the Content interface, which TvShow also implements, so renaming it means migrating all three together behind a deprecation window. The descriptions resolve the ambiguity on their own, so that is where this lesson spends its effort.
Here is the rewrite. Every field gets a description, and every claim in them is one the application actually honors:
"""
A movie in the catalog. Movies are the central entity of the system and the
unit most other operations resolve against. Catalog data is seeded from
The Movie Database (TMDB); live TMDB signals surface only through
`communityRating`.
"""
type Movie implements Content {
"""
The catalog's own stable numeric identifier for this movie. Safe to cache
and to pass back into other operations. Not the TMDB identifier; that is
`tmdbId`.
"""
id: ID!
"""The movie's display title as stored in the catalog."""
title: String!
"""
The four-digit year of the movie's release as recorded in the catalog.
Write-time validation bounds it to 1888-2100; a year in the future means
the catalog recorded an announced release and should be treated as
provisional, since release dates slip.
"""
releaseYear: Int!
"""
The single primary genre the catalog tracks for this movie. Most films
span several genres; this is the one the catalog chose to surface, not
the only one that applies. Null when the title has not been classified.
"""
genre: Genre
"""
The catalog's stored average rating for this movie, from 0.0 to 10.0
inclusive, captured when the title entered the catalog. Null when the
catalog does not have a rating on record, which is "no data," not "rated zero"
and not "a bad movie." For a live community score fetched from TMDB at
request time, read `communityRating` instead.
"""
rating: Float
"""Runtime in minutes. Null when the catalog does not know it."""
runtime: Int
"""Short plot synopsis. Null when none is recorded."""
plot: String
"""URL of the poster image. Null when no poster is recorded."""
posterUrl: String
"""
The Movie Database (TMDB) external identifier. Null when the title does
not have a TMDB link, in which case `communityRating` is also null.
"""
tmdbId: Int
"""
Live community rating fetched from TMDB at request time; the freshest
audience signal available, distinct from `rating`, the catalog's
stored figure. Null when the movie does not have a `tmdbId` or TMDB is
unavailable; null never means "poorly rated."
"""
communityRating: CommunityRating
"""
The directors on record for this movie. An empty list means the catalog
does not have a director on record for the title, not that the film
never had one.
"""
directors: [Person!]!
"""
The cast on record: each entry links a person to the character they
played. Empty when no cast has been recorded.
"""
cast: [MovieCast!]!
"""
User-submitted reviews for this movie, newest first. An empty list means
the movie does not have any reviews; reviews are public, and reading
them does not require authentication. Each review carries an integer
`score` from 1 to 10.
"""
reviews: [Review!]!
"""
Number of reviews on record for this movie. Always present; zero means
the movie does not have any reviews.
"""
reviewCount: Int!
}
Three of those rewrites are worth a closer look. rating and communityRating now name each other and say where each number comes from, so an agent choosing between them has a rule to follow. reviewCount dropped its @BatchMapping note. How a field is resolved on the server tells an agent nothing about what the field contains or when to ask for it. Those words are better spent on the data itself, and the description now explains what a zero means. And directors says what an empty list means: the catalog has no director on record. That is a different claim from the film never having had one, and an agent that reports the first as the second states something false.
Movie implements Content, and the interface needs the same treatment, for a reason that is easy to miss. An agent asking about a watch-list entry gets a Content! instead of a Movie, so the description it reads at that point is the interface's. An undocumented interface leaves the agent with the field names alone, at the moment it is reading back something a user asked it to save. The interface has four fields:
interface Content {
"""The title's display name as stored in the catalog."""
title: String!
"""The single primary genre the catalog tracks. Null when unclassified."""
genre: Genre
"""The catalog's stored average rating, 0.0 to 10.0. Null means "no data on record," not "rated zero"."""
rating: Float
"""URL of the poster image. Null when no poster is recorded."""
posterUrl: String
}
The rating wording is deliberately the same sentence the Movie.rating description uses. Two coordinates that mean the same thing should read the same way. An agent that has learned the rule in one place then recognises it in the other.
Before and After: The Agent-Facing Operations
The Class 2 drafts were good first passes, and the watchlist mutation still has the one-line description it shipped with. The rewrites below tighten language, add per-value descriptions on the Mood enum, and write down the contracts Class 2's audit uncovered. Replace the existing recommendMoviesForMood field on the Query type with the documented version below:
"""
Recommends up to ten movies that fit a given mood for the current user,
if authenticated; the operation also works for anonymous callers.
Suitable for low-stakes recommendation flows ("I want something fun tonight")
where the user benefits from a curated suggestion instead of an exhaustive
search. Results are ranked by rating, highest first, with a stable tiebreak
on id, so an agent that retries sees the same ordering and does not need to
reason about result drift. Returns an empty list (never null) when the
catalog does not contain any matches for the mood; an empty list is a valid
answer, not a failure.
"""
recommendMoviesForMood(
"""
The mood to recommend for. Required. The set is bounded by design;
if the desired mood is not in the list, the agent should pick the
closest available value instead of supplying a free-text hint.
"""
mood: Mood!,
"""
When true, omits any movie the authenticated user has marked WATCHED
on their watch list. Defaults to false. For anonymous callers the flag
is a no-op: there is no watch list to exclude against, and the
operation succeeds instead of failing.
"""
excludeWatched: Boolean! = false
): [Movie!]!
As in Class 2, the operation description sits directly above the field it documents, inside the Query block.
The Mood enum needs the most new documentation. Bounded values without descriptions force the agent to guess what THOUGHTFUL means in this domain. Each value gets its own description:
"""
Available mood categories. Bounded by design so an agent cannot supply
free-text moods that would force fuzzy matching on the server. Add a
new value here and update the recommendation engine in the same change.
Give every value a description, because an agent picks a mood by reading it.
"""
enum Mood {
"""
Familiar, low-stakes films the viewer has likely seen before and would
watch again to relax. Warm tone, broad humor, predictable
resolutions.
"""
COMFORT,
"""High-energy action, exploration, or quest arcs. Fast pacing, clear stakes. Films that mainly aim to frighten belong to HORROR."""
ADVENTURE,
"""Films whose spine is a romantic relationship, regardless of surrounding genre. Excludes films where romance is only a subplot."""
ROMANCE,
"""Films aiming to frighten through atmosphere, suspense, or shock. Includes psychological and supernatural horror. Investigative thrillers belong elsewhere."""
HORROR,
"""
Films that reward attention and reflection: character studies, slow
cinema, philosophical science fiction, morally ambiguous dramas. Expect
a longer post-watch conversation than COMFORT; pick this when the user
wants a film to think about afterwards, instead of one to relax with.
"""
THOUGHTFUL,
"""Films whose primary aim is laughter. Spans broad, romantic, satirical, and absurdist comedy. Excludes dramas with comic relief."""
COMEDY
}
summarizeMovieReviews gets the biggest improvement, even though Class 2 already got its shape right. That shape is a non-null MovieReviewSummary for any movie that exists, a NOT_FOUND error for one that does not, and a nullable summary field carrying the "not enough data" case. What Class 2's description left implicit, this rewrite makes explicit. It names the exact review-count threshold, three, instead of saying "too few," and it spells out the zero-review shape field by field. An agent reading the description alone, without ever calling the operation, knows in advance that reviewCount: 0 pairs with summary: null, averageScore: null and an empty themes list. Replace the existing summarizeMovieReviews field on the Query type with this version:
"""
Returns a structured summary of user reviews for a given movie, computed
server-side from the underlying review corpus (integer scores from 1 to 10
plus free-text comments). The result includes a short summary of
what reviewers said and recurring themes pulled from the review text.
Always returns a non-null `MovieReviewSummary` for a movie that exists;
unknown ids produce a NOT_FOUND error instead of a null result, so this
field never returns null. When there are fewer than three reviews, the
system has insufficient material to produce a meaningful `summary`, so
that field is null and the agent should read `reviewCount` to see how
many there were; `averageScore` is null only when `reviewCount` is zero.
When there are zero reviews, the operation still returns a
`MovieReviewSummary` whose `reviewCount` is zero, whose `summary` and
`averageScore` are null, and whose `themes` list is empty, so the agent
can distinguish "no reviews exist" from "fewer than three reviews exist"
by inspecting the result shape instead of guessing from null alone.
"""
summarizeMovieReviews(
"""
The id of the movie whose reviews should be summarized. Must reference
an existing movie; unknown ids produce a NOT_FOUND error instead of a
null result, so this field never returns null.
"""
movieId: ID!
): MovieReviewSummary!
The type itself gets the same field-by-field treatment. Every claim in these descriptions is one the service already honors, so this is a documentation change with no code to update behind it:
"""
A server-computed snapshot of a movie's review corpus: a short summary of
what reviewers said, plus the recurring themes and the mean score it was
computed from.
"""
type MovieReviewSummary {
"""The id of the summarized movie, echoing the operation's argument."""
movieId: ID!
"""Number of reviews the summary was computed from. Zero means the movie does not have any reviews."""
reviewCount: Int!
"""A short summary of what reviewers said. Null when there are fewer than three reviews to write one from; read reviewCount to see how many there were."""
summary: String
"""Recurring themes pulled from the review text. Empty when reviewCount is zero."""
themes: [String!]!
"""Mean of the 1-to-10 review scores. Null only when reviewCount is zero."""
averageScore: Float
}
The result has three states, and they share one shape:
- A missing movie produces a
NOT_FOUNDerror. - An existing movie with zero reviews returns a
MovieReviewSummarywithreviewCount: 0,summary: null,averageScore: nulland an emptythemeslist. That is a fully populated object. - An existing movie with one or two reviews returns the same shape, except that
averageScoreis a real number. There is enough material to average, and too little to write a summary from.
The agent does not have to tell "failed" from "not enough data" by inspecting a top-level null. The top level is always an object, and every distinction is in its fields.
Notice also the boundary the descriptions draw. They name the error's classification, NOT_FOUND, which is part of the wire contract. They leave out which Java exception produces it, because the next section treats that as an implementation detail.
addToWatchlist is the most important upgrade, because Class 2's audit found two contracts that the code enforces and the schema does not mention: the duplicate-add error and the status default. The current description is a single line written for humans, Add a movie or TV show to the signed-in user's watch list. It is accurate, but it does not mention either contract. Here is the rewrite, edited in place on the Mutation type since the field already exists:
"""
Adds a movie or TV show to the signed-in user's watch list and returns the
created entry.
Duplicate contract: each user can hold a given title at most once, enforced
by the database. Calling this for a title already on the list leaves
the list unchanged and fails with a BAD_REQUEST error whose message says the
title is already in the watch list. Agents should treat that specific error as
confirmation that the goal state already holds, not as a failure to report
or retry.
When `status` is omitted, the entry is created as WANT_TO_WATCH.
"""
addToWatchlist(
"""
The title to add. The input is @oneOf: provide exactly one of `movieId`
or `tvShowId`, enforced by the type system before any resolver runs.
"""
subject: WatchlistSubjectInput!,
"""Initial status for the new entry. Defaults to WANT_TO_WATCH when omitted."""
status: WatchStatus
): WatchlistItem!
The WatchStatus enum gets the same per-value treatment as Mood, because "what does WATCHED imply about the entry?" is exactly the kind of question an agent otherwise answers by guesswork:
"""Lifecycle status of a watch-list entry."""
enum WatchStatus {
"""On the list, not yet seen. The default for new entries."""
WANT_TO_WATCH
"""The user has seen it; the entry remains on the list as a record instead of a plan."""
WATCHED
}
WatchlistItem is what the mutation returns. It is the last thing an agent reads before telling the user the save worked, and an agent can get all three of its fields wrong:
type WatchlistItem {
"""The entry's own identifier, distinct from the movie or show it points at. Pass this to setWatchStatus and removeFromWatchlist."""
id: ID!
"""The saved title, movie or TV show, exposed through the shared Content interface."""
content: Content!
"""Where the entry stands: still planned (WANT_TO_WATCH) or already seen (WATCHED)."""
status: WatchStatus!
}
Within WatchlistItem, the id description matters most. An agent that assumes WatchlistItem.id is the movie id passes the wrong value to removeFromWatchlist. Both are ID!, so the type system cannot catch it, and the call either succeeds against some other entry or fails confusingly. One sentence prevents that mistake.
WatchlistSubjectInput, by contrast, does not need an upgrade: its existing description already states the @oneOf rule in one line. Part of upgrading a partially documented schema is recognizing the descriptions that are already agent-grade and leaving them alone.
In the addToWatchlist description, the duplicate-contract paragraph matters most. Agents should treat that specific error as confirmation that the goal state already holds is the kind of rule a human developer learns by hitting the error once and reading the message. An agent does not remember past sessions. Writing the rule down stops the retry loop in every future session.
What Belongs in Descriptions and What Does Not
Wrong content in a description can be worse than missing content. Three kinds of content often end up in descriptions and do not belong there.
Implementation detail does not belong. Database column names, JPA entity names, resolver annotations, cache keys, queue topics: none of these help the agent decide whether or how to call. They couple your public contract to your internal one and turn every refactor into a documentation rewrite. The schema you inherited had an example: reviewCount's description named @BatchMapping, the Java annotation that resolves it, and this lesson deleted it.
Deprecation does not belong in a description. GraphQL has the built-in @deprecated(reason: "...") directive; clients that consult deprecation metadata see it automatically. The schema already does this correctly in two places, and both are good models for agent-facing deprecations: moviesAll carries @deprecated(reason: "Unbounded. Use 'movies' instead.") and searchMovies carries @deprecated(reason: "Use 'search' instead.").
Notice the shape of those reasons: each names the replacement, which is the part an agent can act on. An agent that finds a deprecated field with a named successor switches without guessing. A bare deprecated, do not use leaves the agent without a replacement. The moviesAll reason also explains why (Unbounded), which warns about the problem the replacement solves. A DO NOT USE note inside a description, instead of the directive, weakens the warning and clutters the rest of the description.
Access control does not belong either. Only admins can call this invites the agent to enforce access rules itself. Your filter chain, @PreAuthorize and field-level authorization are what deny unauthorized calls. Code enforces authorization, and descriptions explain the data to the callers who pass it.
The test is "would this help the agent decide whether and how to call?" If not, it does not belong.
That test has a measurable cost behind it. On GitHub's public schema, descriptions account for 167,011 of 370,108 tokens, which is 45 percent of the whole schema, spread across 13,898 description blocks. The agent reads every word you write here when it lists the tools, and you pay for those words on every request that carries the tool list. So writing descriptions and keeping an agent's context small work against each other. The test above decides between them. A description that changes what the agent does is worth its tokens, and one that only repeats the field name wastes them.
Linting Descriptions in CI
Schemas change over time. A merge can add a new field without a description, and the agent's behavior on that operation gets worse without any warning. The cheapest fix is a CI gate that fails the build when any field on any object or interface type lacks a description.
On a schema this size, the gate needs one exception. Dozens of fields inherited from the base app still lack descriptions, and this lesson leaves most of them for later. So the test ships with a LEGACY_ALLOWLIST that names every field still missing one: 91 "Type.field" entries. With the allowlist in place from the start, the gate applies immediately to every field an agent can newly reach. The inherited backlog can shrink over time without blocking CI.
Spring for GraphQL exposes the parsed schema through GraphQlSource. A single Spring Boot test walks the types and asserts every field has a non-empty description, except for the coordinates named in the allowlist:
package com.graphqlguy.moviedb.schema;
import graphql.schema.GraphQLFieldDefinition;
import graphql.schema.GraphQLInterfaceType;
import graphql.schema.GraphQLObjectType;
import graphql.schema.GraphQLSchema;
import graphql.schema.GraphQLType;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.graphql.execution.GraphQlSource;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class SchemaDescriptionLintTest {
/**
* Temporary allowlist of legacy coordinates that predate the description
* discipline (Class 3). The one rule: this list only ever shrinks. Any type
* an agent can reach through the exposed operations must NOT be here.
*/
private static final Set<String> LEGACY_ALLOWLIST = Set.of(
"AuthResponse.token", "AuthResponse.user",
"CommunityRating.voteCount",
"Country.name", "Country.emoji", "Country.capital", "Country.currency",
"DeleteMovieResponse.success", "DeleteMovieResponse.message",
"DeletePersonResponse.success", "DeletePersonResponse.error", "DeletePersonResponse.deletedId",
"DeleteReviewResponse.success",
"Episode.id", "Episode.seasonNumber", "Episode.episodeNumber", "Episode.title",
"Episode.overview", "Episode.airYear",
"MovieCast.id", "MovieCast.characterName", "MovieCast.person", "MovieCast.movie",
"MoviePage.content", "MoviePage.totalElements", "MoviePage.totalPages",
"MoviePage.currentPage", "MoviePage.size", "MoviePage.isFirst", "MoviePage.isLast",
"MoviePage.hasNext", "MoviePage.hasPrevious",
"Mutation.deleteMovie", "Mutation.createMovie", "Mutation.updateMovie",
"Mutation.createPerson", "Mutation.updatePerson", "Mutation.login",
"Person.id", "Person.name", "Person.birthYear",
"PersonPage.content", "PersonPage.totalElements", "PersonPage.totalPages",
"PersonPage.currentPage", "PersonPage.size",
"Query.moviesAll", "Query.movie", "Query.movies", "Query.moviesByIds",
"Query.searchMovies", "Query.people", "Query.person", "Query.searchPeople",
"Query.search", "Query.tvShow", "Query.tvShows",
"Review.id", "Review.comment", "Review.createdAt", "Review.user",
"ReviewNotification.review",
"TmdbResult.tmdbId", "TmdbResult.title", "TmdbResult.releaseYear",
"TmdbResult.overview", "TmdbResult.posterUrl",
"TvShow.id", "TvShow.title", "TvShow.genre", "TvShow.posterUrl", "TvShow.startYear",
"TvShow.seasons", "TvShow.plot", "TvShow.creators", "TvShow.cast", "TvShow.episodes",
"TvShow.reviews",
"TvShowCast.id", "TvShowCast.characterName", "TvShowCast.person", "TvShowCast.tvShow",
"TvShowPage.content", "TvShowPage.totalElements", "TvShowPage.totalPages",
"TvShowPage.currentPage", "TvShowPage.size",
"User.id", "User.username", "User.email", "User.role");
@Autowired
GraphQlSource graphQlSource;
@Test
void everyFieldOnEveryUserDefinedType_shouldHaveADescription() {
GraphQLSchema schema = graphQlSource.schema();
List<String> missing = new ArrayList<>();
for (GraphQLType type : schema.getAllTypesAsList()) {
String typeName = type instanceof GraphQLObjectType o ? o.getName()
: type instanceof GraphQLInterfaceType i ? i.getName()
: null;
if (typeName == null || typeName.startsWith("__")) {
continue;
}
List<GraphQLFieldDefinition> fields = type instanceof GraphQLObjectType o
? o.getFieldDefinitions()
: ((GraphQLInterfaceType) type).getFieldDefinitions();
for (GraphQLFieldDefinition field : fields) {
String desc = field.getDescription();
if ((desc == null || desc.isBlank())
&& !LEGACY_ALLOWLIST.contains(typeName + "." + field.getName())) {
missing.add(typeName + "." + field.getName());
}
}
}
assertThat(missing)
.as("Every field on every user-defined object or interface type must have a non-empty description.")
.isEmpty();
}
}
Three design decisions need explaining. The loop skips __-prefixed introspection meta-types, so the lint leaves graphql-java's own internal types alone. The test fails with the full list of offenders, so a missed batch shows up as one failure that names every field. The LEGACY_ALLOWLIST check also runs as a fallback, after the missing-description check, so it cannot hide a field that has a description. It excuses only the undescribed fields named on the list.
Run it now and the build stays green. Every field the inherited schema left undocumented is named in the allowlist above, all 91 of them. They include MoviePage's paging flags, the Episode and MovieCast fields, AuthResponse and User, the id-and-name basics on Person, and the base app's own operations, Query.movies among them. That list is this lesson's homework: a complete inventory of the fields whose meaning still depends on their names alone.
Shorten it, starting with the types an agent can reach through the operations you expose, because a model reads those descriptions first. Delete each coordinate as soon as you add its description. The one rule is that the list only ever shrinks, and new entries are not allowed. Once the list is empty, the gate covers every field: a new field without a description fails the build immediately.
Verifying with Introspection
Restart the application and open GraphiQL at http://localhost:8080/graphiql. The introspection queries below are exactly what an agent runs at runtime. If introspection is new to you, the fundamentals course covers the meta-fields, the type-wrapper chain and the full introspection query in Introspection.
query IntrospectionQuery {
__type(name: "Movie") {
name
description
fields(includeDeprecated: true) { name description type { name kind ofType { name kind } } }
}
}
Every field comes back with the description you wrote in the SDL. The includeDeprecated: true argument is worth keeping even here, where no field on Movie is deprecated, because __Type.fields hides deprecated fields by default. Run the same query against Query, and the base app's deprecated moviesAll, searchMovies and searchPeople appear only when that argument is present. An agent that introspects without it does not see those fields. Run the same query against Mood and WatchStatus, replacing fields with enumValues { name description }, and confirm each enum value carries its own description. With a description on each value, the enum becomes a controlled vocabulary that the agent can reason about.
Summary
Agents read descriptions when they decide what to call, so this lesson treated descriptions as contracts. The schema arrived partly documented, and documented for humans. The four questions, meaning, null semantics, constraints and caveats, upgraded it to agent-grade. The Movie type's ambiguous rating versus communityRating pair came first, and cross-referencing descriptions resolved it. The agent-facing operations got their contracts written down. summarizeMovieReviews gained its three-state result, a documentation-only tightening, since the service already implemented that contract. addToWatchlist gained documentation for its duplicate-add and status-default behavior, and its code stayed the same.
The base app's own deprecations, moviesAll and searchMovies, modeled the right way to retire a field with a named successor. What does not belong in a description matters just as much: implementation detail such as the @BatchMapping note this lesson deleted, deprecation notes, and access-control rules all live elsewhere. The lint test finishes the job. First it lists the fields that still lack descriptions, and from then on it keeps every new field described.
Further Reading
- GraphQL specification: Descriptions - the protocol-level definition of what the triple-quoted string in SDL means.
- Spring AI: Tool Calling - direct about why it matters: a detailed description is paramount for the model to understand a tool's purpose, and a poor one leads to the tool being skipped or misused.
- Apollo: Schema Naming Conventions - schema-design discipline that ports cleanly to agent-facing tools.
- "Your GraphQL Schema Is Already an MCP Server" - the four-question framing.
- Spring for GraphQL: Request Execution - documents
GraphQlSource, the handle the lint test uses to reach the parsed schema.
What's Next
The schema is now agent-ready in two senses. Every type and operation an agent can reach carries the contract it needs at runtime, and the lint test fails the build whenever a future change forgets that. The next class branches into Track A. We point Apollo MCP Server at this exact endpoint and watch the descriptions reach the agent's tool list, without any Java code from you. If you are following Track B instead, the same descriptions become the source of truth for the @McpTool metadata you write in Class 8. Either way, from Class 4 on you work on the layer that exposes the schema, and the schema itself stays as it is.
Onward to Class 4: Apollo MCP Server in Twenty Minutes. For the original framing of the four questions, and why GraphQL schemas suit agent surfaces so well, the blog post that motivated this tutorial is still a good single read on the subject.