Class 2: Auto-Generating Tool Definitions from a Schema
Duration: ~110 minutes | Difficulty: Advanced | Prerequisites: Class 1 completed; Java 21 and Maven.
This is the first lesson with code, and it uses two repositories. You only start the Movie Database service. Our project, graphQLBeyondMCP, is where you write every line from here to the end of the course.
Both clone commands are in The Starting Point below, in the order you need them, because the service has to be running before our project has anything to call. That section also explains which repository is which, so read it before you run anything.
Code blocks are titled with the file they belong to, and the note in brackets says what to do with it. (new file) is a file to create. (edited, whole file) replaces a file you already have. Anything else names the one part to add or replace, as in (edited, the call method). Where a block shows part of a new file, the complete file follows in a collapsed block underneath.
Class 1 made an argument: a GraphQL schema is, almost by accident, the artifact we would design if we set out to describe a tool catalog for a language model. Typed arguments, typed results, human-written descriptions, deprecations, one graph-shaped surface: tools need these things, and schemas already have them. This lesson turns the argument into a small application. The schema it reads belongs to the Movie Database service, the GraphQL backend this course supplies. The next section starts it with one command.
By the end, that application will do four things:
- It walks the schema and produces a catalog of agent tools: the list this application advertises to a model. Each entry has a JSON Schema input and a ready-to-run GraphQL operation.
- It filters that catalog through an allow-list that a human reviews.
- It hands the result to Spring AI through the interface that every chat model in the framework uses.
- It executes one of those tools against the real service and returns real data. No model is involved yet, so we know the wiring works before a model drives it.
Hand-writing tool definitions looks quicker for the first three tools. The reason to write a generator instead is drift. A hand-written definition copies facts the schema already states: the argument types, the description, and what comes back. Nothing links that copy to its source. Then the schema gains a field, an argument becomes optional, or a description changes. The copy stops matching, and the model still trusts it. A generator makes the schema the single source of truth. The definitions come from it, so a schema change that would silently break a hand-written definition fails the build on the day it is merged.
The generation belongs in the build, where it produces an artifact we can read, diff and review before it ships. Generating at request time would do the same work inside a running process, where a wrong tool definition only becomes visible in how the model behaves.
The Starting Point
The two projects are easy to confuse, so here is what each one does before you clone either. The Movie Database service is the GraphQL backend that the agent will call. We do not write it, and it only needs a terminal to run in. It began in our Spring GraphQL tutorial and grew from there. This course's copy adds two things. Named watch lists give Class 4 a mutation to put behind an approval gate, and a Content interface covers Movie and TvShow. If you came from our MCP course, you will recognise the service. Both courses start from the same tutorial service and add the few changes their own lessons need, so each course has its own backend repository. graphQLBeyondMCP is the project we build in for the rest of this course, so open that one in your IDE. It calls the service over HTTP.
Nothing in the generator is specific to that schema. It reads whatever SDL we hand it and emits tools for whatever operations it finds, so the same code runs against your own service unchanged. We use the Movie Database because every claim in this course should be one you can check. That needs a service you can start with one command, and a schema whose fields we can name in the lessons.
Start the Movie Database service first, on port 8081, so the generator has something to execute against later in the lesson:
git clone https://github.com/graphqlguy/graphQLMovieDB-agents.git
cd graphQLMovieDB-agents
./mvnw spring-boot:run -Dspring-boot.run.arguments=--server.port=8081
Leave that terminal running, and clone our project, graphQLBeyondMCP, in a second one. This is the project we write in for the rest of the course, so open it in your IDE once it is cloned. Build it with clean every time, here and later. Each lesson is a branch of that repository, and Maven leaves the last branch's compiled classes in target/. Switching branches without cleaning makes the build fail on a test from a lesson you have not reached.
Stay on main. It is this course's starting point, so clone it even if you have worked through other courses on this site. It carries only the project skeleton, the Maven dependencies and the committed schema snapshot:
git clone https://github.com/graphqlguy/graphQLBeyondMCP.git
cd graphQLBeyondMCP
mvn clean verify
What Is Already in the Project
The skeleton is four files, and two of them hold decisions that the rest of the lesson depends on. Open each one before you add anything:
pom.xml
src/main/java/com/graphqlguy/moviedb/agents/AgentsApplication.java
src/main/resources/application.yaml
src/main/resources/schema-snapshot.graphqls
AgentsApplication is a standard Spring Boot application class: a main method and an empty body. The command-line entry point we write at the end of this lesson goes here.
application.yaml is short, and two of its keys matter in this lesson:
spring:
main:
web-application-type: none
# The console belongs to the menu and the transcripts, so the startup banner
# stays out of it.
banner-mode: off
agents:
# The schema the generator walks: a committed snapshot, regenerated deliberately,
# so catalog changes show up in pull requests next to the schema change causing them.
schema: src/main/resources/schema-snapshot.graphqls
allow-list: src/main/resources/tool-allowlist.txt
# Where generated tools execute: the running moviedb Spring GraphQL service.
endpoint: http://localhost:8081/graphql
logging:
level:
root: warn
# Where those warnings go is set in logback-spring.xml: a file, so the console
# carries only what the commands printed. Read target/agents.log when a run fails.
web-application-type: none tells Boot to run its command and exit instead of holding a port open, because everything in this lesson is a command we invoke and read the output of. The agents project is a client, and the only server involved is the Movie Database service you started above. The command-line entry point at the end of the lesson reads the agents keys. agents.schema is the file the generator walks, and agents.endpoint is where generated tools execute: port 8081, where you started the service. Setting the root log level to warn silences Boot's startup logging, so the catalog a command prints stays readable, and banner-mode: off keeps Boot's startup banner out of it too. agents.allow-list names the allow-list file, which Choosing What to Expose explains.
pom.xml pins Spring Boot 4.0.5 and Java 21, and adds five dependencies we will meet in the code:
| Dependency | What it does here |
|---|---|
graphql-java 25.0 | parses the schema and provides the type model the generator walks, with no server behind it |
spring-ai-model | supplies the ToolCallback and ToolDefinition contract that GraphQlToolCallback implements later in this lesson, imported through the Spring AI 2.0.1 BOM |
jtokkit 1.1.0 | counts tokens, which is how this course prices what it builds |
spring-web | provides the RestClient that posts operations to the service |
spring-boot-starter-json | brings Jackson |
One import will look wrong if you have written Jackson code before. Boot 4 ships Jackson 3, whose package is tools.jackson.databind, so ObjectMapper comes from there instead of com.fasterxml.jackson.
The Schema Snapshot, and Why It Is a Copy
schema-snapshot.graphqls is the file with the least obvious purpose when you open the project, and it is the generator's entire input. It is a byte-for-byte copy of the Movie Database service's own src/main/resources/graphql/schema.graphqls, taken at the commit this course was written against. It is committed here so the generator has something to read at build time, with the service stopped. The agents project only reads it. It does not serve the schema or validate against it at run time. Open it and look at two parts. type Query declares 16 fields and type Mutation declares 16. Those are the 32 operations the generator walks, and the allow-list reduces them to seven. The WatchList types near the bottom are the additions this course's service carries, and Class 4's approval gate is built around them. Content is the interface over Movie and TvShow that a watch-list entry points at.
When the service's schema changes, you refresh the snapshot by copying it again, which is a deliberate act that shows up in a pull request:
cp ../graphQLMovieDB-agents/src/main/resources/graphql/schema.graphqls \
src/main/resources/schema-snapshot.graphqls
A symlink is the obvious improvement on a copy, and it is what our Apollo MCP Server chapter uses, because there the schema sits a few directories away inside the same project. Here the link would cross a repository boundary, and that breaks it. Git stores a symlink as its path text. A link from graphQLBeyondMCP into a sibling clone of the service therefore works only on a machine where both repositories sit side by side under those exact names. In CI, which clones only one of them, the link target is missing.
With two repositories, there are three real options:
- publish the schema as a versioned artifact that both sides depend on
- fetch it during the build
- commit a snapshot, and let a check catch the drift
This course takes the third, a local file, because it is the simplest to teach from.
A schema file in your own repository is the least realistic part of this setup, so here is what production usually looks like. In a larger organisation, the schema is published, and consumers do not copy it. It goes to a registry such as Apollo GraphOS or GraphQL Hive, or ships as a versioned artifact from the service's own build. Consumers resolve a version of it the way they resolve any other dependency.
A federated setup goes further. The document a consumer reads is the composed supergraph. Composition produces it, the deployment pipeline publishes it, and nobody passes a subgraph's SDL around by hand. The generator we are about to write does not depend on any of that. It takes SDL text and walks it, so the only line that differs between this lesson and your deployment is the one that fetches the text.
What We Add From Here
Everything below is code we add to that skeleton, in this order:
- the shape of the tool being produced
- the translation that fills it
- the policy that filters it
- the walker that drives the whole process
- the wiring that makes it executable
The diagram shows where each piece sits, so you can place every section that follows:
The diagram has two inputs, one generator, and an artifact that a human can read and a framework can execute. The next five sections build those boxes from the top down.
You write all of that code yourself, in the project you cloned. From here to the end of the lesson, every code block with a file path in its title is a file to create at that path inside graphQLBeyondMCP, and typing them in order leaves us with a working generator. There is one exception, called out again where it appears: AgentsApplication.java already exists in the skeleton, so the last block of the lesson replaces its body. You do not create a new file for it.
The Shape of a Generated Tool
Start with the data, because every later decision is easier once the target shape exists. A generated tool has to carry two different kinds of information, and separating them early prevents a lot of confusion later. Some of it is what the model sees: a name, a description, and a JSON Schema describing the arguments. The rest is what actually runs when the model chooses the tool, which is a complete GraphQL operation document. The model does not see that document and does not write any GraphQL.
Create the first file, the record that holds both halves:
package com.graphqlguy.moviedb.agents.toolgen;
import java.util.List;
import java.util.Map;
/**
* One generated tool: everything a model provider needs to advertise it (name,
* description, JSON Schema input) plus the artifact the callback executes (a complete,
* persisted GraphQL operation document). The agent never writes GraphQL; it picks a
* tool and fills arguments, and this document is what actually runs.
*
* @param name the tool name the model sees; equal to the operation field name
* @param description taken from the schema description, the single source of truth
* @param inputSchema JSON Schema for the arguments, produced by JsonSchemaMapper
* @param operationDocument the persisted GraphQL document the callback executes
* @param roles which agent roles may see this tool; empty means every role
* @param tokenCount what advertising this tool costs, measured with one fixed tokenizer
*/
public record OperationTool(String name, String description, Map<String, Object> inputSchema,
String operationDocument, List<String> roles, int tokenCount) {
}
The last two fields are the ones a first attempt usually leaves out. roles exists because a single catalog will need to look different to different agents, which the allow-list section explains shortly. tokenCount exists because this course prices what it builds. A tool definition is not free: it is sent with every model call, so a catalog large enough will use up the context window that the conversation itself needs. Measuring each tool lets us see when that starts to happen.
Translating GraphQL Types into JSON Schema
Every major model provider describes tool parameters in JSON Schema, so the core of the generator is a translation from GraphQL's input type system into it. The translation is mechanical: each GraphQL concept has one JSON Schema counterpart, and walking through them teaches a little about both type systems.
Scalars map to primitives: Int becomes "integer", Float becomes "number", Boolean becomes "boolean", and String becomes "string". ID also becomes "string", which surprises people the first time: whatever the server stores under an ID, the GraphQL spec serializes it as a string in JSON, so a string is what the model must send.
Enums become strings with an explicit enum value list. This is one of the concrete advantages of a typed schema: where a REST API might document "pass a genre string", the schema states the twelve values that exist, and the JSON Schema carries them, so a well-behaved model can only choose real ones.
A non-null wrapper translates into a required property. GraphQL marks a value as required on the type itself (ID!), while JSON Schema records it on the parent object, as a required list naming the mandatory properties. Because of that mismatch, the mapper has two static helpers next to the main method. One unwraps the ! and finds the real type. The other reports whether the wrapper was there, so the caller can add the name to its own required list.
The complete JsonSchemaMapper.java, collapsed because it runs to 92 lines
package com.graphqlguy.moviedb.agents.toolgen;
import graphql.schema.GraphQLEnumType;
import graphql.schema.GraphQLEnumValueDefinition;
import graphql.schema.GraphQLInputObjectField;
import graphql.schema.GraphQLInputObjectType;
import graphql.schema.GraphQLInputType;
import graphql.schema.GraphQLList;
import graphql.schema.GraphQLNonNull;
import graphql.schema.GraphQLScalarType;
import graphql.schema.GraphQLType;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Translates GraphQL input types into JSON Schema, the language every major model
* provider expects tool parameters in. The mapping is mechanical, which is the whole
* point of this class of the course: the schema already contains everything a tool
* definition needs, so producing one is translation, never authoring.
* <p>
* The rules, each mirroring a GraphQL concept:
* - scalars map to JSON Schema primitives; ID becomes a string, because that is how
* it travels in JSON regardless of what the server stores.
* - enums become string types with an explicit value list, so the model can only
* choose values that exist.
* - non-null wrappers do not change the type; they put the field on the parent's
* "required" list, which is where JSON Schema expresses obligation.
* - lists become arrays of the mapped element type.
* - input objects map recursively to nested object schemas.
*/
public class JsonSchemaMapper {
public Map<String, Object> toJsonSchema(GraphQLInputType type) {
return mapType(unwrapNonNull(type));
}
/** Non-null says the value is required, which the caller records; the type is inside. */
public static GraphQLInputType unwrapNonNull(GraphQLInputType type) {
if (type instanceof GraphQLNonNull nonNull) {
return (GraphQLInputType) nonNull.getWrappedType();
}
return type;
}
public static boolean isRequired(GraphQLInputType type) {
return type instanceof GraphQLNonNull;
}
private Map<String, Object> mapType(GraphQLType type) {
Map<String, Object> schema = new LinkedHashMap<>();
switch (type) {
case GraphQLScalarType scalar -> schema.put("type", switch (scalar.getName()) {
case "Int" -> "integer";
case "Float" -> "number";
case "Boolean" -> "boolean";
default -> "string"; // String, ID, and custom scalars travel as strings
});
case GraphQLEnumType enumType -> {
schema.put("type", "string");
schema.put("enum", enumType.getValues().stream()
.map(GraphQLEnumValueDefinition::getName).toList());
if (enumType.getDescription() != null) {
schema.put("description", enumType.getDescription().strip());
}
}
case GraphQLList list -> {
schema.put("type", "array");
schema.put("items", mapType(unwrapNonNull((GraphQLInputType) list.getWrappedType())));
}
case GraphQLInputObjectType inputObject -> {
schema.put("type", "object");
Map<String, Object> properties = new LinkedHashMap<>();
List<String> required = new ArrayList<>();
for (GraphQLInputObjectField field : inputObject.getFieldDefinitions()) {
Map<String, Object> fieldSchema = mapType(unwrapNonNull(field.getType()));
if (field.getDescription() != null) {
fieldSchema.put("description", field.getDescription().strip());
}
properties.put(field.getName(), fieldSchema);
if (isRequired(field.getType())) {
required.add(field.getName());
}
}
schema.put("properties", properties);
if (!required.isEmpty()) {
schema.put("required", required);
}
schema.put("additionalProperties", false);
}
case null, default -> throw new IllegalStateException(
"Unmapped input type: " + type + ". Every input type a tool exposes"
+ " must have a JSON Schema translation.");
}
return schema;
}
}
Three things in that file are judgment calls that could reasonably have gone the other way.
The default -> "string" arm of the scalar switch catches String, ID, and every custom scalar the schema declares. That is the correct behavior for a first generator, and it is also a known limitation. A custom DateTime scalar reaches the model as a bare string. Nothing tells the model its format, so it can send anything that is textually a string, and only the server will object. Improving it means carrying a format keyword per custom scalar, which the MCP course does through explicit configuration instead of inference. A reasonable first version lets the server reject bad values, and we know that this is what happens.
The additionalProperties: false line on every object schema looks like boilerplate, but it is a safety control. Without it, JSON Schema permits properties beyond the declared ones. For a tool input, that means the model may invent arguments, and nobody notices until the server rejects the operation or silently ignores the extras. With the line, the contract allows exactly the declared arguments. Model providers enforce it with different strictness. That is why the generated GraphQL operation passes arguments as typed variables. Anything that gets past the model-side contract still meets the server's own validation, and the model cannot widen the operation with what it sends.
The final case null, default throws an exception instead of returning an empty schema, and that choice is deliberate. An unmapped input type leaves a gap in the contract that the model reads. Failing loudly at generation time fails the build, and somebody fixes it that day. Returning {} instead ships a tool with an argument that nobody described, and the model then makes confident calls against it.
JSON Schema and GraphQL disagree about where a required value is marked, and the difference comes from old design decisions. GraphQL puts non-null on the type, because the type travels with the field everywhere it appears. JSON Schema puts required on the parent object, because the same property may be optional in one object and mandatory in another. Neither choice is wrong, and any generator bridging the two has to unwrap one representation into the other. Forgetting that unwrap is the classic first bug in hand-written mappers: everything compiles, and every argument quietly becomes optional.
Choosing What to Expose
The generator could emit a tool for all 32 operations the Movie Database schema declares, including deleteMovie and register. It must not, and the rule that prevents it should be deliberately simple. Write the policy as a plain text file, one operation per line, with an optional role list after a colon:
# The curation decision: which operations agents may see, and which roles see them.
# One operation per line; an optional colon lists the roles allowed to use it.
# Anything absent from this file is invisible to every agent. Reviewed like code.
# Read-only lookups: safe for every agent role.
movie
movies
search
person
tvShow
# Watchlist reads are personal data: concierge only.
myWatchLists: concierge
# The one permitted write, and only for the concierge role.
# Mutations beyond this line stay off the list entirely; Class 4 explains the
# confirmation pattern that governs even this one.
addWatchListItem: concierge
One line in that file needs explaining first, because it shows what curation does. The schema also offers searchMovies(title: String!), a narrower and more obvious fit for title search, carrying @deprecated(reason: "Use 'search' instead."). Class 1 called deprecation markers stale-tool flags, and this is the cost of respecting one. search is the supported operation, so search is what the agent gets, even though it returns a union and needs the hand-written selection Class 4 adds. An agent pointed at a deprecated operation is one we have to re-point later, and reading the marker now costs less than discovering the removal through a model's confusion.
Three decisions are encoded in that file.
- The default is deny. An operation absent from the file is invisible to every agent, so exposure is always a deliberate act with a name attached to it in version control.
- Roles are part of curation. A support agent answering catalog questions and a concierge agent managing a user's watchlist need different tools, and the file states who sees what.
- The review diff is the security review. This is why the format is plain text instead of something richer. When a pull request adds a line reading
deleteMovie: concierge, a reviewer sees exactly that, without a configuration format to read through first.
This is the same reasoning that made persisted-query allow-lists work for mobile clients a decade ago, where the client sends the identifier of an operation the server already holds instead of the operation text. Class 4 builds the full safety design on it.
The code that reads the file is correspondingly small, and its one interesting method is allows:
package com.graphqlguy.moviedb.agents.toolgen;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* The curation decision, kept in a file a human reviews in a pull request. One
* operation per line, optionally followed by a colon and the roles allowed to see it:
* <p>
* movie
* myWatchLists: concierge
* addWatchListItem: concierge
* <p>
* An operation absent from this file is invisible to every agent, which makes the
* default deny. The format is deliberately plain text: the review diff IS the
* security review, and nothing should stand between a reviewer and the meaning of
* a changed line.
*/
public class AllowList {
private final Map<String, List<String>> entries;
private AllowList(Map<String, List<String>> entries) {
this.entries = entries;
}
public static AllowList load(Path file) {
Map<String, List<String>> entries = new LinkedHashMap<>();
try {
for (String raw : Files.readAllLines(file)) {
String line = raw.strip();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
String[] parts = line.split(":", 2);
List<String> roles = parts.length == 1 ? List.of()
: Arrays.stream(parts[1].split(",")).map(String::strip)
.filter(role -> !role.isEmpty()).toList();
entries.put(parts[0].strip(), roles);
}
} catch (IOException e) {
throw new IllegalStateException("Could not read the allow-list at " + file, e);
}
return new AllowList(entries);
}
public boolean allows(String operationName, String role) {
List<String> roles = entries.get(operationName);
if (roles == null) {
return false; // absent from the file means denied, for every role
}
return role == null || roles.isEmpty() || roles.contains(role);
}
public List<String> rolesFor(String operationName) {
return entries.getOrDefault(operationName, List.of());
}
}
Read the three return paths in allows as a policy statement, because that is what they are. An operation that the file does not mention is denied outright, which is the default-deny rule expressed in one line. An entry with no roles after it is visible to everyone, which is what makes the common case (a safe read) cheap to write. An entry with roles is visible only to those roles. The role == null case answers a question we meet as soon as we write the command-line tool. Asking for the whole catalog without naming a role should show us everything that is allow-listed. That view is for the human auditing the file, and no agent ever sees it.
Walking the Schema
Everything so far has been passive: a record to fill (OperationTool), a translation to apply (JsonSchemaMapper), and a policy to consult (AllowList). The generator is the component that puts them to work, and it needs a parsed schema to start from. graphql-java produces one from SDL text without any server behind it:
GraphQLSchema schema = UnExecutableSchemaGenerator.makeUnExecutableSchema(
new SchemaParser().parse(Files.readString(Path.of("schema-snapshot.graphqls"))));
"Unexecutable" names a real distinction. An executable schema has data fetchers attached, code that can actually resolve fields. Our generator does not need any of that: it reads type structure and descriptions, and the operations it emits will execute on the real server, not here. UnExecutableSchemaGenerator builds exactly this read-only view, and it is the same tool that the Class 3 instrument uses for search indexing.
The alternative to reading the committed snapshot is introspecting the live service on every generator run, and Class 3's schema loader shows how to do that if you decide you need it. For a build-time artifact, the snapshot is the better choice because of its effect on pull requests. When the schema changes, the person changing it regenerates the catalog, and the reviewer sees the schema diff and the catalog diff side by side in one review. A catalog that changes only when someone changes it deliberately is a catalog we can audit.
That has a real cost: someone must remember to refresh the snapshot, and the build cannot tell that the live service has moved on in the meantime. The build does catch the other half of the problem, once Class 11 adds a test that compares the generated catalog against a committed copy of it. If someone refreshes the snapshot without regenerating the catalog, that test fails, and the change reaches a reviewer.
With a schema in hand, generation is a walk over the two operation roots. For each field the allow-list permits, the generator assembles the JSON Schema for its arguments, builds the operation document, prices the result, and adds an OperationTool:
package com.graphqlguy.moviedb.agents.toolgen;
import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.Encoding;
import com.knuddels.jtokkit.api.EncodingType;
import graphql.schema.GraphQLArgument;
import graphql.schema.GraphQLEnumType;
import graphql.schema.GraphQLFieldDefinition;
import graphql.schema.GraphQLObjectType;
import graphql.schema.GraphQLScalarType;
import graphql.schema.GraphQLSchema;
import graphql.schema.GraphQLType;
import graphql.schema.GraphQLTypeUtil;
import tools.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Walks the schema's Query and Mutation roots and emits one tool per allow-listed
* operation. Generation is a build-time activity: the catalog is a reviewable
* artifact, regenerated when the schema changes, never assembled at request time.
*/
public class ToolCatalogGenerator {
private final JsonSchemaMapper schemaMapper = new JsonSchemaMapper();
private final ObjectMapper json = new ObjectMapper();
private final Encoding encoding =
Encodings.newDefaultEncodingRegistry().getEncoding(EncodingType.O200K_BASE);
public List<OperationTool> generate(GraphQLSchema schema, AllowList allowList, String role) {
List<OperationTool> tools = new ArrayList<>();
addRoot(tools, schema.getQueryType(), "query", allowList, role);
addRoot(tools, schema.getMutationType(), "mutation", allowList, role);
return tools;
}
private void addRoot(List<OperationTool> tools, GraphQLObjectType root, String operationKind,
AllowList allowList, String role) {
if (root == null) {
return;
}
for (GraphQLFieldDefinition field : root.getFieldDefinitions()) {
if (!allowList.allows(field.getName(), role)) {
continue;
}
if (field.getDescription() == null || field.getDescription().isBlank()) {
throw new IllegalStateException(
"Operation '" + field.getName() + "' is on the allow-list without a schema"
+ " description. The model cannot tell such a tool apart from its neighbours,"
+ " so describe the operation in the schema or remove it from the"
+ " allow-list.");
}
String description = field.getDescription().strip();
Map<String, Object> inputSchema = new LinkedHashMap<>();
inputSchema.put("type", "object");
Map<String, Object> properties = new LinkedHashMap<>();
List<String> required = new ArrayList<>();
for (GraphQLArgument argument : field.getArguments()) {
Map<String, Object> argSchema = schemaMapper.toJsonSchema(argument.getType());
if (argument.getDescription() != null) {
argSchema.put("description", argument.getDescription().strip());
}
properties.put(argument.getName(), argSchema);
if (JsonSchemaMapper.isRequired(argument.getType())) {
required.add(argument.getName());
}
}
inputSchema.put("properties", properties);
if (!required.isEmpty()) {
inputSchema.put("required", required);
}
inputSchema.put("additionalProperties", false);
String document = operationDocument(operationKind, field);
int tokens = encoding.countTokens(
field.getName() + " " + description + " " + json.writeValueAsString(inputSchema));
tools.add(new OperationTool(field.getName(), description, inputSchema,
document, allowList.rolesFor(field.getName()), tokens));
}
}
}
The complete ToolCatalogGenerator.java, collapsed because it runs to 138 lines
package com.graphqlguy.moviedb.agents.toolgen;
import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.Encoding;
import com.knuddels.jtokkit.api.EncodingType;
import graphql.schema.GraphQLArgument;
import graphql.schema.GraphQLEnumType;
import graphql.schema.GraphQLFieldDefinition;
import graphql.schema.GraphQLObjectType;
import graphql.schema.GraphQLScalarType;
import graphql.schema.GraphQLSchema;
import graphql.schema.GraphQLType;
import graphql.schema.GraphQLTypeUtil;
import tools.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Walks the schema's Query and Mutation roots and emits one tool per allow-listed
* operation. Generation is a build-time activity: the catalog is a reviewable
* artifact, regenerated when the schema changes, never assembled at request time.
* <p>
* The selection set each tool executes is generated with a deliberate default: the
* scalar and enum fields of the returned object type, one level deep. That default
* keeps generated operations cheap and predictable; anything richer (nested
* relations, connections) is a curation decision a human makes by editing the
* generated document, exactly as they would curate a persisted query.
*/
public class ToolCatalogGenerator {
private final JsonSchemaMapper schemaMapper = new JsonSchemaMapper();
private final ObjectMapper json = new ObjectMapper();
private final Encoding encoding =
Encodings.newDefaultEncodingRegistry().getEncoding(EncodingType.O200K_BASE);
public List<OperationTool> generate(GraphQLSchema schema, AllowList allowList, String role) {
List<OperationTool> tools = new ArrayList<>();
addRoot(tools, schema.getQueryType(), "query", allowList, role);
addRoot(tools, schema.getMutationType(), "mutation", allowList, role);
return tools;
}
private void addRoot(List<OperationTool> tools, GraphQLObjectType root, String operationKind,
AllowList allowList, String role) {
if (root == null) {
return;
}
for (GraphQLFieldDefinition field : root.getFieldDefinitions()) {
if (!allowList.allows(field.getName(), role)) {
continue;
}
if (field.getDescription() == null || field.getDescription().isBlank()) {
throw new IllegalStateException(
"Operation '" + field.getName() + "' is on the allow-list without a schema"
+ " description. The model cannot tell such a tool apart from its neighbours,"
+ " so describe the operation in the schema or remove it from the"
+ " allow-list.");
}
String description = field.getDescription().strip();
Map<String, Object> inputSchema = new LinkedHashMap<>();
inputSchema.put("type", "object");
Map<String, Object> properties = new LinkedHashMap<>();
List<String> required = new ArrayList<>();
for (GraphQLArgument argument : field.getArguments()) {
Map<String, Object> argSchema = schemaMapper.toJsonSchema(argument.getType());
if (argument.getDescription() != null) {
argSchema.put("description", argument.getDescription().strip());
}
properties.put(argument.getName(), argSchema);
if (JsonSchemaMapper.isRequired(argument.getType())) {
required.add(argument.getName());
}
}
inputSchema.put("properties", properties);
if (!required.isEmpty()) {
inputSchema.put("required", required);
}
inputSchema.put("additionalProperties", false);
String document = operationDocument(operationKind, field);
int tokens = encoding.countTokens(
field.getName() + " " + description + " " + json.writeValueAsString(inputSchema));
tools.add(new OperationTool(field.getName(), description, inputSchema,
document, allowList.rolesFor(field.getName()), tokens));
}
}
/**
* Builds the persisted operation the tool executes, forwarding every argument as
* a variable with its original GraphQL type, so the server's own validation and
* coercion still apply to whatever the model supplies.
*/
private String operationDocument(String operationKind, GraphQLFieldDefinition field) {
StringBuilder doc = new StringBuilder(operationKind).append(" Tool_").append(field.getName());
if (!field.getArguments().isEmpty()) {
List<String> varDefs = field.getArguments().stream()
.map(argument -> "$" + argument.getName() + ": "
+ GraphQLTypeUtil.simplePrint(argument.getType()))
.toList();
doc.append("(").append(String.join(", ", varDefs)).append(")");
}
doc.append(" { ").append(field.getName());
if (!field.getArguments().isEmpty()) {
List<String> args = field.getArguments().stream()
.map(argument -> argument.getName() + ": $" + argument.getName())
.toList();
doc.append("(").append(String.join(", ", args)).append(")");
}
doc.append(defaultSelection(field.getType()));
doc.append(" }");
return doc.toString();
}
private String defaultSelection(GraphQLType returnType) {
GraphQLType unwrapped = GraphQLTypeUtil.unwrapAll(returnType);
if (!(unwrapped instanceof GraphQLObjectType objectType)) {
return ""; // scalar, enum, union, or interface returns: nothing generatable safely
}
List<String> scalarFields = objectType.getFieldDefinitions().stream()
.filter(field -> {
GraphQLType fieldType = GraphQLTypeUtil.unwrapAll(field.getType());
return fieldType instanceof GraphQLScalarType || fieldType instanceof GraphQLEnumType;
})
.map(GraphQLFieldDefinition::getName)
.toList();
if (scalarFields.isEmpty()) {
return "";
}
return " { " + String.join(" ", scalarFields) + " }";
}
public String toJson(List<OperationTool> tools) {
List<Map<String, Object>> out = new ArrayList<>();
for (OperationTool tool : tools) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("name", tool.name());
entry.put("description", tool.description());
entry.put("inputSchema", tool.inputSchema());
entry.put("operation", tool.operationDocument());
entry.put("roles", tool.roles());
entry.put("tokens", tool.tokenCount());
out.add(entry);
}
return json.writerWithDefaultPrettyPrinter().writeValueAsString(out);
}
}
The description handling is the part to notice. A tool the model cannot tell apart from its neighbours does more harm than leaving the tool out, because the model still chooses it, for the wrong request. The description is the only thing that tells the model when to call an operation, so the model cannot use a tool without one correctly. The generator therefore refuses to emit such a tool, and the build fails. A generator can copy a description from the schema, but it cannot write one.
When the build fails this way, the fix belongs in the schema rather than the catalog. Every other consumer benefits there too: GraphiQL, the generated docs, and the MCP course's tool registry. Failing at generation time makes someone write the description in the one place that helps every consumer.
Two lines in that class do the pricing. The encoding field near the top chooses a tokenizer, and encoding.countTokens(...) further down measures each tool's name, description and input schema with it. O200K_BASE is the name of one specific tokenizer, the one OpenAI uses for its recent models, which the jtokkit library implements in Java. Pinning a single one is deliberate: every provider splits text into tokens differently, so one count cannot be correct for all of them. Using the same tokenizer everywhere is still useful, because the question this course asks is comparative: is this catalog bigger than that one, and is it growing.
The Operation Behind Each Tool
The tool definition is what the model reads. The operation is what actually runs, and the generator writes one complete GraphQL document for each tool. Add the three methods below inside the ToolCatalogGenerator class we just wrote, above its closing brace.
/**
* Builds the persisted operation the tool executes, forwarding every argument as
* a variable with its original GraphQL type, so the server's own validation and
* coercion still apply to whatever the model supplies.
*/
private String operationDocument(String operationKind, GraphQLFieldDefinition field) {
StringBuilder doc = new StringBuilder(operationKind).append(" Tool_").append(field.getName());
if (!field.getArguments().isEmpty()) {
List<String> varDefs = field.getArguments().stream()
.map(argument -> "$" + argument.getName() + ": "
+ GraphQLTypeUtil.simplePrint(argument.getType()))
.toList();
doc.append("(").append(String.join(", ", varDefs)).append(")");
}
doc.append(" { ").append(field.getName());
if (!field.getArguments().isEmpty()) {
List<String> args = field.getArguments().stream()
.map(argument -> argument.getName() + ": $" + argument.getName())
.toList();
doc.append("(").append(String.join(", ", args)).append(")");
}
doc.append(defaultSelection(field.getType()));
doc.append(" }");
return doc.toString();
}
private String defaultSelection(GraphQLType returnType) {
GraphQLType unwrapped = GraphQLTypeUtil.unwrapAll(returnType);
if (!(unwrapped instanceof GraphQLObjectType objectType)) {
return ""; // scalar, enum, union, or interface returns: nothing generatable safely
}
List<String> scalarFields = objectType.getFieldDefinitions().stream()
.filter(f -> {
GraphQLType fieldType = GraphQLTypeUtil.unwrapAll(f.getType());
return fieldType instanceof GraphQLScalarType || fieldType instanceof GraphQLEnumType;
})
.map(GraphQLFieldDefinition::getName)
.toList();
if (scalarFields.isEmpty()) {
return "";
}
return " { " + String.join(" ", scalarFields) + " }";
}
public String toJson(List<OperationTool> tools) {
List<Map<String, Object>> out = new ArrayList<>();
for (OperationTool tool : tools) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("name", tool.name());
entry.put("description", tool.description());
entry.put("inputSchema", tool.inputSchema());
entry.put("operation", tool.operationDocument());
entry.put("roles", tool.roles());
entry.put("tokens", tool.tokenCount());
out.add(entry);
}
return json.writerWithDefaultPrettyPrinter().writeValueAsString(out);
}
toJson makes the catalog readable in a pull request, as the start of this lesson argued. It is pretty-printed for the same reason: a diff of minified JSON tells a reviewer that something changed and hides what.
For the movie operation, that produces this:
query Tool_movie($id: ID!) { movie(id: $id) { id title releaseYear genre rating runtime plot posterUrl tmdbId reviewCount } }
Two properties of this document look accidental, and both were chosen deliberately. The first is that arguments travel as variables carrying their original GraphQL types, printed by GraphQLTypeUtil.simplePrint. ID! stays ID!, so the server's own validation and coercion apply to whatever the model supplies, and the tool layer does not trust any of it.
The second is the selection set: the list of fields an operation asks for, the part inside the braces of a GraphQL query. defaultSelection builds it, taking the scalar and enum fields of the returned type, one level deep, and stopping there. Following relations instead would turn "look up a movie" into "load the movie, its whole cast, their filmographies and every review", which is expensive for both the token bill and the database. (Where a tool needs more, Class 4 adds a way to replace a generated selection with a hand-written one.)
The two places where defaultSelection returns an empty string document the generator's limits. A union or interface return produces an empty selection. Choosing fields across members needs inline fragments, and a decision about which members matter, which is a judgment a generator should leave to a person. An object type whose fields are all relations behaves the same way. In both cases the document fails validation at the server, loudly, which is the right place for a limitation to surface.
One tool on the allow-list meets that limit immediately, so here it is in more detail. search returns [SearchResult!]!, a union of Movie, Person and TvShow, so the generated document ends at the field name and the server refuses it:
{"errors":[{"message":"Validation error (SubselectionRequired@[search]) : Subselection
required for type '[SearchResult!]!' of field 'search'","extensions":{"classification":"ValidationError"}}]}
The project still builds, and the catalog still generates all seven tools. search is the only one that fails, and it fails when we call it, because the server is the first thing in the chain that can see the document is incomplete. The other six work against the live service from this lesson onward. Class 4 adds the fix: a hand-written selection under tool-selections/, where a person writes the inline fragments once, and decides that a person result needs only an id and a name.
Wiring Into Spring AI
Everything built so far produces data, and the last piece is what makes that data executable by the framework. It is smaller than it looks, because Spring AI's whole tool system rests on one interface. ToolCallback has two responsibilities. getToolDefinition() returns what gets advertised to the model. call(String) runs when the model chooses the tool, taking the model's arguments as a JSON string and returning a string result. Every richer facility in the framework, the @Tool annotation included, is built on this contract.
package com.graphqlguy.moviedb.agents.catalog;
import com.graphqlguy.moviedb.agents.toolgen.OperationTool;
import org.jspecify.annotations.NonNull;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.DefaultToolDefinition;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.web.client.RestClient;
import tools.jackson.databind.ObjectMapper;
import java.util.Map;
/**
* The bridge between a generated tool and Spring AI. ToolCallback is the contract
* every Spring AI chat model consumes: getToolDefinition() is what gets advertised
* to the model, and call() runs when the model chooses the tool.
* <p>
* The call itself is deliberately unexciting: the model's JSON arguments become
* GraphQL variables on the persisted operation document, posted to the endpoint.
* The model chose an operation by name and filled typed blanks; it composed
* nothing, which is precisely the safety property the allow-list pattern provides.
*/
public class GraphQlToolCallback implements ToolCallback {
private final OperationTool tool;
private final String endpoint;
private final ObjectMapper json = new ObjectMapper();
public GraphQlToolCallback(OperationTool tool, String endpoint) {
this.tool = tool;
this.endpoint = endpoint;
}
@Override
public @NonNull ToolDefinition getToolDefinition() {
return DefaultToolDefinition.builder()
.name(tool.name())
.description(tool.description())
.inputSchema(json.writeValueAsString(tool.inputSchema()))
.build();
}
@Override
public @NonNull String call(@NonNull String toolInput) {
Map<?, ?> variables = json.readValue(toolInput, Map.class);
Map<String, Object> body = Map.of(
"query", tool.operationDocument(),
"variables", variables);
String response = RestClient.create()
.post()
.uri(endpoint)
.header("Content-Type", "application/json")
.body(body)
.retrieve()
.body(String.class);
return response == null ? "" : response;
}
}
call is short, and that is what makes it safe. The model's JSON becomes the operation's variables, and the persisted document goes to the server unchanged. The model never sees that document, and the callback never edits it. The only thing the model can influence is the values of the variables, and the server validates those the same way it validates any other client's.
The inputSchema is handed over as a JSON string instead of a structured object, which is the shape DefaultToolDefinition expects. It is also a small reminder of where the boundary sits. From here on the definition is data destined for a provider's API, and Spring AI passes it along without interpreting it.
The Command-Line Entry Point
The last piece is how you run any of this. The application discovers commands as beans: each lesson of this course adds one file, and the entry point itself never changes again. Two small types set that up, starting with what a command is:
package com.graphqlguy.moviedb.agents.command;
import java.util.List;
/**
* One command the application can run. Each class of this course adds one, and the
* application discovers them as beans, so a new command means a new file rather
* than a longer switch statement.
*/
public interface AgentCommand {
/** The name typed on the command line, or chosen from the menu. */
String name();
/** One line describing what the command does, shown in the menu. */
String summary();
/** What the command asks for when the menu runs it, or an empty string if it takes no argument. */
default String prompt() {
return "";
}
/** Where the command sits in the menu: the class that introduces it, times ten. */
int order();
void run(List<String> args, CommandContext context) throws Exception;
}
The second is what every command needs, built once at startup, so no command reparses the schema or reaches for configuration on its own:
package com.graphqlguy.moviedb.agents.command;
import com.graphqlguy.moviedb.agents.toolgen.AllowList;
import com.graphqlguy.moviedb.agents.toolgen.OperationTool;
import com.graphqlguy.moviedb.agents.toolgen.ToolCatalogGenerator;
import graphql.schema.GraphQLSchema;
import org.springframework.core.env.Environment;
import java.util.List;
import java.util.Scanner;
/**
* Everything a command needs, built once at startup: the parsed schema, the
* allow-list, the generator, the endpoint tools execute against, the environment,
* and one console reader. The reader is shared deliberately: two Scanners on
* System.in each buffer what they read, so the second one loses input the first
* already pulled in.
*/
public record CommandContext(Environment env,
GraphQLSchema schema,
AllowList allowList,
ToolCatalogGenerator generator,
String schemaPath,
String endpoint,
Scanner console) {
/** The catalog for one role, or every allow-listed operation when role is null. */
public List<OperationTool> tools(String role) {
return generator.generate(schema, allowList, role);
}
/** The role this run advertises, from configuration. */
public String role() {
return env.getProperty("agents.role", "concierge");
}
}
catalog is then a small class with one job:
package com.graphqlguy.moviedb.agents.command;
import com.graphqlguy.moviedb.agents.toolgen.OperationTool;
import org.springframework.stereotype.Component;
import java.util.List;
/** Class 2: generate the tool catalog and print it, with what advertising it costs. */
@Component
public class CatalogCommand implements AgentCommand {
@Override
public String name() {
return "catalog";
}
@Override
public int order() {
return 20;
}
@Override
public String summary() {
return "generate and print the tool catalog, optionally for one role";
}
@Override
public String prompt() {
return "role (blank for every allow-listed operation)";
}
@Override
public void run(List<String> args, CommandContext context) throws Exception {
String role = args.isEmpty() || args.get(0).isBlank() ? null : args.get(0);
List<OperationTool> tools = context.tools(role);
System.out.println("schema : " + context.schemaPath());
System.out.println("role : " + (role == null ? "(all)" : role));
System.out.println("tools : " + tools.size());
System.out.println("total cost : " + tools.stream()
.mapToInt(OperationTool::tokenCount).sum()
+ " tokens to advertise this catalog (O200K_BASE)");
System.out.println();
System.out.println(context.generator().toJson(tools));
}
}
The complete CatalogCommand.java, collapsed because it runs to 64 lines
package com.graphqlguy.moviedb.agents.command;
import com.graphqlguy.moviedb.agents.toolgen.OperationTool;
import org.springframework.stereotype.Component;
import java.util.List;
/** Class 2: generate the tool catalog and print it, with what advertising it costs. */
@Component
public class CatalogCommand implements AgentCommand {
@Override
public String name() {
return "catalog";
}
@Override
public int order() {
return 20;
}
@Override
public String summary() {
return "generate and print the tool catalog, optionally for one role";
}
@Override
public String prompt() {
return "role (blank for every allow-listed operation)";
}
@Override
public void run(List<String> args, CommandContext context) throws Exception {
String role = args.isEmpty() || args.get(0).isBlank() ? null : args.get(0);
List<OperationTool> tools = context.tools(role);
System.out.println("schema : " + context.schemaPath());
System.out.println("role : " + (role == null ? "(all)" : role));
System.out.println("tools : " + tools.size());
System.out.println("total cost : " + tools.stream()
.mapToInt(OperationTool::tokenCount).sum()
+ " tokens to advertise this catalog (O200K_BASE)");
System.out.println();
for (OperationTool tool : tools) {
System.out.printf(" %-20s %4d tokens %s%n",
tool.name(), tool.tokenCount(),
tool.roles().isEmpty() ? "every role" : String.join(", ", tool.roles()));
}
// The definitions themselves are what a model reads, and they are long, so
// they stay one keystroke away instead of filling the screen.
while (true) {
System.out.println();
System.out.print("tool name for its full definition, `all` for the whole catalog,"
+ " or blank to go back: ");
String choice = context.console().hasNextLine()
? context.console().nextLine().strip() : "";
if (choice.isBlank()) {
return;
}
System.out.println();
if (choice.equalsIgnoreCase("all")) {
System.out.println(context.generator().toJson(tools));
continue;
}
List<OperationTool> one = tools.stream()
.filter(tool -> tool.name().equals(choice))
.toList();
System.out.println(one.isEmpty()
? "No tool called '" + choice + "' in this catalog."
: context.generator().toJson(one));
}
}
}
call is the second command. It tests the wiring before any model is involved:
package com.graphqlguy.moviedb.agents.command;
import com.graphqlguy.moviedb.agents.catalog.GraphQlToolCallback;
import com.graphqlguy.moviedb.agents.toolgen.OperationTool;
import org.springframework.stereotype.Component;
import java.util.List;
/** Class 2: execute one generated tool directly, proving the wiring with no model in the loop. */
@Component
public class CallCommand implements AgentCommand {
@Override
public String name() {
return "call";
}
@Override
public int order() {
return 21;
}
@Override
public String summary() {
return "execute one generated tool against the endpoint, with no model involved";
}
@Override
public String prompt() {
return "tool name and JSON arguments, e.g. movie {\"id\":\"1\"}";
}
@Override
public void run(List<String> args, CommandContext context) throws Exception {
if (args.size() < 2) {
System.out.println("call needs a tool name and a JSON argument object");
return;
}
String toolName = args.get(0);
String jsonArgs = String.join(" ", args.subList(1, args.size()));
OperationTool tool = context.tools(null).stream()
.filter(candidate -> candidate.name().equals(toolName))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(
"No allow-listed tool named '" + toolName + "'"));
System.out.println("operation : " + tool.operationDocument());
System.out.println("arguments : " + jsonArgs);
System.out.println();
System.out.println(new GraphQlToolCallback(tool, context.endpoint()).call(jsonArgs));
}
}
The application stays this small for the rest of the course. It builds the context, sorts the commands by the lesson that introduced them, and hands over. Run it with a command name, and it behaves like any command-line tool. Run it without arguments, and it lists what this build can do, runs your choice, and returns to the list. One start of the application serves a whole session of experiments:
package com.graphqlguy.moviedb.agents;
import com.graphqlguy.moviedb.agents.command.AgentCommand;
import com.graphqlguy.moviedb.agents.command.CommandContext;
import com.graphqlguy.moviedb.agents.toolgen.AllowList;
import com.graphqlguy.moviedb.agents.toolgen.ToolCatalogGenerator;
import graphql.schema.GraphQLSchema;
import graphql.schema.idl.SchemaParser;
import graphql.schema.idl.UnExecutableSchemaGenerator;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.Environment;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Scanner;
/**
* The entry point, and deliberately thin: it builds what every command shares,
* then hands over to one {@link AgentCommand}. Each class of this course adds a
* command class, so the file you are reading stays the same size throughout.
* <p>
* Run it with a command name and its arguments, or with none at all to choose
* from a menu. Configuration lives in application.yaml under agents.*: the schema
* file, the allow-list file, and the GraphQL endpoint the callbacks post to.
*/
@SpringBootApplication
public class AgentsApplication {
public static void main(String[] args) {
SpringApplication.run(AgentsApplication.class, args);
}
@Bean
CommandLineRunner commands(Environment env, List<AgentCommand> commands) {
return args -> {
String schemaPath = env.getProperty("agents.schema", "src/main/resources/schema-snapshot.graphqls");
String allowListPath = env.getProperty("agents.allow-list", "src/main/resources/tool-allowlist.txt");
String endpoint = env.getProperty("agents.endpoint", "http://localhost:8080/graphql");
GraphQLSchema schema = UnExecutableSchemaGenerator.makeUnExecutableSchema(
new SchemaParser().parse(Files.readString(Path.of(schemaPath))));
CommandContext context = new CommandContext(env, schema,
AllowList.load(Path.of(allowListPath)), new ToolCatalogGenerator(),
schemaPath, endpoint, new Scanner(System.in));
List<AgentCommand> ordered = commands.stream()
.sorted(Comparator.comparingInt(AgentCommand::order))
.toList();
if (args.length == 0) {
runFromMenu(ordered, context);
return;
}
String requested = args[0];
AgentCommand command = ordered.stream()
.filter(candidate -> candidate.name().equals(requested))
.findFirst()
.orElse(null);
if (command == null) {
System.out.println("Unknown command: " + requested);
printMenu(ordered);
return;
}
command.run(List.of(Arrays.copyOfRange(args, 1, args.length)), context);
};
}
/**
* Started with no arguments: list what this build can do, run the choice, and
* come back. The application starts once and stays up, so running the same task
* through several frameworks is a matter of keystrokes.
*/
private static void runFromMenu(List<AgentCommand> commands, CommandContext context) {
while (true) {
printMenu(commands);
System.out.print("> ");
String choice = context.console().hasNextLine()
? context.console().nextLine().strip() : "q";
if (choice.isBlank() || choice.equalsIgnoreCase("q") || choice.equalsIgnoreCase("quit")) {
System.out.println("Bye.");
return;
}
AgentCommand command = byNumberOrName(commands, choice);
if (command == null) {
System.out.println("No command called '" + choice + "'.");
continue;
}
try {
runWithPrompt(command, context);
} catch (Exception failure) {
// A failed command returns to the menu. An agent run fails often
// enough that restarting the application each time would teach
// patience rather than agents.
System.out.println();
System.out.println("The command failed: " + failure);
}
}
}
/** Ask for whatever the chosen command needs, then run it. */
private static void runWithPrompt(AgentCommand command, CommandContext context) throws Exception {
List<String> arguments = new ArrayList<>();
if (!command.prompt().isBlank()) {
System.out.print(command.prompt() + ": ");
String line = context.console().hasNextLine()
? context.console().nextLine().strip() : "";
if (!line.isBlank()) {
arguments.addAll(Arrays.asList(line.split(" ")));
}
}
System.out.println();
command.run(arguments, context);
}
private static void printMenu(List<AgentCommand> commands) {
System.out.println();
System.out.println("Movie Database agents. Choose a command by number or name, or q to quit:");
for (int i = 0; i < commands.size(); i++) {
System.out.printf(" %2d %-14s %s%n",
i + 1, commands.get(i).name(), commands.get(i).summary());
}
}
private static AgentCommand byNumberOrName(List<AgentCommand> commands, String choice) {
for (AgentCommand command : commands) {
if (command.name().equals(choice)) {
return command;
}
}
try {
int index = Integer.parseInt(choice) - 1;
return index >= 0 && index < commands.size() ? commands.get(index) : null;
} catch (NumberFormatException notANumber) {
return null;
}
}
}
Two details in that loop matter:
- The
Scanneris created once and lives on the context. TwoScanners overSystem.ineach buffer what they read, and the second loses input that the first already read. Class 4's approval prompt reads from this same one. - A command that throws an exception is caught and reported, and the menu stays. Agent runs fail often, and restarting the application each time would take over the lesson.
The hard-coded 8080 in that fallback is the last resort if configuration is missing; application.yaml sets agents.endpoint to port 8081, which is where you started the Movie Database service. The version in our project also has usage text and argument checks. This listing leaves them out because they do not teach anything new. One detail does teach something: call generates the catalog with a null role before looking the tool up, so the command can reach any allow-listed tool. That is correct for a human debugging the wiring. Inside an agent it would be wrong, because there the role is what limits the reachable tools. Class 4 turns that distinction into something the code enforces, so it holds even when nobody remembers the convention.
Keeping the Console for the Menu
One file remains, and it exists so that everything above is readable. Spring Boot logs to the console by default. This application's console holds a menu, prompts and transcripts, so a stray framework warning lands in the middle of a question. Logging goes to a file instead:
<?xml version="1.0" encoding="UTF-8"?>
<!--
The console belongs to the menu, the prompts and the transcripts, so logging goes
to a file and nowhere else. When a run fails, target/agents.log has the stack
trace and every warning the frameworks emitted.
-->
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<property name="LOG_FILE" value="target/agents.log"/>
<include resource="org/springframework/boot/logging/logback/file-appender.xml"/>
<root level="WARN">
<appender-ref ref="FILE"/>
</root>
</configuration>
Both include lines pull in Spring Boot's own definitions, so the file keeps the framework's formatting and only redirects where the output goes. target/agents.log is the place to look when a run fails or a framework has something to say, and Class 7 reads it deliberately to watch an advisor work.
Running It
Now that the code is in place, start the application. Spring Boot's Maven plugin compiles what changed and runs it, and clean removes compiled classes left from another lesson's branch:
mvn clean spring-boot:run
Choose 1 for catalog, and answer concierge when it asks for a role.
The header reports which schema was read, which role was asked for, how many tools that role gets, and what advertising them costs:
schema : src/main/resources/schema-snapshot.graphqls
role : concierge
tools : 7
total cost : 804 tokens to advertise this catalog (O200K_BASE)
The catalog itself follows as JSON, one object per tool. This is the main result of the lesson, so read two of them closely. The simplest tool in the catalog is movie:
{
"name": "movie",
"description": "Returns a movie by its database identifier. Use this when you have an exact\nID, typically from a prior `search` or `movies` call. Returns null if no\nmovie exists with that ID, so check for null before assuming success.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": [ "id" ],
"additionalProperties": false
},
"operation": "query Tool_movie($id: ID!) { movie(id: $id) { id title releaseYear genre rating runtime plot posterUrl tmdbId reviewCount } }",
"roles": [],
"tokens": 75
}
Every field came from the schema. The description is the schema description of Query.movie, copied unchanged. The inputSchema is the field's one argument, id: ID!, mapped to a string that is required because the argument is non-null. The operation is the document the callback will execute, with every scalar field of Movie selected. And tokens is what this one definition costs each time it is advertised to a model.
The mutation shows the same process on a harder input, an input object with an enum inside it:
{
"name" : "addWatchListItem",
"description" : "Add a movie or TV show to one of your lists. Adding a title that is already there returns the existing entry, so retrying is safe.",
"inputSchema" : {
"type" : "object",
"properties" : {
"input" : {
"type" : "object",
"properties" : {
"watchListId" : { "type" : "string" },
"titleId" : { "type" : "string" },
"titleType" : {
"type" : "string",
"enum" : [ "MOVIE", "TV_SHOW" ],
"description" : "Whether a watch-list entry points at a movie or a TV show."
},
"userNotes" : { "type" : "string" }
},
"required" : [ "watchListId", "titleId", "titleType" ],
"additionalProperties" : false
}
},
"required" : [ "input" ],
"additionalProperties" : false
},
"operation" : "mutation Tool_addWatchListItem($input: AddWatchListItemInput!) { addWatchListItem(input: $input) { success } }",
"roles" : [ "concierge" ],
"tokens" : 136
}
Four things in that object matter, because later lessons rely on all of them:
- The nested
inputobject is why Class 4 needs an argument repair. A small model often sends the three inner fields at the top level. - The enum became a closed value list, so a model that invents
titleType: "FILM"is refused before the call leaves the JVM. - The three
requiredentries came from the!marks in the schema. "roles": [ "concierge" ]is the allow-list's role column, copied into the definition. It is why thesupportrole sees five of the seven tools.
The last field is the interesting one for what comes next. The generated operation selects success and stops. The response type's other two fields, item and error, are objects, and the conservative default does not follow objects. So the agent learns only whether the write worked. search is worse, and printing it makes that plain:
"operation" : "query Tool_search($query: String!) { search(query: $query) }"
search returns a union, and GraphQL requires a selection on a union field, so that document fails at the server every time. The generator produces it anyway, because choosing fields across Movie, TvShow and Person is a judgement it should decline to make. Class 4 is where both of these get their hand-written replacements, and these two incomplete documents are the reason for that section.
Running it for support returns five tools and 640 tokens. That is the allow-list's role column at work: the file restricts the two watchlist operations to the concierge role, so this view leaves them out.
Now execute one tool against the running service directly, before any model uses it. Choose 2 for call, and type the tool name and its JSON arguments at the prompt:
tool name and JSON arguments, e.g. movie {"id":"1"}: movie {"id":"1"}
operation : query Tool_movie($id: ID!) { movie(id: $id) { id title releaseYear genre rating runtime plot posterUrl tmdbId reviewCount } }
arguments : {"id":"1"}
{"data":{"movie":{"id":"1","title":"The Shawshank Redemption","releaseYear":1994,
"genre":"DRAMA","rating":9.3,"runtime":142, ... }}}
That is real data from the real service, returned through the same interface that a chat model drives in the framework lessons later in the course. Make a habit of testing the callback without a model. When an agent misbehaves later, you already know that the tool layer works.
What a Catalog Costs
That 804-token figure is small, and it is charged on every turn, because tool definitions are part of the prompt in every model call an agent makes. The same catalog cost 527 tokens before the five read operations were documented, so more than a third of the cost is descriptions. Those descriptions are what make the tools usable.
Seven tools use a very small part of a modern context window. Seventy would cost around eight thousand tokens on every call, and seven hundred would be impractical. Once a catalog no longer fits the context window comfortably, you have reached the problem that Class 3 solves. There the catalog is replaced by three meta-tools, and a search index chooses what the model sees.
Two Practical Problems
Two small things go wrong in real projects, and both are cheap to handle at generation time and expensive to debug at run time.
- Name collisions. Tool names share one flat namespace per agent, and GraphQL operations live under two roots. A schema with
Query.reviewandMutation.reviewgenerates two tools with the same name, and providers either reject the duplicate or drop one silently. The generator's namespace is flat because the model's is. Detect the collision while generating, fail the build, and let a person pick prefixed names. - Schema drift in CI. Regenerate the catalog in the build and fail if the result differs from the committed one. Without that check, the first thing to notice the mismatch is the agent, in production. With it, the build fails, and a person fixes the mismatch before anything ships.
It is the same discipline as the breaking-change taxonomy that our introduction sets out, where a schema change is classified before it ships instead of after a client breaks.
Summary
Tool definitions restate facts the schema already holds. Generate them at build time, into an artifact humans can review, with the schema as the single source of truth. Drift then shows up as a failing build. We built that generator in five pieces:
OperationToolnames the shape: what the model sees, and the operation that runs.JsonSchemaMappertranslates GraphQL's input types into JSON Schema.AllowListexpresses curation as text a reviewer can read in a diff.ToolCatalogGeneratorwalks the two roots, builds each operation, and prices the result.GraphQlToolCallbackhands the catalog to Spring AI through the two-method contract every chat model in the framework consumes.
The translation is mechanical: scalars to primitives, ID to string, enums to closed value lists, non-null unwrapped into required lists, input objects mapped recursively, and additionalProperties: false sealing each object against invented arguments. What cannot be generated is meaning, so the generator throws an exception when an allow-listed operation lacks a description.
Exposure is a curation decision with a default of deny, and roles narrow each agent's view: five tools for support, seven for concierge, measured. Each tool executes a persisted operation with typed variables and a deliberately shallow generated selection, tested here against the live service before any model used it. The whole catalog costs 804 tokens to advertise. That number stays manageable only while schemas stay small, and the next lesson starts from there.
Further Reading
- graphql-java documentation: the schema model this lesson walks, including the SDL parsing and type APIs the generator is built on.
- JSON Schema specification: the target language of the translation; the sections on
requiredandadditionalPropertiesare the two this lesson leans on. - Spring AI: Tool Calling: the
ToolCallbackcontract and everything the framework layers over it; read it after implementing the interface by hand, so the convenience APIs make sense. - Apollo MCP Server: define tools: a production system making the same choices this lesson makes (operations from files, manifests, curated selection sets); a useful comparison for your generator's design decisions.
- GraphQL specification: Introspection: the mechanism behind the live-introspection alternative to the committed snapshot, and the reason any GraphQL API can be walked this way at all.
What's Next
You now have a catalog that is generated, priced, allow-listed, and tested against the service. It assumes that the schema is small enough to catalog. Class 3 covers schemas where that assumption fails. It builds the search/introspect/execute layer over GitHub's 6,640-coordinate schema with two retrieval backends, and measures both against the AI Working Group's proposed benchmark data. Class 4 then takes up safety: complexity caps against the query that asks for everything, the allow-list extended into persisted queries, and the confirmation flow that governs even the one write this lesson permitted.