Skip to main content

Module 3: Implementing Tools

Duration: ~45 minutes | Level: Intermediate | Prerequisites: Module 2: Your First MCP Server


Tools Are the AI's Hands

Tools are how the AI takes action in the world. Everything from "search the web" to "create a database record" to "send a Slack message" is a Tool. Getting tool design right is the most important skill in MCP server development.

In this module, we build three progressively realistic tools:

  1. calculate, pure computation, no external state
  2. read_file, filesystem access with path validation
  3. search_customers, async I/O against a database-style backend

Tool Design Principles

Before code, three principles that separate good tools from bad ones:

1. One tool, one concern. A tool named manage_database that can create, read, update, and delete is hard for the AI to reason about. Three or four specific tools are better.

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, not prose. Return JSON objects the AI can reason about, not human-readable summaries. The AI will summarise for the user; you shouldn't.


Tool 1: Calculator

A pure-computation tool, no side effects, always safe to call.

ObjectMapper mapper = new ObjectMapper();

var calculateTool = McpServerFeatures.SyncToolSpecification.builder()
.tool(McpSchema.Tool.builder()
.name("calculate")
.description("""
Evaluate a mathematical expression and return the result.
Supports: +, -, *, /, ^, sqrt(), abs(), round(), floor(), ceil().
Examples: "2 + 2", "sqrt(144)", "2^10", "round(3.14159, 2)".
Use for arithmetic and math operations, not for currency conversion.
""")
.inputSchema(new McpSchema.JsonSchema(
"object",
Map.of("expression", Map.of(
"type", "string",
"description", "The mathematical expression to evaluate")),
List.of("expression"),
null, null, null))
.build())
.callHandler((exchange, request) -> {
String expression = (String) request.arguments().get("expression");
try {
// evaluate() is your math evaluator. You can drop in a library
// (exp4j, mxParser) or use the small recursive-descent parser the
// companion repo ships in tools-server/.../ExpressionEvaluator.java,
// which supports +, -, *, /, ^, parentheses, and the functions above.
double result = evaluate(expression);
String json = mapper.writeValueAsString(
Map.of("expression", expression, "result", result));
return CallToolResult.builder().addTextContent(json).build();
} catch (IllegalArgumentException e) {
return CallToolResult.builder().isError(true).addTextContent(
"Invalid expression: " + e.getMessage()
).build();
} catch (JsonProcessingException e) {
return CallToolResult.builder().isError(true).addTextContent(
"JSON serialization failed: " + e.getMessage()
).build();
}
})
.build();

Notice: we return JSON, not "The result is 42". The AI will format the answer appropriately for the user.

evaluate() is a stand-in

The snippet above shows the MCP wiring, not a working expression parser; evaluate(String) is yours to supply. For a complete, compiling implementation see ExpressionEvaluator.java in the companion tools-server module, or plug in a library such as exp4j or mxParser behind the same method signature.


Tool 2: Read File

A filesystem tool with path traversal protection:

private static final Path ALLOWED_DIR = Path.of(System.getProperty("user.home"), "Documents");

var readFileTool = McpServerFeatures.SyncToolSpecification.builder()
.tool(McpSchema.Tool.builder()
.name("read_file")
.description("""
Read the contents of a text file.
Only files within the user's Documents folder are accessible.
Returns the file contents as text. Fails for binary files or files outside the allowed directory.
""")
.inputSchema(new McpSchema.JsonSchema(
"object",
Map.of("path", Map.of(
"type", "string",
"description", "Relative path from the Documents folder, e.g. 'project/notes.txt'")),
List.of("path"),
null, null, null))
.build())
.callHandler((exchange, request) -> {
String relativePath = (String) request.arguments().get("path");

// Resolve and normalise to prevent path traversal
Path target;
try {
target = ALLOWED_DIR.resolve(relativePath).normalize();
} catch (Exception e) {
return CallToolResult.builder().isError(true).addTextContent("Invalid path: " + e.getMessage()).build();
}

// Security check: ensure resolved path is still within the allowed directory
if (!target.startsWith(ALLOWED_DIR)) {
return CallToolResult.builder().isError(true).addTextContent(
"Access denied: path is outside the allowed directory"
).build();
}

if (!Files.exists(target)) {
return CallToolResult.builder().isError(true).addTextContent("File not found: " + relativePath).build();
}

try {
String content = Files.readString(target);
return CallToolResult.builder().addTextContent(content).build();
} catch (IOException e) {
return CallToolResult.builder().isError(true).addTextContent("Read failed: " + e.getMessage()).build();
}
})
.build();

The path traversal check (if (!target.startsWith(ALLOWED_DIR))) is the critical security invariant. Never skip it.


Tool 3: Database Search (Async)

For tools that do I/O, database queries, API calls, use async handlers to avoid blocking.

Quick Reactor primer for sync-only developers: the async path uses Project Reactor's Mono<T> type. Mono is "a future with operators":

  • A Mono<T> represents a single asynchronous result (or empty, or error). Conceptually like CompletableFuture<T> but with lazy execution and a richer operator set.
  • Mono.fromFuture(future) wraps an existing CompletableFuture so non-reactive code (JPA, JDBC, REST clients) composes into the reactive chain.
  • .map(fn) transforms the value (sync). .flatMap(fn) transforms it into another Mono (async). .onErrorResume(fn) recovers from failure.
  • The framework subscribes to the returned Mono and pulls the result out. You don't call .block() in handlers; that defeats the purpose.

