Class 3: Tools in Depth
Duration: ~30 minutes | Level: Intermediate | Prerequisites: Class 2: From a REST Application to an MCP Server. Still no API key and no model.
What We'll Cover
- The rest of the tools
order-serviceneeds generateOutputSchema, and how it changes the shape of a result- What each behavioural hint tells a client, and when to set it
- Writing a description that helps a model pick the right tool
- Writing an error message 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 in 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
Class 2 exposed a single lookup. 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. That repetition is doing real work: the model has no way to discover the set otherwise, and a status it 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 has no 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 a 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 setting it does not always have an effect. Spring AI skips schema generation when the return type is void, a primitive or its wrapper, a simple value type such as String, or CallToolResult. There is nothing useful to describe in those cases, so a tool returning a String is unaffected by the flag.
When a schema is produced, the tool's return mode changes from TEXT to STRUCTURED. That changes both what the tool advertises in tools/list and what comes back from a call, so it is worth knowing which clients already call the tool before switching it on.
The Behavioural 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 |
|---|---|
readOnlyHint | The call changes nothing |
destructiveHint | The call may remove or overwrite something |
idempotentHint | Calling twice with the same arguments has the same effect as calling once |
openWorldHint | The call reaches something outside this server, such as another service |
They are hints rather than guarantees, and nothing enforces them. Their value is that a host can use them without knowing anything about our domain. Claude Desktop, for instance, treats a tool differently when readOnlyHint is false.
Our four tools split cleanly. The three lookups are read-only and idempotent. update_order_status is neither read-only nor destructive: it changes a row, but the previous status is not lost in a way that matters, and setting the same status twice leaves the same result. In Class 12 we add cancel_order, which is where destructiveHint is true, and where a hint alone is not enough.
openWorldHint is false on all of them, because everything they touch is our own database. In Class 11 we add recheck_shipments, which reaches a carrier's service, and that is the one tool where we set it to true.
Writing a Description
A tool description is the only documentation a model has. There is no signature to inspect, no source to open, and no way to try it and see. Everything the model knows about when to call get_orders_by_status is in that string.
What makes a description work:
Say what it returns, not only what it does. "List orders in one status" leaves the model guessing whether it gets IDs or whole orders. "List orders in one status, newest first" answers it.
Give the format of anything the model has to construct. ORD-10001 and CUST-42 appear in the descriptions because a model that has never seen an order ID will otherwise invent one.
Enumerate closed sets. The five statuses are listed because there is no other way for the model to learn them.
Say what an empty result means. "Returns an empty list if the customer has no orders" stops the model reporting an error when it gets [].
Leave out how it works. That the service uses a JPA repository is true and useless. It spends tokens on every request and changes nothing about when to call the tool.
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 what OrderService.updateStatus raises for a bad status, which the starter already gets right:
throw new IllegalArgumentException(
"Invalid status '%s'. Must be one of: %s".formatted(status, validStatuses));
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 rule is the same as the description: say what went wrong and what would work instead. A stack trace is useful to us when we are debugging the server, but the model cannot do anything with it.
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
}
The cost is that Spring AI can no longer derive an input schema, so the tool has to describe its own. Use it only when a fixed signature genuinely cannot express the tool. For everything in this course, the typed parameters are better: they are checked by the compiler, and the schema is generated from them, so the two always match.
Check the Four Tools
Open a session as in Class 2, then:
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"}}}'
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 them with a generated output schema, all carrying hints that describe what kind of operation they are. The descriptions and error messages are written for the reader that actually gets them.
Class 4 adds the second MCP primitive: resources, which a model does not choose to call.
Next: Class 4: Resources. @McpResource, URI templates, and why a resource is not a tool.