Class 8: Tool Descriptions From the Schema
Duration: 40 minutes | Difficulty: Intermediate-Advanced | Prerequisites: Class 7.
This lesson carries on from Class 7. If you followed along, keep working on the project you already have. If you skipped it, clone the mcp_class_7 branch to start from the same place:
git clone --branch mcp_class_7 https://github.com/graphqlguy/graphQLMovieDB-MCP.git
By the end of this lesson, your tool descriptions come from the GraphQL schema descriptions you improved in Class 3, in place of the hand-typed Java strings on the @McpTool annotations. The schema becomes the single source of the documentation that both frontend developers and AI agents read. When you edit a schema description in schema.graphqls and restart the application, the agent sees the new text, and the Java code stays the same.
The Duplication Problem
Open MovieMcpTools.java and look at the three @McpTool(description = "...") blocks. Now open schema.graphqls and look at the triple-quoted descriptions on recommendMoviesForMood, summarizeMovieReviews, and addToWatchlist. They say similar things in different words. The schema descriptions are more precise, because Class 3 put them through the four questions. The annotation descriptions are more conversational, because Class 7 had you write them while you were focused on the wiring.
This leads to a common failure. Someone improves a description, and changes it in only one file. A month later someone else notices that the other copy is out of date and edits it differently. Now frontend developers and the agent read two slightly different descriptions. With thirty operations, the copies drift apart, and every difference is an accident. The schema should be the source of documentation, and the MCP tool should take its text from there. Class 3 set up the practice, and this lesson wires the code to follow it.
Two Pipelines Into the Tool Registry
Before we write code, one part of the architecture needs to be clear, because this lesson depends on it. Spring AI has two separate tool systems that use similar names, and they reach the MCP server through two separate pipelines.
The first is the chat-model Tool Calling API: ToolCallback, ToolDefinition, the @Tool annotation, MethodToolCallbackProvider. It lets a Spring AI application that calls an LLM give the model tools. It is older than the MCP support and does not know about MCP. The MCP server starter can connect the two. When spring.ai.mcp.server.tool-callback-converter is true (the default), the starter converts any ToolCallback or ToolCallbackProvider beans into MCP tool specifications and registers them with the server.
The second is the MCP annotations pipeline, which Classes 6 and 7 used. When spring.ai.mcp.server.annotation-scanner.enabled is true (also the default), the auto-configuration scans beans for @McpTool methods. It builds MCP SDK tool specifications from them directly, as McpServerFeatures.SyncToolSpecification objects for our SYNC server. This pipeline does not use ToolDefinition or ToolCallback at all.
Both pipelines add tools to the same registry. That rules out the approach that looks most natural: wrapping MovieMcpTools in a MethodToolCallbackProvider and rewriting the descriptions on the resulting callbacks. That approach fails for two reasons:
MethodToolCallbackProviderrecognizes only the chat-model@Toolannotation. When a tool object lacks it, the provider throws anIllegalArgumentExceptionat startup: "No @Tool annotated methods found".MovieMcpToolshas only@McpToolmethods.- Even if it recognized them, the wrapped copies would register next to the scanner's originals. The MCP Java SDK rejects duplicate tool names at startup with an
IllegalArgumentException: "Tool with name '...' is already registered".
The right approach is a third registration route that the starter documents. Declare a bean of type List<McpServerFeatures.SyncToolSpecification>, and the auto-configuration registers every specification in it. A SyncToolSpecification holds two things:
- an
McpSchema.Tool, with the name, title, description and input schema thattools/listadvertises - a call handler that executes the tool
The description we want to change lives on the McpSchema.Tool, and the call handler stays as it is. So the plan has four steps:
- Build the specifications from the
@McpToolmethods, the way the scanner does. - Replace each
Tool's description with the one from the schema. - Register the result ourselves.
- Turn the scanner off, so every tool is registered once.
Reading Descriptions From GraphQlSource
Spring for GraphQL exposes the executable schema through the GraphQlSource bean. From there, graphQlSource.schema() returns a GraphQLSchema whose getQueryType() and getMutationType() give us the typed Query and Mutation GraphQLObjectTypes. Each operation field has a getDescription() that returns the triple-quoted text from the SDL.
A small helper class does the lookup and caches the result:
package com.graphqlguy.moviedb.mcp;
import graphql.schema.GraphQLFieldDefinition;
import graphql.schema.GraphQLObjectType;
import org.springframework.graphql.execution.GraphQlSource;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class SchemaDescriptionProvider {
private final GraphQlSource graphQlSource;
private final Map<String, String> cache = new ConcurrentHashMap<>();
public SchemaDescriptionProvider(GraphQlSource graphQlSource) {
this.graphQlSource = graphQlSource;
}
public Optional<String> describe(String operationName) {
return Optional.ofNullable(cache.computeIfAbsent(operationName, this::lookup));
}
private String lookup(String operationName) {
return findField(graphQlSource.schema().getQueryType(), operationName)
.or(() -> findField(graphQlSource.schema().getMutationType(), operationName))
.map(GraphQLFieldDefinition::getDescription)
.orElse(null);
}
private Optional<GraphQLFieldDefinition> findField(GraphQLObjectType type, String name) {
if (type == null) return Optional.empty();
return Optional.ofNullable(type.getFieldDefinition(name));
}
}
Three details matter here:
- The cache. With
ConcurrentHashMap.computeIfAbsent, the first call for an operation reads the schema, and every later call is a hash lookup. The schema lookup is cheap anyway, becausegetFieldDefinitionis a map access, so the cache is only a small convenience. - The order. The lookup tries
Queryfirst andMutationsecond. A name missing from both returnsOptional.empty(). - Missing descriptions. When
lookupreturnsnull,computeIfAbsentleaves the cache unchanged, so the next call for that name reads the schema again. At this cost that is harmless.describewraps thenullinOptional.ofNullable, so a missing description comes back asOptional.empty(). That is a valid state, and the next section detects it and logs a warning.
Customizing the Tool Registry
Now we wire in the new descriptions. The annotation scanner hands its work to Spring AI's SyncMcpToolProvider, and we can call that same class ourselves. It takes a list of beans, finds their @McpTool methods, and produces one SyncToolSpecification per method. The input JSON Schema generation and the argument binding are the same as the scanner's in Class 7. We run the provider, rebuild each specification's Tool with the description from the schema, and expose the result as the bean that the auto-configuration registers:
package com.graphqlguy.moviedb.mcp;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.spec.McpSchema;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.mcp.annotation.provider.tool.SyncMcpToolProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
/**
* Class 8: the third registration route. We run the class the annotation
* scanner uses (SyncMcpToolProvider) by hand, rewrite each tool's description
* from the GraphQL schema, and contribute the result as the bean the
* auto-configuration registers. The annotation scanner itself is disabled in
* application.yaml so every tool registers exactly once.
*/
@Configuration
public class SchemaDescriptionToolSpecifications {
private static final Logger log =
LoggerFactory.getLogger(SchemaDescriptionToolSpecifications.class);
@Bean
public List<McpServerFeatures.SyncToolSpecification> schemaDescribedTools(
MovieMcpTools movieMcpTools,
HelloMcpTool helloMcpTool,
SchemaDescriptionProvider descriptions) {
List<McpServerFeatures.SyncToolSpecification> scanned =
new SyncMcpToolProvider(List.of(movieMcpTools, helloMcpTool))
.getToolSpecifications();
return scanned.stream()
.map(spec -> withSchemaDescription(spec, descriptions))
.toList();
}
private McpServerFeatures.SyncToolSpecification withSchemaDescription(
McpServerFeatures.SyncToolSpecification spec,
SchemaDescriptionProvider descriptions) {
McpSchema.Tool tool = spec.tool();
String description = descriptions.describe(tool.name()).orElseGet(() -> {
log.warn("No schema description for tool '{}'; falling back to the annotation description.",
tool.name());
return tool.description();
});
McpSchema.Tool rewritten = McpSchema.Tool.builder(tool.name(), tool.inputSchema())
.title(tool.title())
.description(description)
.outputSchema(tool.outputSchema())
.annotations(tool.annotations())
.icons(tool.icons())
.meta(tool.meta())
.build();
return McpServerFeatures.SyncToolSpecification.builder()
.tool(rewritten)
.callHandler(spec.callHandler())
.build();
}
}
SyncMcpToolProvider does what the annotation scanner does at startup, so it returns the same four specifications the server has registered since Class 7: the three movie tools and ping. We pass ping in on purpose, so it stays registered after the configuration change below. For each specification, withSchemaDescription looks up the tool name in the schema and rebuilds the McpSchema.Tool field by field. The two-argument builder(name, inputSchema) sets the required fields, and the chained calls copy the rest. Only the description changes, and the new Tool is paired with the original call handler. McpSchema.Tool is an immutable record, so the code copies every field into a new object.
One more configuration change completes the wiring. Our bean and the annotation scanner would both register the same tool names, and the MCP server refuses duplicates at startup, so the scanner has to be turned off. That takes two new lines, annotation-scanner and enabled: false, inside the spring.ai.mcp.server block that Class 6 created. The listing shows that whole block, so you can see where the two lines go. If you add the block as a second top-level spring: key, startup fails with found duplicate key spring, and every Spring test in the suite fails with it:
spring:
ai:
mcp:
server:
name: moviedb-mcp
version: 1.0.0
type: SYNC
protocol: STREAMABLE
capabilities:
tool: true
resource: false
prompt: false
completion: false
# Class 8: tools are registered through the schemaDescribedTools bean
# (SyncToolSpecification list); the scanner would register duplicates.
annotation-scanner:
enabled: false
If you leave this out, startup fails with IllegalArgumentException: Tool with name '...' is already registered., the MCP Java SDK's error for a duplicate tool name. A clear startup failure is better than a server that quietly offers two copies of a tool. From this lesson on, the List<McpServerFeatures.SyncToolSpecification> bean is the only way tools reach the server. To register a new @McpTool bean, add it to the SyncMcpToolProvider call above.
This is the complete application.yaml after this change:
The complete application.yaml, collapsed because it runs to 66 lines
server:
port: 8080
spring:
application:
name: moviedb
datasource:
url: jdbc:h2:mem:moviedb;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
path: /h2-console
jpa:
hibernate:
ddl-auto: create-drop
show-sql: false
properties:
hibernate:
format_sql: false
graphql:
graphiql:
enabled: true
websocket:
path: /graphql
cors:
allowed-origins: http://localhost:5173
allow-credentials: true
threads:
virtual:
enabled: true
ai:
mcp:
server:
name: moviedb-mcp
version: 1.0.0
type: SYNC
protocol: STREAMABLE
capabilities:
tool: true
resource: false
prompt: false
completion: false
# Class 8: tools are registered through the schemaDescribedTools bean
# (SyncToolSpecification list); the scanner would register duplicates.
annotation-scanner:
enabled: false
jwt:
secret: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
expiration: 86400000
demo:
# Artificial resolver latency for demoing batching / parallel field fetching. 0 = no delay.
latency: 0s
tmdb:
# API Read Access Token (v4); empty fallback keeps the app fully usable without a key
api-key: ${TMDB_API_KEY:}
base-url: https://api.themoviedb.org/3
image-base-url: https://image.tmdb.org/t/p/w500
graphql:
# Instrumentation configuration
# 15, not 10: the standard introspection query is depth 13, so anything lower
# breaks GraphiQL docs/autocomplete and codegen tooling.
max-query-depth: 15
slow-resolver-threshold-ms: 100
With the scanner disabled and the bean in place, the wiring is complete. Restart the application, and MCP Inspector shows tool descriptions identical to your schema descriptions, character for character.
Handling Missing Descriptions
Many schemas have operations without a triple-quoted description. The Movie Database app's schema was one of them. It started partly documented, with real descriptions on operations like watchlist and tmdbSearch and bare names on others, until Class 3 improved the agent-facing operations and added the lint check. A tool registry built on schema descriptions has to handle that mixed state, because many schemas stay partly documented.
The customizer above handles it. It falls back to the annotation description and logs a warning at startup. On a fresh start you will see exactly one warning, for the ping tool from Class 6, which is not a schema operation. That is intended, and the warning also confirms that the fallback path works.
For tools that map to schema operations, act on that warning. A WARN line saying "No schema description for tool 'X'" should become a backlog item to add the description to the schema. To catch this at build time, use the lint test from Class 3, which already covers it. It fails the build when any object-type field in the schema lacks a description, so missing descriptions cannot come back.
For tutorial purposes, the warning-plus-fallback is the right balance. In production, where schema discipline matters more, you might turn the warning into an exception at startup, so the application refuses to start when a tool-mapped operation lacks a schema description.
Including Constraints That Live Outside the Schema
Some constraints that matter to an agent are in the schema, but outside its description text. The Movie Database schema declares several as validation directives:
movieslimits itspageandsizearguments with@Range.CreateReviewInputlimitsscorewith@Range(min: 1, max: 10)andcommentwith@Size(max: 2000).
The extended-validation library enforces these rules before any resolver runs. They are visible in the SDL, but as directives, outside the description text. Other rules exist only as text: idempotency guarantees, rate limits and version-stability promises appear in operation descriptions, when someone writes them down.
These constraints stay out of the tool's input JSON Schema. The input schema is generated from the Java input records, so the @Range and @Size directives in the GraphQL schema do not affect it. On the record side, the generator ignores Bean Validation annotations such as @Min, @Max, @Pattern and @Size. It does recognize:
@McpToolParam, for the description and the required flag- Jackson's
@JsonProperty(required = true)and@JsonPropertyDescription - Swagger's
@Schema - nullability annotations
A rule like "score is 1 to 10" is enforced when the operation runs. The agent cannot see it while planning, unless you add it to the description yourself.
The best place for such a constraint is the description text, because the agent reads that when it decides which tool to call. There are two ways to add it:
- By hand. Write the rule into the schema description. Class 3's four questions already include what the operation expects as input.
- In code. Extend the customizer above. Walk the operation's arguments in the parsed schema, read the
@Range,@Sizeand@Patterndirectives on eachGraphQLArgument, and append a short block to the description before building the newTool.
The code route keeps the schema as the single source for each rule, at the cost of a slightly more complex customizer. You can also restate the constraint with an annotation that the generator recognizes, such as @JsonPropertyDescription on the record component. That documents the same rule in two places.
This tutorial stops at descriptions. The constraints that matter to our three tools are already in the schema descriptions from Class 3. The generated JSON Schema carries the shape and the required fields, which is the division MCP clients expect.
Verifying the Result
Restart the Boot app. Reconnect MCP Inspector to http://localhost:8080/mcp. Click into any of the three tools and read the description. It should match the schema description from Class 3 word for word.
Now make a change in schema.graphqls. Edit the description on recommendMoviesForMood to add a sentence. Save the file, restart the app (or wait for Spring Boot DevTools auto-restart if you have it configured), and reconnect Inspector. The new description shows up in the tool. You did not change any Java code. The tool registry picked up the change, because the description now lives in the schema.
This is the result of the work on schema descriptions in Class 3. Documentation lives in one place, and every client of the schema reads it, including the agent. Every improvement to a description reaches the agent in the same release.
The GraphQL AI Working Group and Why This Work Matters
This lesson treats schema descriptions as the single source of documentation that both humans and agents read. Standards work points in the same direction. The GraphQL Foundation AI Working Group formed in October 2025, following GraphQLConf 2025, to work on the wider problem that this lesson touches.
The Working Group's stated goal is to reduce three problems when LLMs generate GraphQL operations: incorrect field selection, invalid queries, and invented schema elements. Good descriptions help most directly with the third, where an agent references a field that does not exist. An agent with an accurate picture of the schema has less reason to invent one. The Working Group's first technical approach, described in its November 2025 meeting recap, is a semantic similarity search over the schema. It starts from the root query and mutation fields, goes down one level at a time, and compares types and fields with the user's intent to find the relevant ones.
That approach has since grown into the group's semantic introspection RFC, written by Pascal Senn and Michael Staib and published in the Working Group's repository. It proposes a standardized __search introspection field, so an agent can search the schema in natural language. Hot Chocolate v16 shipped semantic introspection as a product feature in May 2026. A fine-tuned open embedding model for GraphQL schemas, Qwen3-Embedding-0.6B-GraphQL, was announced on graphql.org in July 2026 and is being contributed to the Working Group through open pull requests. It scores higher than OpenAI's general-purpose text-embedding-3-large on field recall. The search runs over schema content, including descriptions, so good descriptions help there too, beyond the tool registry.
The Working Group is also developing a subschema directive (working name @require_graphql_subschema, tracked in the Working Group's issue tracker). An agent would get an entry-point subschema with the accessible root fields and commonly used types. The directive declares additional schema dependencies, so the agent can widen its view in a controlled, explicit way. The Apollo Contracts section in Class 5 is the closest existing equivalent. A spec-level directive would let every implementation give each agent its own part of the schema in the same way. It is still a proposal, so watch it, and do not build on it yet.
One Working Group finding needs careful wording, because it is easy to overstate and it ages quickly. In the group's late-2025 comparison of how LLMs write GraphQL operations, Claude Sonnet 4.5 did best on correctness and schema adherence, with GPT-5 models close behind. Newer models have replaced both since then. The group also called the comparison preliminary at the time, because it lacked a reliable way to grade answers against the GraphQL AST. The evaluation work it started has lasted: by mid-2026, a benchmark of 816 natural-language questions with ground-truth answers across five production schemas is being contributed to the group through open pull requests.
Whichever model your agent uses, it reads your schema descriptions when it decides what to call. The Working Group's tooling, semantic schema search and subschema directives, also depends on accurate, well-described schema content. Treat description quality as engineering work with the same priority as the code.
Summary
This lesson removed the duplicated tool descriptions by making the schema the single source. Spring AI feeds the MCP server through two pipelines that add to the same registry: the chat-model tool-callback converter and the @McpTool annotation scanner. To change the descriptions, we took over registration ourselves:
- We called the scanner's own
SyncMcpToolProviderdirectly. - We rebuilt each
McpSchema.Toolwith the description read fromGraphQlSource. - We declared the result as a
List<McpServerFeatures.SyncToolSpecification>bean. - We disabled the annotation scanner, so every tool registers exactly once.
Editing a description in schema.graphqls now changes what the agent sees after the next restart, and the Java code stays the same.
Further Reading
- Spring AI MCP Streamable HTTP Server Boot Starter - documents the
List<McpServerFeatures.SyncToolSpecification>registration route and theannotation-scanner.enabledandtool-callback-converterproperties this lesson leans on. - Spring AI: Tool Calling API - the chat-model tool system (
ToolCallback,ToolDefinition,@Tool); a separate tool system from the MCP annotations, relevant to this lesson only as the converter pipeline's source. - Spring for GraphQL: GraphQlSource - how to read the parsed schema at startup.
- graphql-java: Schema (type-system API) - the type-system API used to walk Query/Mutation fields.
- MCP specification: Tool descriptions - what the agent receives in
tools/listfor each tool, in the 2025-11-25 revision that MCP Java SDK 2.x negotiates.
What's Next
Class 9 turns to structured outputs and errors. On success, the tools currently return typed records, and the framework encodes them as JSON. Class 9 makes the content type an explicit choice, JSON or text, depending on what the agent does with the result. It introduces the isError flag for failures, and maps the three GraphQL execution outcomes onto MCP tool results that an agent can act on:
- full success
- partial data with errors
- total failure