You can add Reactor explicitly with io.projectreactor:reactor-core. In most Spring AI / MCP server setups it's already transitive via the SDK.

With that out of the way:

var searchCustomersTool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(McpSchema.Tool.builder()
.name("search_customers")
.description("""
Search the customer database by name, email, or phone number.
Returns a JSON array of matching customers with id, name, email, and account status.
Use for customer lookup, not for retrieving bulk data (max 50 results).
""")
.inputSchema(new McpSchema.JsonSchema(
"object",
Map.of(
"query", Map.of("type", "string", "description", "Search term"),
"limit", Map.of("type", "integer", "description", "Max results (1-50)", "default", 10)),
List.of("query"),
null, null, null))
.build())
.callHandler((exchange, request) -> {
String query = (String) request.arguments().get("query");
int limit = Optional.ofNullable(request.arguments().get("limit"))
.map(l -> ((Number) l).intValue())
.map(l -> Math.min(50, Math.max(1, l)))
.orElse(10);

// customerRepository.searchAsync is a stand-in for whatever async backend you have
// (Spring Data's reactive Mongo, an AsyncTask from your service layer, an HTTP
// call via WebClient, etc.). Anything returning CompletableFuture<List<Customer>>
// works with Mono.fromFuture. If your backend is already reactive, drop the wrap.
return Mono.fromFuture(customerRepository.searchAsync(query, limit))
.map(customers -> CallToolResult.builder()
.addTextContent(toJson(customers))
.build())
.onErrorResume(e -> Mono.just(
CallToolResult.builder().isError(true)
.addTextContent("Search failed: " + e.getMessage())
.build()));
})
.build();

AsyncToolSpecification.builder().callHandler(...) takes a handler returning Mono<CallToolResult> (Project Reactor). The SDK handles the async plumbing; you focus on the reactive composition. If you already have a CompletableFuture from a non-reactive backend, wrap it with Mono.fromFuture(...).

customerRepository, Customer, and toJson are stand-ins

This snippet assumes three things you provide: a customerRepository whose searchAsync(query, limit) returns CompletableFuture<List<Customer>>, a Customer type, and a toJson(...) helper (an ObjectMapper.writeValueAsString(...) call wrapped to handle JsonProcessingException). The companion tools-server module ships complete, compiling versions of all three (CustomerRepository with an in-memory implementation, the Customer record, and JSON serialisation inside SearchCustomersTool) so you can see the whole picture wired together.


Registering Multiple Tools

Combine all tools when building the server. Use McpServer.sync(...) for sync tools, McpServer.async(...) for async (Mono-returning) tools. The two API surfaces are separate; you can't mix them directly. Either block on the async backend inside a sync handler and return its result synchronously, or wrap sync specifications as async ones (the companion tools-server does the latter):

// Sync server (calculateTool and readFileTool are SyncToolSpecification)
McpServer.sync(new StdioServerTransportProvider(new JacksonMcpJsonMapper(new ObjectMapper())))
.serverInfo("acme-tools", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
.tools(calculateTool, readFileTool)
.build();

For all-async tools, build via McpServer.async(...):

// Async server (searchCustomersTool is AsyncToolSpecification)
McpServer.async(new StdioServerTransportProvider(new JacksonMcpJsonMapper(new ObjectMapper())))
.serverInfo("acme-tools", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
.tools(searchCustomersTool)
.build();

Return Types

CallToolResult is a record with four fields (content, isError, structuredContent, and meta, serialized on the wire as _meta); construct it via its builder rather than the canonical constructor, which avoids spelling out null fields:

// Text (most common)
CallToolResult.builder()
.addTextContent("result text")
.build();

// Multiple content items (text + image, for example)
CallToolResult.builder()
.addTextContent("Chart description")
.addContent(new McpSchema.ImageContent(null, "base64-encoded-png", "image/png"))
.build();

// Error
CallToolResult.builder()
.isError(true)
.addTextContent("Error details")
.build();

addTextContent(String) is a shorthand for addContent(new McpSchema.TextContent(text)). ImageContent and the other content types take an optional Annotations first argument (pass null for none).


Tool Description Anti-Patterns

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

Too vague:

"search", searches things

The AI has no idea 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)

Companion code

A runnable server with all three tools (sync calculate, sync sandboxed read_file, and async search_customers via Reactor) plus a unit-test suite covering 25 cases is in the companion repository at projects/mcp-java-sdk/tools-server/. The companion hosts all three tools on a single McpServer.async(...) by adapting the two sync specifications to async ones; see AsyncSpecs.java, which wraps each sync handler in Mono.fromCallable on the boundedElastic scheduler, mirroring the SDK's own internal sync-to-async adaptation. The same module also illustrates the error-handling patterns from Module 6 and the unit-test style from Module 7. Build with mvn -B -pl tools-server -am verify.

What's Next

You can now build tools. In the next module, we implement Resources, the read-only, data-exposure primitive.

→ Module 4: Implementing Resources