Class 3: Tools in Depth
Duration: ~45 minutes | Level: Intermediate | Prerequisites: Class 2: From a REST Application to an MCP Server. Still works without an API key or a model.
What We'll Cover
- The rest of the tools
order-serviceneeds generateOutputSchema, how it changes the shape of a result, and why it rejects valid data until you tell it which fields are optional- What each behavioral hint tells a client, and when to set it
- Writing a description that helps a model pick the right tool
- Writing an error message that a model can recover from
CallToolRequest, for a tool whose arguments are not known at compile time
This class carries on from Class 2. If you followed along, keep working on the project you
already have. If you skipped it, clone the class_2
branch to start from the same place:
git clone --branch class_2 https://github.com/the-mcp-guy/spring-ai-mcp-course.git
Three More Tools
In Class 2 we exposed a single lookup, get_order. Answering real support questions needs a few more. Add them to OrderTools:
@McpTool(
name = "get_customer_orders",
description = """
List every order belonging to one customer, newest first.
Customer IDs look like CUST-42.
Returns an empty list if the customer has no orders.
""",
annotations = @McpTool.McpAnnotations(
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false))
public List<Order> getCustomerOrders(
@McpToolParam(description = "The customer ID, for example CUST-42") String customerId) {
return orderService.findByCustomerId(customerId);
}
@McpTool(
name = "get_orders_by_status",
description = """
List orders in one status, newest first.
Valid statuses are PENDING, PROCESSING, SHIPPED, DELIVERED and CANCELLED.
Returns an empty list if no order is in that status.
""",
annotations = @McpTool.McpAnnotations(
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false))
public List<Order> getOrdersByStatus(
@McpToolParam(description = "One of PENDING, PROCESSING, SHIPPED, DELIVERED, CANCELLED")
String status) {
return orderService.findByStatus(status);
}
@McpTool(
name = "update_order_status",
description = """
Move an order to a new status.
Valid statuses are PENDING, PROCESSING, SHIPPED, DELIVERED and CANCELLED.
Returns the updated order.
""",
annotations = @McpTool.McpAnnotations(
readOnlyHint = false,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false))
public Order updateOrderStatus(
@McpToolParam(description = "The order ID, for example ORD-10001") String orderId,
@McpToolParam(description = "The new status") String newStatus) {
return orderService.updateStatus(orderId, newStatus);
}
get_orders_by_status and update_order_status both list the valid statuses in the description. These tools take the status as a String, so the published schema does not carry the set, and a status the model invents comes back as an error it then has to recover from.
Restart and the log reports what the scanner found:
McpServerAutoConfiguration : Registered tools: 4
Structured Output
tools/call in Class 2 returned the order as JSON inside a text block:
"content": [ { "type": "text", "text": "{\"orderId\":\"ORD-10001\", ... }" } ]
That works, and a model reads it. But the client does not receive a schema for it, so nothing on the other side knows the shape before the call happens. MCP allows a tool to declare an output schema as well as an input one, and Spring AI generates it from the return type when asked.
Ask for one on get_order. Open the tool you wrote in Class 2 and add generateOutputSchema = true to its @McpTool, leaving the description and the annotations as they are:
@McpTool(
name = "get_order",
generateOutputSchema = true,
description = """
...
""",
annotations = ...)
public Order getOrder(...) { ... }
Restart. tools/list now carries an outputSchema derived from the Order record, and that schema is then used at both ends of the call:
The step that matters is the check order-service runs on itself. A result that does not fit the published schema is replaced by an error before it goes out. When the check passes, the call returns a structuredContent object alongside the text block:
{
"result": {
"content": [ { "type": "text", "text": "{\"orderId\":\"ORD-10001\", ... }" } ],
"structuredContent": { "orderId": "ORD-10001", "status": "SHIPPED", "customer": { ... } },
"isError": false
}
}
Both are present, so a client that only understands text keeps working while one that reads the schema gets a typed object.
The attribute defaults to false, and it has an effect only for some return types:
| Return type of the tool method | Schema generated? |
|---|---|
A record or a bean, such as Order | yes |
void, a primitive or its wrapper | no, these types do not have fields to describe |
A simple value type: a String, an enum, a UUID, a date | no, for the same reason |
CallToolResult | no, the method builds the whole result itself |
List<Order>, or any other collection | yes, but 2025-11-25 requires an outputSchema to describe an object at its root, so an array schema is unusable |
The last row is why generateOutputSchema goes on get_order and not on the two list tools. The 2026-07-28 revision widens structuredContent to any JSON value, arrays included.
Turning the flag on switches the tool's return mode from TEXT to STRUCTURED. That changes both what the tool advertises in tools/list and what a call returns, so check which clients already call the tool before switching it on.
A generated schema thinks every field is required
The schema comes from the Java types, and Java records say which fields exist without saying which may be absent. So the generator marks all of them required, and for Order that includes shipment, which is null on every order that has not shipped yet. Ask for such an order, and the server checks the result against the schema it published, then returns an error instead of the order:
Tool (get_order) output validation failed: Validation failed:
JSON schema validation errors: [/shipment: null found, object expected]
63 of the 200 seeded orders are in that state, so this is worth fixing now. Two changes to the Order record work together, in order-service/src/main/java/com/themcpguy/supportdesk/orders/domain/Order.java:
import com.fasterxml.jackson.annotation.JsonInclude;
import org.jspecify.annotations.Nullable;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Order(
String orderId,
OrderStatus status,
Customer customer,
List<OrderItem> items,
BigDecimal totalAmount,
Instant createdAt,
Instant lastUpdated,
@Nullable Shipment shipment) {
}
@Nullable comes from JSpecify, the standard set of nullness annotations for Java, and Spring AI reads it when it builds the schema, so shipment drops out of the required list. @JsonInclude(NON_NULL) leaves the field out of the JSON instead of sending "shipment": null. Both are needed, and the middle row of this table is why:
| What the record declares | In required? | What the JSON carries | Result for ORD-10002 |
|---|---|---|---|
Shipment shipment | yes | "shipment": null | isError true, /shipment: null found, object expected |
@Nullable Shipment shipment | no | "shipment": null | isError true, still a type error: the declared type is object and the value is null |
@Nullable plus @JsonInclude(NON_NULL) | no | the key is absent | isError false, the order comes back |
Marking a field optional says the key may be absent, and it does not widen the declared type to accept null. Restart and ask for ORD-10002, a PENDING order, and the result comes back with isError false and the shipment key absent, which reads as "this order has not shipped".
This applies to any generated output schema. The generator can only describe what the type system tells it, and Java's does not record optionality on its own. So every optional field in a generated schema is wrong until you say otherwise.
The Behavioral Hints
The annotations block is four booleans and a title. They do not change what the tool does. They tell a client what kind of operation it is, so a host can decide what to allow and what to confirm.
| Hint | Says | Default |
|---|---|---|
readOnlyHint | The tool does not modify its environment | false |
destructiveHint | The tool may remove or overwrite something, rather than only adding | true |
idempotentHint | Calling twice with the same arguments has the same effect as calling once | false |
openWorldHint | The set of things the tool can reach is open-ended, the way a web search is, rather than closed, the way our own orders are | true |
The MCP maintainers describe how a client reads them in order, and the first answer decides whether the rest matter:
destructiveHint and idempotentHint mean something only when readOnlyHint is false. A read-only
tool does not write, so a client ignores both.
A hint that is not sent is read as its default. Every default is the pessimistic reading, so a tool that does not publish any hints is assumed to write, to overwrite, to be unsafe to repeat, and to reach anywhere.
@McpTool.McpAnnotations declares the same four defaults:
boolean readOnlyHint() default false;
boolean destructiveHint() default true;
boolean idempotentHint() default false;
boolean openWorldHint() default true;
An IDE therefore marks any hint you set to its own default as a redundant assignment, and
readOnlyHint = false on update_order_status is one. It is redundant in the source only. Spring AI
reads all four values whether you wrote them or not, so tools/list publishes the same four either
way. This course sets all four on every tool, so a declaration tells you what the tool claims without
having to remember which values are defaults.
They are only hints, and the protocol does not enforce them. A host can act on them without knowing
anything about our domain, and Claude Desktop, for instance, treats a tool differently when
readOnlyHint is false. The specification limits that trust at MUST level:
For trust & safety and security, clients MUST consider tool annotations to be untrusted unless they come from trusted servers.
A server can claim readOnlyHint = true and delete a row anyway. order-service is ours, so we know what its hints mean. In Class 9 we connect the npm filesystem server, which the MCP project wrote and we only run, and there the hints are claims we have to judge.
Every tool the course ends up with, and the four values each publishes:
| Tool | readOnlyHint | destructiveHint | idempotentHint | openWorldHint | Added in |
|---|---|---|---|---|---|
get_order | true | false | true | false | Class 2 |
get_customer_orders | true | false | true | false | Class 3 |
get_orders_by_status | true | false | true | false | Class 3 |
update_order_status | false | false | true | false | Class 3 |
recheck_shipments | false | false | true | true | Class 11, where it calls a carrier's service |
cancel_order | false | true | false | false | Class 12, where the change cannot be undone |
The one value worth arguing about is destructiveHint on update_order_status.
The specification reserves destructiveHint = false for a tool that "performs only additive updates", and update_order_status overwrites the status column, so a strict reading would set it to true. We set it to false because the previous status stays in the order history and moving an order forward is a routine support step. The MCP maintainers' post gives the same rule without settling a case like this one: "set readOnlyHint: true on read-only tools, destructiveHint: false on additive operations". Decide it per tool and write down which way you went, because a host that auto-approves non-destructive writes acts on your answer.
Writing a Description
A tool description is the only documentation a model has. The model cannot inspect the signature or read the source, and it cannot call the tool to find out. Five rules follow from that, with the weak version of each next to the version in order-service:
| Rule | The weaker description | What the model does with it | The version in order-service |
|---|---|---|---|
| Say what it returns, not only what it does | "List orders in one status" | guesses whether it gets IDs or whole orders | "List orders in one status, newest first" |
| Give the format of anything the model has to construct | "The order ID" | invents an ID such as 10001 or order-1 | "The order ID, for example ORD-10001" |
| Enumerate closed sets | "The new status" | tries IN_TRANSIT and gets an error | "Valid statuses are PENDING, PROCESSING, SHIPPED, DELIVERED and CANCELLED" |
| Say what an empty result means | the empty case is not mentioned | reports an error when it gets [] | "Returns an empty list if the customer has no orders" |
| Leave out how it works | "Uses a JPA repository to query orders" | does not learn when to call the tool | the sentence is not there |
Every tool description is sent with every request, so words that do not help the model choose are paid for again on each one, in tokens, the small pieces of text a model is charged by.
The third rule follows from the signature: status is a String, so the schema does not carry the five values and the description must. Typing the parameter as the OrderStatus enum would publish them in the input schema instead:
public List<Order> getOrdersByStatus(
@McpToolParam(description = "The status to list") OrderStatus status) { ... }
The SDK then rejects a bad value before the method runs. This course keeps the String, so the error message in the next section is what teaches the model. You are in the same position whenever the valid set cannot be written as a Java enum.
When a model does not call a tool it should have called, the description is the first place to look. Class 14 comes back to this because it also determines what is worth testing.
Writing an Error
Class 2 showed that a failed tool call comes back with isError: true and HTTP 200. The model reads the message and decides what to do next, so the message is written for that reader.
throw new IllegalArgumentException(
"No order with ID '%s'. Check the ID and try again.".formatted(orderId));
Compare that with the message for a bad status, which the course project already gets right. OrderService.updateStatus hands the new status to OrderStatus.parse, and that is where the IllegalArgumentException is thrown:
throw new IllegalArgumentException(
"Invalid status '%s'. Must be one of: %s"
.formatted(value, java.util.Arrays.toString(values())));
That message lists the valid values, so a model that guessed IN_TRANSIT can correct itself on the next call. A message reading Invalid status would leave it guessing again. The correction costs one extra exchange:
The last arrow is what the message makes possible: the model builds the second call from the list it was given.
The rule is the same as the description: say what went wrong and what would work instead. A stack trace helps us when we are debugging the server. The model cannot build a second call from it, because it does not name a value that would work.
When the Arguments Are Not Known in Advance
Every tool so far has a fixed signature, and Spring AI generates the schema from it. Sometimes the arguments depend on something known only at runtime, such as a set of filters loaded from configuration.
For that case, a tool method can take a CallToolRequest and read the arguments itself:
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
@McpTool(name = "search_orders", description = "...")
public List<Order> searchOrders(CallToolRequest request) {
Map<String, Object> args = request.arguments();
// read whatever the caller sent, then build and return the result
}
What that costs, set against the typed parameters used everywhere else in the course:
| Typed parameters | A CallToolRequest parameter | |
|---|---|---|
What tools/list publishes | a schema generated from the method signature | an empty object schema: {"type": "object", "properties": {}, "required": []} |
| Where the arguments are documented | in the schema, which every client can read | in the description string only |
| Does the compiler check the call | yes | no, the arguments arrive as a Map |
| Do the schema and the code always match | yes, the schema comes from the signature | no, you update the description by hand |
Use CallToolRequest only when a fixed signature cannot express the tool.
Check the Four Tools
Open a session, as in Class 2.
Opening a session: the two calls from Class 2 (click to expand)
initialize first. The Accept header names both content types, because the server chooses which one it replies with:
curl -i -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
The reply carries an Mcp-Session-Id header. Copy it, then acknowledge the handshake, which the protocol requires before anything else:
curl -i -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: PASTE-YOURS-HERE' \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
That returns 202 with an empty body. Restarting the server ends the session, so both calls must be made again after each restart.
On Windows: the same calls in PowerShell (click to expand)
Every command in this section needs the same three changes:
| In the Unix commands | In PowerShell | Why |
|---|---|---|
curl | curl.exe | Windows PowerShell treats curl as an alias for Invoke-WebRequest, which does not take these flags |
a trailing \ | a trailing backtick | the line continuation character is different |
-d '{"jsonrpc":"2.0", ... }' | save the body to body.json, then -d "@body.json" | PowerShell 7.3 and later pass the single-quoted body to the program intact. Windows PowerShell 5.1, the version built into Windows, rebuilds the command line and the inner double quotes do not survive, so the server receives invalid JSON. The file form works in both |
The initialize call, the first one in this section, becomes:
@'
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}
'@ | Set-Content -Path body.json
curl.exe -i -X POST http://localhost:8080/mcp `
-H "Content-Type: application/json" `
-H "Accept: application/json, text/event-stream" `
-d "@body.json"
Apply the same three changes to the remaining commands in this section: the acknowledgment, tools/list, and the two tools/call requests. Each keeps every header the Unix version sends, including Mcp-Session-Id once you have yours.
Then list the tools:
curl -N -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: PASTE-YOURS-HERE' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
Four tools come back. Try the one that changes something:
curl -N -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: PASTE-YOURS-HERE' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"update_order_status","arguments":{"orderId":"ORD-10002","newStatus":"PROCESSING"}}}'
/mcp is open on localhost, so any process that can reach port 8080 can call update_order_status. The specification requires a server to control who may call its tools. Class 17 covers what publishing /mcp outside the machine needs: OAuth 2.1 authorization on top of MCP.
Then send a status that does not exist, and read what comes back:
{
"result": {
"content": [ { "type": "text",
"text": "Error invoking method: updateOrderStatus\nInvalid status 'IN_TRANSIT'. Must be one of: [PENDING, PROCESSING, SHIPPED, DELIVERED, CANCELLED]" } ],
"isError": true
}
}
That is the message a model receives, prefix and all, and the part after the prefix contains enough for it to try again correctly.
What We Built
order-service exposes four tools with generated input schemas, one of which has a generated output schema, all carrying hints describing the type of operation they represent. The descriptions and error messages are written for the model that reads them.
In Class 4 we add the second MCP primitive: resources, which a model does not choose to call.
Further Reading
- Tools (MCP specification 2025-11-25): the normative rules behind this class, covering the tool definition fields, output schemas, structured content and the two kinds of error.
- Tool Annotations as Risk Vocabulary: the MCP maintainers on what each of the four hints buys a client, why the defaults are pessimistic, and how unevenly clients act on them today.
- MCP Annotations: Server (Spring AI reference): every attribute of
@McpTool,@McpToolParamand@McpTool.McpAnnotationswith its default, plus the dynamic schema and exception handling rules. - MCP Server (MCP Java SDK): how the SDK underneath Spring AI validates tool arguments against the input schema before the handler runs, and how to turn that off.
- Tool Calling (Spring AI reference): the schema generation Spring AI shares between plain tool calling and MCP, including which annotations mark a property optional.
- JSpecify User Guide: what
@Nullablemeans as a type-use annotation, and why marking one record component changes how tools read the whole type. - Jackson Annotations (jackson-annotations wiki): the annotation reference, where
@JsonIncludeis described as keeping nulls and empty values out of the serialised JSON, per property or for a whole class. - object (Understanding JSON Schema): what the
requiredkeyword asserts, which is why an absent field passes and a null one fails.
Sources
- Schema Reference: ToolAnnotations: the four hints, their meanings and their defaults, and the rule that
destructiveHintandidempotentHintare meaningful only whenreadOnlyHintisfalse. - Tools: Output Schema: a tool may declare an output schema, and a server must return structured results that conform to it.
schema.tsfor 2025-11-25 (MCP specification):outputSchemain this revision is restricted totype: "object"at the root level, which is why an array schema is unusable.- Tools: Structured Content (MCP specification 2026-07-28): the next revision allows "any JSON value (object, array, string, number, boolean, or null)" in
structuredContent. - Tool Annotations as Risk Vocabulary: the MCP maintainers' advice to "set
readOnlyHint: trueon read-only tools,destructiveHint: falseon additive operations", and what a client does with each hint. - Tools: Error Handling: a tool execution error comes back in the result with
isError: true, and clients should pass it to the model so it can correct itself. - Tools: Security: a client must treat annotations from an untrusted server as untrusted, and a server must control who may call its tools.
- Transports: a POST carrying only notifications gets HTTP
202 Acceptedwith an empty body, which is whatnotifications/initializedreturns. - McpAsyncServer.java (MCP Java SDK): the server validates the structured result against the output schema and returns
Tool (get_order) output validation failed: ...withisErrortrue. - McpTool.java (Spring AI):
generateOutputSchemadefaults tofalse, andannotations()itself defaults to@McpAnnotations, so all four hints are always published. - MCP Annotations: Dynamic Schema Support: a tool method can take a
CallToolRequestand read the arguments itself, and the tool then advertises an empty object schema. - about_Parsing (PowerShell): PowerShell 7.3 changed native command argument parsing and preserves embedded quotes, and Windows PowerShell 5.1 does not.
Next: Class 4: Resources. @McpResource, URI templates, and why a resource is not a tool.