Tool Calling
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Written against Spring AI 2.0.1, Spring Boot 4.1.x and Java 21 (verified 2026-09-29). A model can only produce text. Tool
calling lets it ask your application to run a Java method and use the result: to look something up, or to change
something. The model never runs anything itself. It returns the name of a tool and a JSON object of arguments, your
application decides whether and how to run it, and the result goes back to the model as one more message. This page
adds two tools to the running DocsAssistant service: searchDocs, a read-only search over the documentation
vector store, and openIssue, a write tool that only maintainers can reach and that never creates anything without
a human approval. The classes on it were compiled against the 2.0.1 jars. The security behaviour, the loop, the
exception handling and returnDirect were also run against a stub ChatModel and a Spring context, since no model server
was available. The loop itself, ordering and limits are on
Tool Calling Advisor & Tool Search; the design of good
tools independent of any framework is on Tool Design.
The tool-call flow
ChatClient registers a ToolCallingAdvisor for you (see
Advisors for how advisors wrap a call). It sends the tool definitions (name,
description and a JSON Schema of the parameters) together with the conversation. If the model answers with tool calls
instead of text, the advisor gives them to the ToolCallingManager, which finds the matching ToolCallback, runs it,
appends the result to the history and calls the model again. This repeats until the model answers without tool calls.
Three consequences follow from the diagram. The application owns execution, so every check you would put in front of
a REST endpoint (authorisation, validation, rate limits, auditing) belongs in or around the tool. The model sees only the
definitions and results, so anything that must not reach it, such as the caller’s identity, has to travel in the
tool context. And every round is a separate model call, which is why a tool that returns a lot of text makes a request
slow and expensive. The 1.x behaviour where each ChatModel ran the loop internally is gone in 2.0: calling a
ChatModel directly with tools returns the model’s tool-call request and executes nothing, so use ChatClient or drive
the loop yourself. Source: Spring AI: Tool Calling.
Declaring tools with @Tool and @ToolParam
Annotate a method with @Tool and it becomes a tool. Spring AI derives the JSON Schema from the parameter types. The
method may be public or not, static or not, but for GraalVM native images the class must be a Spring bean or be
registered for reflection.
| Attribute | Effect |
|---|---|
|
Tool name; defaults to the method name and must be unique within the tools sent to one model |
|
What the tool does and when to use it. Without it the model has nothing to decide with, so always set it |
|
Return the result to the caller instead of to the model; see below |
|
The |
|
Describes one parameter; the most specific description wins over Jackson’s |
|
Defaults to |
Mark a parameter required only when the model can really know its value: a required parameter with no way to find the
value invites the model to invent one. Parameters of type Optional, futures, reactive types (Mono, Flux,
Flow) and functional types are not supported by method tools, because they have no schema the model could fill in.
ToolContext is the exception, it is injected and does not appear in the schema (see
ToolContext).
searchDocs is the tool from the
retrieval section of the vector-rag page,
now a Spring bean with parameter descriptions. One change matters for safety: the component value comes from the
model, and the model can be steered by text it read, so it is turned into a filter with FilterExpressionBuilder
instead of being concatenated into a filter string.
package com.example.docsassistant;
import java.util.List;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.stereotype.Component;
@Component
public class DocsTools {
public record DocHit(String sourcePath, String section, String content, double score) { }
private final VectorStore vectorStore;
public DocsTools(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
@Tool(description = """
Search the DocsAssistant documentation and return the best matching passages. \
Use it for every question about the documented libraries; do not answer from memory.""")
public List<DocHit> searchDocs(
@ToolParam(description = "What to look for, phrased as a natural-language query") String query,
@ToolParam(description = "Documentation component to restrict the search to, for example "
+ "irurueta-algebra; omit to search everything", required = false) String component) {
SearchRequest.Builder request = SearchRequest.builder().query(query).topK(5);
if (component != null && !component.isBlank()) {
// the component comes from the model, so build the filter instead of concatenating a string
request.filterExpression(new FilterExpressionBuilder().eq("component", component).build());
}
return this.vectorStore.similaritySearch(request.build()).stream()
.map(d -> new DocHit(
(String) d.getMetadata().get("source_path"),
(String) d.getMetadata().get("section"),
d.getText(),
d.getScore() != null ? d.getScore() : 0.0))
.toList();
}
}
Source: Spring AI: Tool Calling.
The tool returns a list of records and the default converter serialises it to JSON with Jackson, so the model receives the passages with their source path and section and can cite them. The passages are also untrusted input: a documentation page can contain text that reads like an instruction, so the system prompt should say that tool results are data. The defences are on Security & Guardrails and Agent Guardrails & Safety.
Registering tools on the ChatClient
ChatClient accepts tools at two levels, and both take a mix of @Tool objects, ToolCallback instances and
ToolCallbackProvider instances:
-
defaultTools(…)on the builder makes the tools available to every request built from that client; -
tools(…)on one request adds tools for that call only. It appends to the defaults, it does not replace them.
Default tools are shared by every user of the client, so put only safe, read-only tools there. A tool that changes
something, or that only some users may use, is added per request after the caller has been checked. That is how
DocsToolChat combines the two, together with the tool context and the openIssue tool defined next.
A guarded write tool: openIssue
openIssue is the reverse of searchDocs in every respect that matters. It changes state, so it has three layers of
protection. Authorisation: @PreAuthorize("hasRole('MAINTAINER')") on the method, enforced by Spring Security’s
method security, and the tool is only registered on requests of maintainers. Human approval: the tool does not create
the GitHub issue, it puts a draft in an IssueApprovalQueue that a maintainer confirms elsewhere, and it tells the
model plainly that nothing was created. Least privilege: whichever component creates the issue after approval uses a
token that can write issues in one repository and nothing else, read from an environment variable, never from code. The
requester is read from the tool context, filled by server code from the authenticated user, so a prompt cannot name a
different requester.
package com.example.docsassistant;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Component;
@Component
public class IssueTools {
private final IssueApprovalQueue approvals;
public IssueTools(IssueApprovalQueue approvals) {
this.approvals = approvals;
}
@PreAuthorize("hasRole('MAINTAINER')")
@Tool(description = """
Draft a GitHub issue about a documentation problem. The issue is NOT created: a maintainer \
must approve the draft first. Tell the user that the draft is waiting for approval.""")
public String openIssue(
@ToolParam(description = "Short imperative title, at most 80 characters") String title,
@ToolParam(description = "Markdown description of the problem, with the page it affects") String body,
ToolContext toolContext) {
if (title.length() > 80) {
// a message the model can act on: the default processor sends it back so the model retries
throw new IllegalArgumentException("title is " + title.length() + " characters; shorten it to 80");
}
String requester = (String) toolContext.getContext().get("requester");
if (requester == null) {
throw new IllegalStateException("no requester in the tool context; refusing to draft an issue");
}
String draftId = this.approvals.submit(new IssueDraft(requester, title.strip(), body));
return "Draft " + draftId + " is waiting for maintainer approval; the issue has not been created yet.";
}
}
interface IssueApprovalQueue {
String submit(IssueDraft draft);
}
record IssueDraft(String requester, String title, String body) { }
Source: Spring AI: Tool Calling. Source: Spring Security: Method Security.
Method security has to be switched on, otherwise the annotation is decoration:
package com.example.docsassistant;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration(proxyBeanMethods = false)
@EnableMethodSecurity
class MethodSecurityConfig {
}
Source: Spring Security: Method Security.
The annotation was exercised on a real Spring context: the bean is a CGLIB proxy, ToolCallbacks.from(…) found
openIssue on the proxy and produced a schema with title and body only, a call as ROLE_MAINTAINER reached the
method, and a call as ROLE_USER failed with an AuthorizationDeniedException before the method ran. The check depends on the
SecurityContext of the thread that executes the tool. With blocking call() that is the request thread, but on
reactive or streaming paths the tool may run on another thread, so test that case before relying on the annotation, and
keep the maintainer check in DocsToolChat as a second layer. The identity model is on
Spring Security.
package com.example.docsassistant;
import java.util.Map;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
@Service
class DocsToolChat {
private final ChatClient chatClient;
private final IssueTools issueTools;
DocsToolChat(ChatClient.Builder builder, DocsTools docsTools, IssueTools issueTools) {
// read-only tools are safe as defaults; the write tool is added per request below
this.chatClient = builder.defaultTools(docsTools).build();
this.issueTools = issueTools;
}
String ask(Authentication authentication, String question) {
boolean maintainer = authentication.getAuthorities().stream()
.anyMatch(a -> "ROLE_MAINTAINER".equals(a.getAuthority()));
ChatClient.ChatClientRequestSpec request = this.chatClient.prompt()
.user(question)
// never sent to the model; the tool reads it to know who is asking
.toolContext(Map.of("requester", authentication.getName()));
if (maintainer) {
request = request.tools(this.issueTools);
}
return request.call().content();
}
}
Source: Spring AI: Tool Calling.
ToolContext
ToolContext carries data to a tool without sending it to the model: a tenant, a user id, a request id. Set it on the
request with toolContext(Map) or on the builder with defaultToolContext(Map) (a default and a request value are
merged, the request wins), and declare a ToolContext parameter on the tool method. The parameter is injected and is not
part of the schema. A tool that declares one fails with an IllegalArgumentException when the request has no tool
context at all, an empty map counts as none, which is a useful fail-closed behaviour for openIssue. Source:
Spring AI: Tool Calling.
ToolCallback and the programmatic styles
Every tool, whatever way it was declared, becomes a ToolCallback: a ToolDefinition (name, description, JSON Schema
of the input), a ToolMetadata (currently the returnDirect flag) and a call(String json) method, with an overload
that also takes a ToolContext. There are three ways to create one.
| Style | Use when | Entry point |
|---|---|---|
Declarative |
You own the class |
|
Method-backed, programmatic |
You do not own the class, or the definition is built at run time |
|
Function-backed |
The logic is a lambda or a method reference |
|
FunctionToolCallback needs an input type to generate the schema. A record with @ToolParam on its components keeps the
description next to the field, and declaring the function as a typed variable avoids an ambiguity between the Function
and Consumer overloads of the builder. Register the callback as a bean and Spring AI’s ToolCallbackResolver can find it by name; pass it to the
client explicitly as well, because in 2.0.1 a tool is only executed when it was attached to the request.
package com.example.docsassistant;
import java.util.function.Function;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.ai.tool.function.FunctionToolCallback;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
class CatalogTools {
record ComponentRequest(
@ToolParam(description = "Component name, for example irurueta-algebra") String component) { }
record PageCount(String component, int pages) { }
interface DocsCatalog {
int pageCount(String component);
}
@Bean
ToolCallback pageCount(DocsCatalog catalog) {
Function<ComponentRequest, PageCount> function =
request -> new PageCount(request.component(), catalog.pageCount(request.component()));
return FunctionToolCallback.builder("pageCount", function)
.description("Return how many documentation pages a component has")
.inputType(ComponentRequest.class)
.build();
}
}
Source: Spring AI: Tool Calling.
package com.example.docsassistant;
import java.lang.reflect.Method;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallback;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.ai.tool.support.ToolDefinitions;
import org.springframework.util.ReflectionUtils;
class ProgrammaticTools {
// 1. every @Tool method of one or more objects, as an array
ToolCallback[] fromObjects(DocsTools docsTools) {
return ToolCallbacks.from(docsTools);
}
// 2. the same, as a provider that can be a bean or be passed to tools(...)
ToolCallbackProvider asProvider(DocsTools docsTools) {
return MethodToolCallbackProvider.builder().toolObjects(docsTools).build();
}
// 3. one method, with a definition you control (no @Tool needed on the method)
ToolCallback oneMethod(DocsTools docsTools) {
Method method = ReflectionUtils.findMethod(DocsTools.class, "searchDocs", String.class, String.class);
return MethodToolCallback.builder()
.toolDefinition(ToolDefinitions.builder(method)
.name("search_docs")
.description("Search the documentation")
.build())
.toolMethod(method)
.toolObject(docsTools)
.build();
}
}
MethodToolCallbackProvider is the shape to use when another component asks for a ToolCallbackProvider, and it is the
same interface the MCP client implements for remote tools (see MCP Client).
ToolDefinitions lives in org.springframework.ai.tool.support, a package the reference page does not print in its
snippets. Sources: Spring AI: Tool Calling and the 2.0.1 jars.
Returning the result directly
Normally the tool result is one more input for the model, which turns it into prose. If the result is already the
answer, returnDirect = true skips that second model call: the tool result becomes the response and the loop stops.
When the model requests several tools in one round, the flag is honoured only if every requested tool has it.
package com.example.docsassistant;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
class PageLinkTools {
private final String siteUrl;
PageLinkTools(@Value("${docsassistant.site-url}") String siteUrl) {
this.siteUrl = siteUrl.endsWith("/") ? siteUrl : siteUrl + "/";
}
// the result is the final answer: the model never sees it and no second model call is made
@Tool(description = "Return the canonical URL of a documentation page", returnDirect = true)
String pageUrl(@ToolParam(description = "Path of the page below the site root, for example "
+ "ai/spring-ai/advisors.html") String path) {
if (!path.matches("[a-z0-9/_-]+\\.html")) {
throw new IllegalArgumentException("path must look like ai/spring-ai/advisors.html");
}
return this.siteUrl + path;
}
}
Source: Spring AI: Tool Calling.
The site root comes from the property docsassistant.site-url, so the tool never hard-codes an address. Running it against a stub model made exactly one model call and returned the tool output as the response. Two
details from that run are worth knowing: the default converter serialises a String result as a JSON string, so the
response text arrives with quotes; supply a ToolCallResultConverter on the annotation if you need the raw text. And
because the model never sees a direct result, the tool must produce something safe to show as it is. The parameter is
validated against a pattern here because a model-supplied path is untrusted input.
Exceptions in tools
When a tool throws, MethodToolCallback wraps the cause in a ToolExecutionException and hands it to a
ToolExecutionExceptionProcessor. The default processor, DefaultToolExecutionExceptionProcessor, does this: a
RuntimeException becomes a message that goes back to the model as the tool result, so the model can correct itself, and
anything that is not a runtime exception (a checked exception or an Error) is rethrown. That is why openIssue
throws IllegalArgumentException with a sentence such as "title is 91 characters; shorten it to 80": the model reads it and
tries again. Two switches change the policy:
-
the property
spring.ai.tools.throw-exception-on-error=truerethrows every tool error to the caller; -
your own
ToolExecutionExceptionProcessorbean, or aDefaultToolExecutionExceptionProcessorwith an allow-list of exception types that are unwrapped and rethrown as they are.
Authorisation failures should not be discussed with the model. An AccessDeniedException caught by the default policy
becomes the string "Access Denied" in the conversation, and the model may apologise or try another route. The bean below
rethrows it, so the caller sees a normal Spring Security exception and the web layer answers 403.
package com.example.docsassistant;
import java.util.List;
import org.springframework.ai.tool.execution.DefaultToolExecutionExceptionProcessor;
import org.springframework.ai.tool.execution.ToolExecutionExceptionProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
@Configuration(proxyBeanMethods = false)
class ToolErrorPolicy {
@Bean
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() {
return DefaultToolExecutionExceptionProcessor.builder()
// false: other RuntimeExceptions are sent back to the model as a message it can react to
.alwaysThrow(false)
// authorisation failures are not the model's business: unwrap and rethrow to the caller
.rethrowExceptions(List.of(AccessDeniedException.class))
.build();
}
}
Source: Spring AI: Tool Calling.
Running a stub tool that threw a SecurityException showed both paths: with the default processor the model received the
message and answered normally, with the allow-list the exception reached the caller unchanged. If you implement
ToolCallback yourself, throw ToolExecutionException from call so that the processor sees it.
Operating tools safely
A few properties are worth turning into settings early, all covered in more detail elsewhere. The number of calls one
turn may make is capped (40 per tool and 150 in total by default) and configurable through spring.ai.tools.limits.*,
see Tool Calling Advisor & Tool Search. Every tool call is a
Micrometer observation named spring.ai.tool with a span called execute_tool <name>; arguments and results are not
exported to traces unless you set spring.ai.tools.observations.include-content, which is a privacy decision, see
Observability. Tools exposed to other applications through the Model Context Protocol
use @McpTool instead of @Tool, see MCP Server. Tools written for other agent
frameworks are compared on Agent Frameworks Landscape.
Where older material differs
Books and posts written for Spring AI 1.0 or earlier show tool APIs that no longer exist in 2.0.
| Older form | In 2.0 |
|---|---|
|
|
|
Removed. Declare a |
|
|
|
|
Per-model |
Removed; the loop is the |
|
Removed; use |
The migration checklist is on Migrating from 1.x to 2.x.