Skip to main content

Class 3: Implementing Tools

Duration: ~105 minutes | Level: Intermediate | Prerequisites: Class 2: Your First MCP Server


What Tools Are For

Tools are how the AI takes action. Everything from "search the web" to "send a Slack message" is a Tool, and how you design them decides how well the AI can use your server.

The echo tool from Class 2 showed that the wiring works. Now we build three that do real work, each a different shape of problem:

ToolWhat it doesI/OSpecification typereadOnlyHint
calculateevaluates an arithmetic expressionnoneSyncToolSpecificationtrue
search_customersreads from the customer backendasynchronousAsyncToolSpecificationtrue
add_contactwrites to the same backendasynchronousAsyncToolSpecificationfalse

The second one has to deal with work that finishes later. The third changes data, which is the difference between a Tool and a Resource. We will get each one running before starting the next.

Companion code

This class builds directly on Class 2. 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/mcp-java-sdk-course.git

Tool Design Principles

Three principles separate good tools from bad ones:

1. One tool, one concern. A tool named manage_customers that can create, read, update and delete is hard for the AI to reason about. Separate search_customers and add_contact tools are better: each has a single job, so its schema and its description can describe that job exactly.

2. Descriptions are prompts. The tool description is what the AI reads. Write it as you would write a prompt: specific about when to use it, specific about what it does and doesn't do. Include examples if the usage isn't obvious.

3. Return structured data. Return JSON objects the AI can reason about, and leave the human-readable summary to the model. The AI turns the data into a sentence for the user, and it does that better from JSON than from a sentence you wrote.


Where This Code Goes

Everything stays in the project you already have: no new project, no Maven modules, no change to pom.xml.

We do not touch HelloMcpServer. Class 2's server keeps its echo tool, and by the end of this class Claude Desktop will be talking to it and to the new one. In each class we add a server instead of overwriting the last one, so every server you build stays available to go back to.

Create the package com.themcpguy.tools. By the end of the class it holds eight files, and everything we build in this class lives in there:

src/main/java/com/themcpguy/
├── HelloMcpServer.java <- Class 2's echo server, left alone
└── tools/ <- new package, all of Class 3
├── Results.java <- shared error helper
├── ExpressionEvaluator.java <- plain Java, no MCP
├── CalculateTool.java
├── CustomerRepository.java <- stands in for a database
├── SearchCustomersTool.java
├── AddContactTool.java
├── AsyncSpecs.java <- added when the second tool arrives
└── ToolsMcpServer.java <- this class's entry point

The shared error helper

Every tool reports failure the same way, so write it once. Create src/main/java/com/themcpguy/tools/Results.java:

package com.themcpguy.tools;

import io.modelcontextprotocol.spec.McpSchema.CallToolResult;

/** Small helper so every tool reports failures the same way. */
public final class Results {

private Results() {
}

public static CallToolResult error(String message) {
return CallToolResult.builder()
.isError(true)
.addTextContent(message)
.build();
}
}

Tool 1: Calculator

A pure-computation tool: no side effects, always safe to call, fast enough that nobody has to think about threads.

It needs something to evaluate the expression. That part is plain Java, separate from MCP, so here it is in full, to paste and come back to later.

ExpressionEvaluator.java: a recursive-descent parser, which reads the text left to right and calls itself for each bracketed piece (click to expand)
package com.themcpguy.tools;

import java.util.Map;
import java.util.function.DoubleBinaryOperator;
import java.util.function.DoubleUnaryOperator;

/**
* Tiny arithmetic expression evaluator used by the 'calculate' tool. Supports the
* subset the tool's description advertises: +, -, *, /, ^, parentheses, and the
* functions sqrt, abs, round, floor, ceil.
* <p>
* Kept small on purpose. The string it evaluates arrives from a model, and may
* originally have come from a user. This parser understands arithmetic and
* nothing else, so arithmetic is all it can be made to do.
* <p>
* Strict by design: anything it can't parse raises {@link IllegalArgumentException}
* with a message suitable for surfacing back to the model in a tool-error result.
*/
final class ExpressionEvaluator {

private static final Map<String, DoubleUnaryOperator> UNARY = Map.of(
"sqrt", Math::sqrt,
"abs", Math::abs,
"floor", Math::floor,
"ceil", Math::ceil
);

private static final Map<String, DoubleBinaryOperator> BINARY = Map.of(
"round", (v, places) -> {
double scale = Math.pow(10, places);
return Math.round(v * scale) / scale;
}
);

private ExpressionEvaluator() {}

static double evaluate(String expression) {
if (expression == null || expression.isBlank()) {
throw new IllegalArgumentException("expression is empty");
}
return new Parser(expression).parse();
}

/** Recursive-descent parser. */
private static final class Parser {
private final String src;
private int pos;

Parser(String src) {
this.src = src;
this.pos = 0;
}

double parse() {
double v = parseExpr();
skipWhitespace();
if (pos < src.length()) {
throw new IllegalArgumentException(
"unexpected character '" + src.charAt(pos) + "' at position " + pos);
}
return v;
}

// expr = term (('+' | '-') term)*
private double parseExpr() {
double v = parseTerm();
while (true) {
skipWhitespace();
if (consume('+')) v += parseTerm();
else if (consume('-')) v -= parseTerm();
else return v;
}
}

// term = power (('*' | '/') power)*
private double parseTerm() {
double v = parsePower();
while (true) {
skipWhitespace();
if (consume('*')) v *= parsePower();
else if (consume('/')) {
double divisor = parsePower();
if (divisor == 0) throw new IllegalArgumentException("division by zero");
v /= divisor;
} else return v;
}
}

// power = unary ('^' power)? (right-associative)
private double parsePower() {
double v = parseUnary();
skipWhitespace();
if (consume('^')) v = Math.pow(v, parsePower());
return v;
}

// unary = ('-' | '+')? primary
private double parseUnary() {
skipWhitespace();
if (consume('-')) return -parseUnary();
if (consume('+')) return parseUnary();
return parsePrimary();
}

// primary = number | '(' expr ')' | funcCall
private double parsePrimary() {
skipWhitespace();
if (pos >= src.length()) throw new IllegalArgumentException("unexpected end of expression");
char c = src.charAt(pos);

if (consume('(')) {
double v = parseExpr();
skipWhitespace();
if (!consume(')')) throw new IllegalArgumentException("expected ')' at position " + pos);
return v;
}

if (Character.isLetter(c)) {
return parseFunctionCall();
}

return parseNumber();
}

private double parseFunctionCall() {
int start = pos;
while (pos < src.length() && Character.isLetter(src.charAt(pos))) pos++;
String name = src.substring(start, pos).toLowerCase();
skipWhitespace();
if (!consume('(')) throw new IllegalArgumentException("expected '(' after '" + name + "'");
double arg1 = parseExpr();
skipWhitespace();
if (consume(',')) {
double arg2 = parseExpr();
skipWhitespace();
if (!consume(')')) throw new IllegalArgumentException("expected ')' after second arg to '" + name + "'");
DoubleBinaryOperator op = BINARY.get(name);
if (op == null) throw new IllegalArgumentException("unknown 2-arg function '" + name + "'");
return op.applyAsDouble(arg1, arg2);
}
if (!consume(')')) throw new IllegalArgumentException("expected ')' after arg to '" + name + "'");
DoubleUnaryOperator op = UNARY.get(name);
if (op == null) throw new IllegalArgumentException("unknown function '" + name + "'");
return op.applyAsDouble(arg1);
}

private double parseNumber() {
skipWhitespace();
int start = pos;
boolean sawDigit = false;
boolean sawDot = false;
while (pos < src.length()) {
char c = src.charAt(pos);
if (Character.isDigit(c)) { sawDigit = true; pos++; }
else if (c == '.' && !sawDot) { sawDot = true; pos++; }
else break;
}
if (!sawDigit) throw new IllegalArgumentException("expected number at position " + start);
return Double.parseDouble(src.substring(start, pos));
}

private boolean consume(char expected) {
skipWhitespace();
if (pos < src.length() && src.charAt(pos) == expected) {
pos++;
return true;
}
return false;
}

private void skipWhitespace() {
while (pos < src.length() && Character.isWhitespace(src.charAt(pos))) pos++;
}
}

}

