Skip to main content

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-service needs
  • 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
Companion code

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 methodSchema generated?
A record or a bean, such as Orderyes
void, a primitive or its wrapperno, these types do not have fields to describe
A simple value type: a String, an enum, a UUID, a dateno, for the same reason
CallToolResultno, the method builds the whole result itself
List<Order>, or any other collectionyes, 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:

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 declaresIn required?What the JSON carriesResult for ORD-10002
Shipment shipmentyes"shipment": nullisError true, /shipment: null found, object expected
@Nullable Shipment shipmentno"shipment": nullisError true, still a type error: the declared type is object and the value is null
@Nullable plus @JsonInclude(NON_NULL)nothe key is absentisError 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.

HintSaysDefault
readOnlyHintThe tool does not modify its environmentfalse
destructiveHintThe tool may remove or overwrite something, rather than only addingtrue
idempotentHintCalling twice with the same arguments has the same effect as calling oncefalse
openWorldHintThe set of things the tool can reach is open-ended, the way a web search is, rather than closed, the way our own orders aretrue

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:

Spring AI: McpTool.McpAnnotations
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:

ToolreadOnlyHintdestructiveHintidempotentHintopenWorldHintAdded in
get_ordertruefalsetruefalseClass 2
get_customer_orderstruefalsetruefalseClass 3
get_orders_by_statustruefalsetruefalseClass 3
update_order_statusfalsefalsetruefalseClass 3
recheck_shipmentsfalsefalsetruetrueClass 11, where it calls a carrier's service
cancel_orderfalsetruefalsefalseClass 12, where the change cannot be undone

The one value worth arguing about is destructiveHint on update_order_status.

A judgement call on destructiveHint

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:

RuleThe weaker descriptionWhat the model does with itThe 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 meansthe empty case is not mentionedreports 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 toolthe 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:

Illustrative: the same parameter typed as an enum
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:

Illustrative sketch, not part of the companion project
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 parametersA CallToolRequest parameter
What tools/list publishesa schema generated from the method signaturean empty object schema: {"type": "object", "properties": {}, "required": []}
Where the arguments are documentedin the schema, which every client can readin the description string only
Does the compiler check the callyesno, the arguments arrive as a Map
Do the schema and the code always matchyes, the schema comes from the signatureno, 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:

You run this
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:

You run this (with your own Mcp-Session-Id)
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 commandsIn PowerShellWhy
curlcurl.exeWindows PowerShell treats curl as an alias for Invoke-WebRequest, which does not take these flags
a trailing \a trailing backtickthe 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:

You run this in PowerShell
@'
{"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:

You run this (with your own Mcp-Session-Id)
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:

You run this (with your own Mcp-Session-Id)
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"}}}'
Anything on this machine can move an order

/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

Sources

  • Schema Reference: ToolAnnotations: the four hints, their meanings and their defaults, and the rule that destructiveHint and idempotentHint are meaningful only when readOnlyHint is false.
  • Tools: Output Schema: a tool may declare an output schema, and a server must return structured results that conform to it.
  • schema.ts for 2025-11-25 (MCP specification): outputSchema in this revision is restricted to type: "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: true on read-only tools, destructiveHint: false on 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 Accepted with an empty body, which is what notifications/initialized returns.
  • McpAsyncServer.java (MCP Java SDK): the server validates the structured result against the output schema and returns Tool (get_order) output validation failed: ... with isError true.
  • McpTool.java (Spring AI): generateOutputSchema defaults to false, and annotations() itself defaults to @McpAnnotations, so all four hints are always published.
  • MCP Annotations: Dynamic Schema Support: a tool method can take a CallToolRequest and 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.