It all sits behind evaluate(String), the only method you would replace if you ever swap it out.

Why not just use a library?

Reaching for an expression library is the first thing most of us would do. Each of the usual candidates has a problem for this job.

  • exp4j is Apache 2.0 and does exactly this job, but its last release was January 2017.
  • mXparser is actively maintained and far more capable, but it ships under a dual licence: free for non-commercial use, paid for commercial use. That is workable on a personal project and a problem at work.
  • Janino and the standalone Nashorn engine both work by evaluating source code, Java in one case and JavaScript in the other. Arithmetic does not need that much power.

That last point is the main reason we wrote our own. The string being evaluated comes from a model, which may be passing along something a user typed. Our parser only understands arithmetic. An engine that can define variables, call functions or compile source can do a great deal more, and we cannot promise that a tool the AI chooses to call will only ever receive maths. Class 8, where we harden this server, goes further into this.

For parsing dates you would use java.time straight away: it is maintained, permissively licensed, and does not execute the text you give it. The question is what a library can do with untrusted input.

Now the tool. Create src/main/java/com/themcpguy/tools/CalculateTool.java:

package com.themcpguy.tools;

import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpSchema.ToolAnnotations;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.Map;

public final class CalculateTool {

private static final Logger log = LoggerFactory.getLogger(CalculateTool.class);

private static final String SCHEMA = """
{
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The mathematical expression to evaluate"
}
},
"required": ["expression"]
}
""";

private final McpJsonMapper jsonMapper;

public CalculateTool(McpJsonMapper jsonMapper) {
this.jsonMapper = jsonMapper;
}

public SyncToolSpecification spec() {
Tool definition = Tool.builder("calculate", jsonMapper, SCHEMA)
.description("""
Evaluate a mathematical expression and return the result.
Supports +, -, *, /, ^ (power), parentheses, and the functions
sqrt, abs, floor, ceil, and round(value, places).
Examples: "2 + 2", "sqrt(144)", "2^10", "round(3.14159, 2)".
Use for arithmetic and maths. Do not use for currency conversion,
because there are no live exchange rates here.
""")
.annotations(ToolAnnotations.builder()
.readOnlyHint(true)
.openWorldHint(false)
.build())
.build();

return SyncToolSpecification.builder()
.tool(definition)
.callHandler((exchange, request) -> evaluate(request.arguments()))
.build();
}

CallToolResult evaluate(Map<String, Object> arguments) {
Object raw = arguments.get("expression");
if (!(raw instanceof String expression) || expression.isBlank()) {
return Results.error("'expression' is required and must be a non-blank string");
}
try {
double value = ExpressionEvaluator.evaluate(expression);
log.debug("calculate '{}' -> {}", expression, value);
return CallToolResult.builder()
.addTextContent(jsonMapper.writeValueAsString(
Map.of("expression", expression, "result", value)))
.build();
} catch (IllegalArgumentException e) {
return Results.error("Invalid expression: " + e.getMessage());
} catch (IOException e) {
return Results.error("Could not serialise the result: " + e.getMessage());
}
}
}

Four things worth noticing.

The schema is a text block, and the description is chained on afterwards. Tool.builder(...) asks for the name and the schema up front because a tool is not valid MCP without them. Everything you chain on after that is optional:

Builder callRequired?What it sets
Tool.builder(name, jsonMapper, schema)requiredthe name, and the inputSchema parsed from the JSON text block
.description(String)optionalthe text the model reads when it chooses a tool
.annotations(ToolAnnotations)optionalthe behaviour hints the host reads
.title(String)optionala display name for a user interface
.outputSchema(...)optionalthe shape of structuredContent, described at the end of this class
.icons(List<Icon>)optionalicons a client can show next to the tool
.meta(Map)optionalfree-form metadata, sent on the wire as _meta

readOnlyHint(true) is a promise to the client. It says calling this does not change anything, so a host is free to run it without asking permission, run it twice, or run it speculatively. openWorldHint(false) says the set of things the tool can reach is fixed and known, the way a customer database is. A web search is the opposite: it can reach anything on the internet.

We return JSON, not "The result is 12". sqrt(144) comes back as:

{"expression":"sqrt(144)","result":12.0}

Map.of does not fix the order of its keys, so the two can arrive the other way round. The model formats either form for the user far better than we can, and it can feed the number straight into a follow-up step.

One McpJsonMapper does both jobs. It builds the schema and serialises the result through writeValueAsString, which is why it is the only mapper field in the class. It throws IOException rather than Jackson's JsonProcessingException, because McpJsonMapper is the SDK's own interface and deliberately does not leak the Jackson version underneath.


Try the Calculator

Don't write the others until this one works.

Create src/main/java/com/themcpguy/tools/ToolsMcpServer.java. It is Class 2's server with echo swapped for calculate, and it lives in the tools package alongside the tool classes, so it does not need imports for them:

package com.themcpguy.tools;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ToolsMcpServer {

private static final Logger log = LoggerFactory.getLogger(ToolsMcpServer.class);

public static void main(String[] args) throws InterruptedException {

McpJsonMapper jsonMapper = new JacksonMcpJsonMapper(new ObjectMapper());
var transportProvider = new StdioServerTransportProvider(jsonMapper);

var calculate = new CalculateTool(jsonMapper);

McpServer.sync(transportProvider)
.serverInfo("acme-tools", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(calculate.spec())
.build();

log.info("acme-tools started (stdio); awaiting messages on stdin");

Thread.currentThread().join();
}
}

In Class 2 you built the echo tool's definition inside main, wrote its handler as a lambda underneath, and registered the result there. Here the definition and the handler for calculate are in CalculateTool, the file you wrote a moment ago, so main only creates that class and asks it for its specification:

// near the top of main(), where Class 2 built the whole tool inline:
var calculate = new CalculateTool(jsonMapper);

// further down in the builder chain, where Class 2 passed an inline-assembled spec:
.tools(calculate.spec())

The full set of differences:

Class 2Class 3
FileHelloMcpServer.javaToolsMcpServer.java, a new file
Toolechocalculate
Where the tool is builtinline, inside mainin CalculateTool
Name reported by serverInfomy-first-serveracme-tools

Everything else is the same: the same transport, the same capabilities, and Thread.currentThread().join() still holding the process open. Moving the tool into its own class is what keeps main readable once there are three of them.

Tell Claude Desktop about it

Your JAR now contains two servers, and Claude needs to be told about the new one. Open claude_desktop_config.json again and add a second entry, leaving the first exactly as it is:

{
"mcpServers": {
"my-first-server": {
"command": "java",
"args": ["-jar", "/absolute/path/to/mcp-java-sdk-course/target/mcp-java-sdk-course-1.0.0-SNAPSHOT.jar"]
},
"acme-tools": {
"command": "java",
"args": [
"-cp",
"/absolute/path/to/mcp-java-sdk-course/target/mcp-java-sdk-course-1.0.0-SNAPSHOT.jar",
"com.themcpguy.tools.ToolsMcpServer"
]
}
}
}

Same JAR, same absolute path, different launch flag. That flag is what selects which server runs:

LaunchWhich class runsWhere that class comes from
-jar app.jarcom.themcpguy.HelloMcpServerthe Main-Class in the JAR manifest, which is the <mainClass> you set in pom.xml back in Class 1. Any -cp passed alongside -jar is ignored
-cp app.jar com.themcpguy.tools.ToolsMcpServercom.themcpguy.tools.ToolsMcpServerthe class you name on the command line, found on the classpath

So a single fat JAR can hold as many servers as you like, and Claude launches each as a separate process.

Build and restart

mvn package

Then fully quit and reopen Claude Desktop. Cmd + Q on macOS, or right-click the tray icon and choose Quit on Windows. Closing the window is not enough.

Claude starts your server as a child process and keeps it alive, so every rebuild from here on needs a restart before your new code runs. If you change something and nothing seems to happen, this is almost always why.

Doesn't listChanged handle this?

Not for rebuilds. Your server does advertise tools: { listChanged: true } during the handshake, and the SDK backs that with real methods: addTool(...), removeTool(name) and notifyToolsListChanged() exist on both the sync and async server. They are for a server that is already running and changes its own tool list mid-session, say because a feature flag flipped or a user signed in.

Two things can change your tool list, and only one of them reaches a running process:

The right-hand branch is the one that catches people. The JVM loaded your classes when the process started, so replacing the JAR on disk does not reach it, and it carries on serving the old code. Claude Desktop spawned that process, so restarting it means restarting Claude.

Today every code change costs a full restart of Claude Desktop. The way around it is a transport where Claude does not own your process: with HTTP your server runs on its own and Claude connects to it over the network, so you restart only the server. That is Class 9.

Ask Claude something

You do not decide when a tool gets called. Claude reads the descriptions of the tools available to it. Message by message, it judges whether calling one is better than answering directly. Ask it What is 2 + 2 * 3? and it will almost certainly reply "8" without touching your server, because doing that in its head is faster and more reliable than a round trip.

To test your wiring, ask for the tool by name. That is a check that the parts are connected, and not an example of good tool design:

Use the calculate tool to work out 2 + 2 * 3

Claude calls the tool and comes back with 8. If that works, everything is connected: the process launched, the handshake succeeded, the schema validated your argument, and your handler ran.

To see Claude choose the tool on its own, give it a sum that is hard to do mentally.

If I invest 18500 at 7.5% compound interest for 7 years, how much interest do I earn? Work it out exactly.

Here the tool is genuinely the better option, because 1.075^7 to full precision is not something to estimate. Claude should call calculate on its own with something close to (18500 * 1.075^7) - 18500 and report 12192.409091061392.

Then one that should fail:

Use the calculate tool on 1 divided by 0

The tool returns an error instead of a number, Claude reports that the calculation failed, and it does not invent an answer. That is the isError(true) path from Results.error(...). A tool that says why it failed is more useful to a model than one that returns something plausible and wrong.

Your tool is competing for the job

Getting called is not guaranteed, and your tool has two kinds of rival.

The model itself. calculate is the clearest case: Claude can do arithmetic unaided, so for anything easy it will just answer.

The tools the client already has. Claude Desktop can search the web and run code on its own, and it can reach your files once you install a filesystem connector. If one of your tools overlaps with one of those, Claude may pick the built-in one, and your server does not hear about the question.

2 + 2 * 3 stops at the first branch. "Is Stark Holdings still an active customer?" travels all the way to the last one, because nothing built in holds your database.

While you are testing, name the tool in your prompt whenever it has a rival. That removes the ambiguity, and the question at this stage is whether your code works.

The pom.xml already has everything you need

Everything in this class, including the Reactor types below, is already on your classpath. mcp-core depends on reactor-core, so Maven downloads it for you; confirm with mvn dependency:tree | grep reactor.


Tool 2: Searching Customers

Real backends do I/O. A database query, an HTTP call or a queue round trip each park a thread that sits idle while it waits. That is affordable with a handful of calls, and it stops being affordable once many calls arrive at once.

The SDK's answer is a parallel API. AsyncToolSpecification replaces SyncToolSpecification, and the two differ in what your handler hands back and who waits for it:

SyncToolSpecificationAsyncToolSpecification
Your handler returnsCallToolResultMono<CallToolResult>
While the backend worksthe calling thread waitsthe thread is released, and the result arrives later
The SDKconverts your handler and waits for itsubscribes to your Mono
You must notcall .block() inside the handler

A Reactor primer, if you have not met it. Reactor is the library the SDK uses for work that finishes later: you describe the steps once, and it runs them when a result arrives, which is what "reactive" means here. Its Mono<T> is "a future with operators":

  • A Mono<T> is a single asynchronous result: one value, or empty, or an error. Think CompletableFuture<T>, but lazy and with a much larger toolbox.
  • Mono.fromFuture(supplier) wraps a CompletableFuture, which is how ordinary non-reactive code joins a reactive chain.
  • .map(fn) transforms the value. .timeout(d) fails the chain if it takes too long. .onErrorResume(fn) recovers from failure.
  • The SDK subscribes to whatever Mono you return and pulls the result out. Never call .block() inside a handler; that reintroduces exactly the parked thread you were avoiding.

First, something to search. Create src/main/java/com/themcpguy/tools/CustomerRepository.java:

package com.themcpguy.tools;

import java.util.List;
import java.util.Locale;
import java.util.NoSuchElementException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;

/**
* Both methods return CompletableFuture so that non-reactive backends (JDBC, JPA,
* a REST client) compose cleanly via Mono.fromFuture.
* <p>
* In a real application this would be a JPA repository or an HTTP client. Here it is
* an interface with a small in-memory implementation, so the seam you would replace
* is obvious and a test can substitute a repository that fails on purpose.
*/
public interface CustomerRepository {

record Customer(String id, String name, String email, String accountStatus) {
}

/** A person at a customer. Customers are companies; contacts are the people there. */
record Contact(String id, String customerId, String name, String email) {
}

/** Matches a customer by its own details, or by any of its contacts. */
CompletableFuture<List<Customer>> searchAsync(String query, int limit);

/** Fails with NoSuchElementException if customerId does not exist. */
CompletableFuture<Contact> addContactAsync(String customerId, String name, String email);

static CustomerRepository inMemory() {
return new InMemory();
}

/**
* Seeded with three customers and no contacts. The lists are concurrent because
* the async server dispatches tool calls on several threads, so a search can
* genuinely overlap an add.
*/
final class InMemory implements CustomerRepository {

private final List<Customer> customers = new CopyOnWriteArrayList<>(List.of(
new Customer("CUST-1", "Acme Corp", "[email protected]", "ACTIVE"),
new Customer("CUST-2", "Globex Industries", "[email protected]", "ACTIVE"),
new Customer("CUST-3", "Stark Holdings Ltd", "[email protected]", "SUSPENDED")));

private final List<Contact> contacts = new CopyOnWriteArrayList<>();
private final AtomicInteger nextContactId = new AtomicInteger(1);

@Override
public CompletableFuture<List<Customer>> searchAsync(String query, int limit) {
String needle = query.toLowerCase(Locale.ROOT);
return CompletableFuture.supplyAsync(() -> customers.stream()
.filter(c -> matches(c, needle, query))
.limit(limit)
.toList());
}

private boolean matches(Customer customer, String needle, String rawQuery) {
if (customer.name().toLowerCase(Locale.ROOT).contains(needle)
|| customer.email().toLowerCase(Locale.ROOT).contains(needle)
|| customer.id().equalsIgnoreCase(rawQuery)) {
return true;
}
return contacts.stream()
.filter(contact -> contact.customerId().equals(customer.id()))
.anyMatch(contact -> contact.name().toLowerCase(Locale.ROOT).contains(needle)
|| contact.email().toLowerCase(Locale.ROOT).contains(needle));
}

@Override
public CompletableFuture<Contact> addContactAsync(String customerId, String name, String email) {
return CompletableFuture.supplyAsync(() -> {
boolean exists = customers.stream().anyMatch(c -> c.id().equalsIgnoreCase(customerId));
if (!exists) {
throw new NoSuchElementException("no customer with id '" + customerId + "'");
}
Contact created = new Contact(
"CONTACT-" + nextContactId.getAndIncrement(), customerId, name, email);
contacts.add(created);
return created;
});
}
}
}

Note CopyOnWriteArrayList: the async server dispatches calls on several threads, so a search can overlap the add we write later.

Now the tool. Create src/main/java/com/themcpguy/tools/SearchCustomersTool.java:

package com.themcpguy.tools;

import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpSchema.ToolAnnotations;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;

import java.io.IOException;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.TimeoutException;

public final class SearchCustomersTool {

private static final Logger log = LoggerFactory.getLogger(SearchCustomersTool.class);

private static final Duration BACKEND_TIMEOUT = Duration.ofSeconds(10);
private static final int MAX_LIMIT = 50;
private static final int DEFAULT_LIMIT = 10;

private static final String SCHEMA = """
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term (matches name, email, or customer ID)"
},
"limit": {
"type": "integer",
"description": "Maximum number of results",
"minimum": 1,
"maximum": 50,
"default": 10
}
},
"required": ["query"]
}
""";

private final McpJsonMapper jsonMapper;
private final CustomerRepository repository;

public SearchCustomersTool(McpJsonMapper jsonMapper, CustomerRepository repository) {
this.jsonMapper = jsonMapper;
this.repository = repository;
}

public AsyncToolSpecification spec() {
Tool definition = Tool.builder("search_customers", jsonMapper, SCHEMA)
.description("""
Search the customer database by name, email, or customer ID.
Returns a JSON array of matching customers with id, name, email
and accountStatus. Use for customer lookup, not for bulk export:
the limit caps at %d.
""".formatted(MAX_LIMIT))
.annotations(ToolAnnotations.builder()
.readOnlyHint(true)
.openWorldHint(false)
.build())
.build();

return AsyncToolSpecification.builder()
.tool(definition)
.callHandler((exchange, request) -> search(request.arguments()))
.build();
}

Mono<CallToolResult> search(Map<String, Object> arguments) {
Object raw = arguments.get("query");
if (!(raw instanceof String query) || query.isBlank()) {
return Mono.just(Results.error("'query' is required and must be a non-blank string"));
}

int limit = arguments.get("limit") instanceof Number n
? Math.clamp(n.intValue(), 1, MAX_LIMIT)
: DEFAULT_LIMIT;

return Mono.fromFuture(() -> repository.searchAsync(query, limit))
.timeout(BACKEND_TIMEOUT)
.map(customers -> {
try {
return CallToolResult.builder()
.addTextContent(jsonMapper.writeValueAsString(customers))
.build();
} catch (IOException e) {
return Results.error("Could not serialise the results: " + e.getMessage());
}
})
.onErrorResume(TimeoutException.class, e -> Mono.just(
Results.error("Backend timed out after " + BACKEND_TIMEOUT.toSeconds() + "s")))
.onErrorResume(e -> {
log.error("Unexpected error in search_customers", e);
return Mono.just(Results.error("Internal error: " + e.getMessage()));
});
}
}

Four details that are easy to get wrong.

Mono.fromFuture(() -> ...) takes a supplier, not a future. Mono.fromFuture(repository.searchAsync(q, l)) calls the backend immediately, when the Mono is assembled. A retry would then reuse the same stale result, and a chain that nobody subscribes to would still have hit the database. The supplier form defers the call until subscription, which is what "lazy" means in Reactor.

The chain that search(...) builds has four ways to end. Follow which branch produces which CallToolResult:

Only one of those four endings carries customers. The other three set isError(true), so the model always gets a reply it can read.

limit arrives as a Number, not an int. JSON has one numeric type, so Jackson may hand you an Integer, a Long, or a Double. instanceof Number n accepts all three and n.intValue() narrows once. Casting straight to Integer works right until a client sends 10.0. Math.clamp then pins the result into range; it arrived in Java 21, which is why the pom.xml from Class 1 sets maven.compiler.release to 21.

The default: 10 in the schema does not fill itself in. It is this part of the schema:

"limit": {
"type": "integer",
"description": "Maximum number of results",
"minimum": 1,
"maximum": 50,
"default": 10
}

JSON Schema default is documentation for the client, not a rule the server applies. If limit is absent the instanceof falls through to DEFAULT_LIMIT. The minimum and maximum are enforced by the server's validation, but we clamp anyway, because they stop being enforced the moment anyone sets .validateToolInputs(false).

Both onErrorResume calls turn a failure into a tool error. A tool error is a normal reply carrying isError(true), which the model reads and can act on. A protocol error fails the whole JSON-RPC request instead, so the model gets an error where it expected a result. A timeout gets its own message so the model knows it can retry. Anything else is logged in full on the server and reported to the model in one line.


Going Async

One constraint shapes the rest of this class: McpServer.sync(...) accepts only SyncToolSpecification, and McpServer.async(...) accepts only AsyncToolSpecification. You cannot pass a mix to either. The SDK has an internal AsyncToolSpecification.fromSync(...), but it is package-private, so you cannot call it.

There is only one server, and it is async

There are not really two servers to choose between. McpSyncServer is a wrapper:

public class McpSyncServer {
private final McpAsyncServer asyncServer;

Every MCP server is reactive underneath. The sync flavour takes your blocking handlers, converts them with that same package-private fromSync, and waits for the results on your behalf. The async flavour hands you the engine directly and expects Mono handlers.

The choice is about who does the converting, the SDK or you, and the SDK's version only offers you all-or-nothing.

That leaves two options. Make everything sync and block on the future inside the handler, which gives up the benefit you went async for. Or make everything async and lift the synchronous tools into a Mono, which is the route we take, in a small helper class.

Create src/main/java/com/themcpguy/tools/AsyncSpecs.java:

package com.themcpguy.tools;

import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification;
import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification;
import io.modelcontextprotocol.server.McpSyncServerExchange;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

/**
* Turns a SyncToolSpecification into an AsyncToolSpecification, so that one
* McpServer.async(...) can host both kinds of tool.
* <p>
* The SDK has its own AsyncToolSpecification.fromSync, but it is package-private,
* so we build the same thing here. The sync body runs on the bounded-elastic
* scheduler, which is the pool Reactor reserves for blocking work.
*/
public final class AsyncSpecs {

private AsyncSpecs() {
}

public static AsyncToolSpecification asAsync(SyncToolSpecification sync) {
var handler = sync.callHandler();
return AsyncToolSpecification.builder()
.tool(sync.tool())
.callHandler((exchange, request) -> Mono
.fromCallable(() -> handler.apply(new McpSyncServerExchange(exchange), request))
.subscribeOn(Schedulers.boundedElastic()))
.build();
}
}

Two things matter here.

Mono.fromCallable(...) defers the blocking body until subscription, and .subscribeOn(Schedulers.boundedElastic()) runs it on the pool Reactor keeps for exactly this purpose. Leave that off and the blocking call runs on a thread the reactive machinery needs, which is how a reactive server deadlocks under load.

A sync handler is handed an McpSyncServerExchange, but an async server only has an McpAsyncServerExchange, and new McpSyncServerExchange(exchange) bridges the two, which is what makes asAsync work for any tool. Our handlers here ignore the exchange, so passing null would work today. It would fail as soon as a tool needs to send a notification or read the client's roots.

Now update ToolsMcpServer.java:

package com.themcpguy.tools;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ToolsMcpServer {

private static final Logger log = LoggerFactory.getLogger(ToolsMcpServer.class);

public static void main(String[] args) throws InterruptedException {

McpJsonMapper jsonMapper = new JacksonMcpJsonMapper(new ObjectMapper());
var transportProvider = new StdioServerTransportProvider(jsonMapper);
var customers = CustomerRepository.inMemory();

var calculate = new CalculateTool(jsonMapper);
var searchCustomers = new SearchCustomersTool(jsonMapper, customers);

McpServer.async(transportProvider)
.serverInfo("acme-tools", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(
AsyncSpecs.asAsync(calculate.spec()),
searchCustomers.spec())
.build();

log.info("acme-tools started (stdio); awaiting messages on stdin");

Thread.currentThread().join();
}
}

McpServer.sync became McpServer.async, calculate got wrapped in AsyncSpecs.asAsync(...), and the new tool went in as-is because its spec() already returns an AsyncToolSpecification.

Notice what did not change: CalculateTool is untouched. It does not know the server switched execution models, because nothing in it names a server type. Splitting each tool into a spec() and a package-private handler is what allows that.

Build and restart Claude Desktop. This time, do not name the tool. Just ask:

I just got an email from [email protected]. Which customer is that?

Claude should work out on its own that it needs search_customers, call it, and come back with:

[{"id":"CUST-2","name":"Globex Industries","email":"[email protected]","accountStatus":"ACTIVE"}]

That is what model-controlled means: the model decides when a tool runs. You did not tell Claude which tool to use, or even that a tool existed. It read the description you wrote, matched it against what you asked, and decided.

Every field of every match is now in the conversation, customer email addresses included, because everything a tool returns is copied into the model's context. Returning more than the question needs is the risk OWASP calls sensitive information disclosure, so return the fields the model needs to answer and leave the rest.

Try this one too:

Is Stark Holdings still an active customer?

Nothing in the question mentions searching or a tool, but answering it needs your database. Claude calls search_customers, gets back "accountStatus":"SUSPENDED", and tells you no.

This tool gets called without being named, where calculate had to be asked for, because nothing else in Claude Desktop can answer it. (If it does not fire for you, fall back to Use the search_customers tool to find... and check your wiring first.)


Tool 3: Adding a Contact

Everything so far only reads. calculate computes, search_customers looks things up, and running either of them twice does not change anything. Both are perfectly good Tools, but they leave out something only Tools can do: a Tool is allowed to change data. Resources, which are the subject of the next class, only ever read.

So the last tool writes. Create src/main/java/com/themcpguy/tools/AddContactTool.java:

package com.themcpguy.tools;

import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpSchema.ToolAnnotations;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;

import java.io.IOException;
import java.time.Duration;
import java.util.Map;
import java.util.NoSuchElementException;

public final class AddContactTool {

private static final Logger log = LoggerFactory.getLogger(AddContactTool.class);

private static final Duration BACKEND_TIMEOUT = Duration.ofSeconds(10);

private static final String SCHEMA = """
{
"type": "object",
"properties": {
"customerId": {
"type": "string",
"description": "Id of the customer this person works for, e.g. 'CUST-2'"
},
"name": {
"type": "string",
"description": "The person's name, e.g. 'Dana Wu'"
},
"email": {
"type": "string",
"format": "email",
"description": "The person's email address"
}
},
"required": ["customerId", "name", "email"]
}
""";

private final McpJsonMapper jsonMapper;
private final CustomerRepository repository;

public AddContactTool(McpJsonMapper jsonMapper, CustomerRepository repository) {
this.jsonMapper = jsonMapper;
this.repository = repository;
}

public AsyncToolSpecification spec() {
Tool definition = Tool.builder("add_contact", jsonMapper, SCHEMA)
.description("""
Add a person as a contact at an existing customer, and return the
created record. Customers are companies; contacts are the people
who work there.
Find the customer with search_customers first, because this needs
that customer's id. It does not create customers, and it does not
check for duplicates: calling it twice adds the same person twice.
""")
.annotations(ToolAnnotations.builder()
.readOnlyHint(false)
.destructiveHint(false)
.idempotentHint(false)
.openWorldHint(false)
.build())
.build();

return AsyncToolSpecification.builder()
.tool(definition)
.callHandler((exchange, request) -> add(request.arguments()))
.build();
}

Mono<CallToolResult> add(Map<String, Object> arguments) {
Object rawId = arguments.get("customerId");
if (!(rawId instanceof String customerId) || customerId.isBlank()) {
return Mono.just(Results.error("'customerId' is required and must be a non-blank string"));
}
Object rawName = arguments.get("name");
if (!(rawName instanceof String name) || name.isBlank()) {
return Mono.just(Results.error("'name' is required and must be a non-blank string"));
}
Object rawEmail = arguments.get("email");
if (!(rawEmail instanceof String email) || email.isBlank()) {
return Mono.just(Results.error("'email' is required and must be a non-blank string"));
}
if (!email.matches("[^@\\s]+@[^@\\s]+\\.[^@\\s]+")) {
return Mono.just(Results.error("'" + email + "' is not a valid email address"));
}

return Mono.fromFuture(() -> repository.addContactAsync(customerId.strip(), name.strip(), email.strip()))
.timeout(BACKEND_TIMEOUT)
.map(created -> {
log.info("add_contact created {} at {}", created.id(), created.customerId());
try {
return CallToolResult.builder()
.addTextContent(jsonMapper.writeValueAsString(created))
.build();
} catch (IOException e) {
return Results.error("Contact was created, but could not be serialised: "
+ e.getMessage());
}
})
.onErrorResume(NoSuchElementException.class, e -> Mono.just(Results.error(
"No customer with id '" + customerId + "'. Use search_customers to find the right one.")))
.onErrorResume(e -> {
log.error("Unexpected error in add_contact", e);
return Mono.just(Results.error("Could not add the contact: " + e.getMessage()));
});
}
}

The annotations are what this tool is here to show. Compare them with search_customers:

search_customersadd_contactWhat the host assumes if it is absent
readOnlyHinttruefalsefalse
destructiveHintnot setfalsetrue
idempotentHintnot setfalsefalse
openWorldHintfalsefalsetrue

The last column is why add_contact sets destructiveHint(false) explicitly: leave it out and the specification's default of true applies, so a host assumes the tool may destroy something.

These are hints for the client, not rules the SDK enforces. Nothing stops you writing to a database from a tool marked readOnlyHint(true); the annotation is a promise, and hosts act on it. A host may run a read-only tool without asking, and prompt the user before one that is not.

  • readOnlyHint(false) says this changes state. That alone is often enough for a host to ask permission first.
  • destructiveHint(false) says it only adds. A tool that deletes or overwrites would set this true, and a careful host can treat those differently again.
  • idempotentHint(false) is the one people forget, and it says something specific: calling this twice with the same arguments is not the same as calling it once. Our tool does not check for duplicates, so a second call really does create a second contact. A host that retries after a timeout needs to know that.

The description says the same thing in English: "it does not check for duplicates: calling it twice adds the same person twice."

The host reads the annotations and the model reads the description, so the two have to agree. The four hints reach the host as one object inside the tool's entry in tools/list:

"annotations": {
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": false
}

A hint is a claim your server makes about itself. The specification says a client MUST treat annotations as untrusted unless the server is trusted, and that there SHOULD always be a person able to deny a tool call.

Annotations say what a tool does, they do not say who may call it. This server does not authorize the caller and it does not limit how often a tool runs. Over stdio the only control is that Claude Desktop launched the process on your own machine. The specification asks a server to apply access controls and to rate limit tool invocations, and Class 8 is where we take that up.

Validation goes past the schema. The email property is declared like this:

"email": { "type": "string", "format": "email", "description": "The person's email address" }

The 2.0 server does not enforce that format, so the handler checks the shape itself. JSON Schema says format must be disabled as an assertion by default, so it describes what you expect without ever rejecting anything. Write your own check for any rule you actually rely on.

Register it

One more edit to ToolsMcpServer.java. add_contact shares the repository with search_customers, so they see the same data, and its spec() already returns an AsyncToolSpecification, so it does not need bridging:

package com.themcpguy.tools;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ToolsMcpServer {

private static final Logger log = LoggerFactory.getLogger(ToolsMcpServer.class);

public static void main(String[] args) throws Exception {

McpJsonMapper jsonMapper = new JacksonMcpJsonMapper(new ObjectMapper());
var transportProvider = new StdioServerTransportProvider(jsonMapper);
var customers = CustomerRepository.inMemory();

var calculate = new CalculateTool(jsonMapper);
var searchCustomers = new SearchCustomersTool(jsonMapper, customers);
var addContact = new AddContactTool(jsonMapper, customers);

McpServer.async(transportProvider)
.serverInfo("acme-tools", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(
AsyncSpecs.asAsync(calculate.spec()),
searchCustomers.spec(),
addContact.spec())
.build();

log.info("acme-tools started (stdio); awaiting messages on stdin");

Thread.currentThread().join();
}
}

Notice customers is created once and passed to both tools. If each tool built its own repository, the contact you add would not be visible to the next search, which is a confusing bug to track down.

Watch the two tools work together

Build, restart, and start with the question you asked earlier, but about someone who is not on file:

I just got an email from Dana Wu ([email protected]). Which customer is that?

Claude searches for the address, does not find it, and tries the domain instead. That finds Globex Industries, so it will tell you something like: not an exact match, but globex.example belongs to Globex Industries, and Dana is not on file there. That is two tool calls, and Claude chose both on its own.

Then:

Yes, add her as a contact there.

Claude calls add_contact, and you get the created record:

{"id":"CONTACT-1","customerId":"CUST-2","name":"Dana Wu","email":"[email protected]"}

Follow where CUST-2 comes from:

The last arrow to acme-tools is the step that matters. add_contact requires a customerId, and Claude filled it in without asking you. It already had CUST-2 from the second search, so the output of one tool became the input of the next.

That is chaining, and nothing in your code arranged it: the model worked it out from two descriptions and a schema.

Now ask the first question again:

Which customer is [email protected]?

This time it comes back with Globex Industries. The same question came back empty two turns ago and answers correctly now, because the tool call in between changed the data. A Resource could not have done that.

What happens when Claude pushes back

Be vaguer than the example above and you will see the descriptions doing real work. Ask it to add a contact without saying who, and it will not invent one: name is required, and guessing something like "Globex (new contact)" would file a record that later searches would miss. It asks instead.

Try giving it a customerId that does not exist and you get the error your handler writes:

No customer with id 'CUST-999'. Use search_customers to find the right one.

The message names the tool that fixes the problem, which is what lets the model recover on its own instead of reporting the failure back to you.

Ask Claude to add Dana a second time and you get CONTACT-2 alongside CONTACT-1, which is the behaviour idempotentHint(false) declares in a tool that does not check for duplicates.


What Else a Tool Can Return

Every tool you wrote in this class returns a CallToolResult holding a single piece of text, which covers most tools you will ever write. Two of the type's four fields do things you may want later:

FieldWhat it holds
contenta list of items: text, image, audio, a link to a resource, or an embedded resource. in Class 4 we build the resources those last two point at
isErrorwhether the call failed. This is what Results.error(...) sets
structuredContentmachine-readable JSON, kept separate from content
metafree-form metadata, sent on the wire as _meta

Always build it with CallToolResult.builder() rather than the record constructor, so you do not have to pass nulls for unused fields:

// Text. What every tool in this class does.
CallToolResult.builder()
.addTextContent("result text")
.build();

// An error. What Results.error(...) does.
CallToolResult.builder()
.isError(true)
.addTextContent("Error details")
.build();

// Several items at once: a description the model can read,
// plus the image it describes.
CallToolResult.builder()
.addTextContent("Sales for Q3, trending up 12%")
.addContent(McpSchema.ImageContent.builder("base64-encoded-png", "image/png").build())
.build();

addTextContent(String) is shorthand for addContent(McpSchema.TextContent.builder(text).build()). ImageContent and AudioContent each have a builder(data, mimeType) that takes the two required fields up front, exactly like Tool.builder(...) does, and leaves annotations and meta as optional chained calls.

content is a list for a reason. A chart tool can return the picture and a sentence describing it, so a model that cannot see images still has something to work with.

structuredContent is the most useful of the four. Look again at CalculateTool: it builds a Map, serialises it to a JSON string with writeValueAsString, and hands that string over as text. The model then has to read JSON out of a text block. structuredContent exists for exactly this, carrying the JSON as data, described by an outputSchema on the tool the same way inputSchema describes the arguments:

{
"type": "object",
"properties": {
"expression": { "type": "string" },
"result": { "type": "number" }
},
"required": ["expression", "result"]
}

Send both fields, though. The specification says a tool returning structured content SHOULD also return the same JSON serialised in a text block, so that a client written before the field existed still sees the answer:

{
"content": [
{ "type": "text", "text": "{\"expression\":\"sqrt(144)\",\"result\":12.0}" }
],
"structuredContent": { "expression": "sqrt(144)", "result": 12.0 }
}

Returning text alone stays valid and is still the common case, so we leave calculate as it is. If you find yourself serialising objects into strings on every call, structuredContent is the field you are looking for.


Tool Description Anti-Patterns

The AI's ability to use your tools depends almost entirely on good descriptions. Avoid these common mistakes:

Too vague:

"search", searches things

The AI cannot tell when to call this, or what it searches.

Missing scope:

"get_user", returns user information

Which user? By ID? By email? What information exactly?

Missing negative guidance:

"execute_query", runs a database query

Can it run destructive queries? The AI doesn't know, and may try.

Good description structure:

  1. What the tool does (one sentence)
  2. What parameters it expects (brief)
  3. What it returns
  4. When to use it (and optionally, when NOT to)

Look back at the three descriptions you wrote. Each one ends by saying what the tool will not do: "do not use for currency conversion", "not for bulk export", "calling it twice adds the same person twice". Those closing sentences are what stop the model using a tool for the wrong job.


What We Built

src/main/java/com/themcpguy/
├── HelloMcpServer.java Class 2's echo server, still running
└── tools/
├── Results.java
├── ExpressionEvaluator.java
├── CalculateTool.java read-only, pure computation
├── CustomerRepository.java
├── SearchCustomersTool.java read-only, async I/O
├── AddContactTool.java writes, async I/O
├── AsyncSpecs.java
└── ToolsMcpServer.java async server, three tools

One JAR now holds two servers, both connected to Claude Desktop at the same time, chosen by -jar and -cp. Ask Claude to echo something and it still works, because nothing in this class touched HelloMcpServer.

Every tool exposes a spec() and a package-private handler that takes a plain Map, which is what makes them testable without starting anything. Class 7 picks up exactly there: evaluate(...) returns a CallToolResult you can assert on directly, and search(...) and add(...) return a Mono that Reactor's StepVerifier can step through.


What's Next

You can now build tools, including one that changes something. In the next class we implement Resources. The specification calls Tools, Resources and Prompts MCP's three primitives, meaning the kinds of thing a server can offer, and a Resource is data the application chooses to expose rather than data the model goes and fetches.

→ Class 4: Implementing Resources


Further Reading

Sources

  • MCP specification 2025-11-25: Tools: the content types a CallToolResult may carry, including resource links. It also carries the rule that a client MUST treat annotations as untrusted unless the server is trusted, and that there SHOULD be a person able to deny a call. Its security considerations say a server MUST apply access controls and rate limit tool invocations, and its structured-content section is where the serialised JSON in a text block is asked for.
  • MCP schema 2025-11-25 (schema.ts): the definitions and the defaults of readOnlyHint, destructiveHint, idempotentHint and openWorldHint, which is the last column of the annotations table.
  • MCP Server (Java SDK): that a running server registers a tool with addTool(...) on both the sync and the async server, and that input validation is on until validateToolInputs(false) switches it off.
  • io.modelcontextprotocol.sdk:mcp-core on Maven Central: the 2.0.0 API this class uses, including the Tool.builder(...) surface, removeTool(name) and notifyToolsListChanged() on both McpSyncServer and McpAsyncServer, the package-private AsyncToolSpecification.fromSync, McpSyncServer holding an McpAsyncServer, and reactor-core arriving as a compile-scope dependency.
  • java Command Reference (JDK 21): "When you use -jar, the specified JAR file is the source of all user classes, and other class path settings are ignored."
  • Math (Java SE 21 javadoc): Math.clamp is marked "Since: 21", which is why maven.compiler.release is 21.
  • Map (Java SE 21 javadoc): "The iteration order of mappings is unspecified and is subject to change", which is why the key order of the calculate reply is not fixed.
  • JSON Schema Validation, draft 2020-12: that format assertion "MUST be disabled by default", and that default is a metadata annotation.
  • Reactor Core: Schedulers: that boundedElastic gives a blocking process its own thread so it does not tie up other resources, which is why AsyncSpecs calls subscribeOn.
  • Mono (reactor-core javadoc): that fromFuture(Supplier) wraps a lazily-supplied CompletableFuture on subscription, and that timeout(Duration) propagates a TimeoutException.
  • Connect to local MCP servers: the shape and location of claude_desktop_config.json, the absolute-path requirement, and that file access in Claude Desktop comes from an MCP server you install.
  • mXparser licence: the dual licence, free for non-commercial use and purchased for commercial use.
  • exp4j: the library described as Apache 2.0 with its last release, 0.4.8, in January 2017.
  • JEP 372: Remove the Nashorn JavaScript Engine: Nashorn was removed from the JDK in Java 15, which is why the lesson calls it the standalone Nashorn engine.
  • LLM02:2025 Sensitive Information Disclosure: why the customer fields a tool returns are worth thinking about before you return them.