|
This version is still in development and is not considered stable yet. For the latest stable version, please use Spring AI 2.0.0! |
Tool Calling
Tool calling — the ability for an AI model to invoke application-defined functions and act on their results — is the essential building block of agentic AI systems. A model that can only generate text is a chatbot; a model that can discover information, take action, and loop until a goal is reached is an agent.
Tools are used for two broad purposes:
-
Information retrieval — augmenting the model’s knowledge with data from databases, web services, file systems, or search engines. Examples: fetching the current weather, querying customer records, looking up the latest news.
-
Taking action — executing operations in your system. Examples: sending an email, booking a flight, updating a record, triggering a workflow.
Although we speak of "tool calling" as a model capability, it is the application that owns the tool calling logic. The model can request a tool call and provide input arguments; the application is responsible for executing the tool and returning the result. The model never receives direct access to the APIs behind your tools — a critical security consideration.
Architecture Overview
Spring AI 2.0 makes the tool calling loop a first-class, composable component of the `ChatClient’s advisor chain. The flow at a glance:
-
You define tools (Defining Tools) and pass them to
ChatClient. -
ChatClientauto-registers aToolCallingAdvisor(The Tool Calling Loop) that drives the loop. -
The model decides which tools to call;
ToolCallingManagerexecutes them; the loop continues until the model produces a response without tool calls. -
Other advisors in the chain — memory, observability, retries, custom logic — compose with the loop through a single dial: advisor ordering (Memory and the Tool Loop).
This architecture replaces the per-ChatModel tool execution loops of Spring AI 1.x. Calling ChatModel directly is still supported for low-level use cases — see ChatModel Tool Calling.
|
| Check the Chat Model Comparisons to see which models support tool calling. |
Quick Start
Define a tool by annotating a method with @Tool:
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.context.i18n.LocaleContextHolder;
class DateTimeTools {
@Tool(description = "Get the current date and time in the user's timezone")
String getCurrentDateTime() {
return LocalDateTime.now().atZone(LocaleContextHolder.getTimeZone().toZoneId()).toString();
}
@Tool(description = "Set a user alarm for the given time, provided in ISO-8601 format")
void setAlarm(String time) {
LocalDateTime alarmTime = LocalDateTime.parse(time, DateTimeFormatter.ISO_DATE_TIME);
System.out.println("Alarm set for " + alarmTime);
}
}
Pass the tools to ChatClient via .tools() and ask a question that requires them:
ChatModel chatModel = ...
String response = ChatClient.create(chatModel)
.prompt("Can you set an alarm 10 minutes from now?")
.tools(new DateTimeTools())
.call()
.content();
The model decides to call getCurrentDateTime(), then setAlarm(…) with the computed time. Spring AI handles the full round-trip automatically.
Defining Tools
Spring AI offers three ways to define tools, from highest-level to most explicit:
| Style | Use when | Section |
|---|---|---|
Declarative |
You own the method and want minimal ceremony |
|
|
You need programmatic control over a method-backed tool |
|
|
You want to expose a |
All three produce ToolCallback instances that flow through the same loop. You can mix styles freely.
Declarative: @Tool
Annotate any method — public, package-private, private; static or instance — with @Tool:
class WeatherTools {
@Tool(description = "Get the current weather for a given city")
public String getWeather(String city) {
return weatherService.fetch(city);
}
}
The @Tool annotation supports:
-
name— the tool name. Defaults to the method name. Must be unique within the tool set provided to a model. -
description— what the tool does and when to use it. Strongly recommended; without it the model has no guidance on when to call the tool. -
returnDirect— return the result directly to the caller instead of feeding it back to the model. See Return Direct. -
resultConverter— theToolCallResultConverterto use. See Result Conversion.
For AOT compilation (GraalVM native image), the class containing the @Tool methods must be a Spring bean (e.g. @Component). Otherwise, annotate it with @RegisterReflection(memberCategories = MemberCategory.INVOKE_DECLARED_METHODS).
|
Add @ToolParam to describe individual parameters:
class WeatherTools {
@Tool(description = "Get the weather for a city at a specific time")
public String getWeather(
@ToolParam(description = "City name") String city,
@ToolParam(description = "Time in ISO-8601 format", required = false) String at) {
return weatherService.fetch(city, at);
}
}
By default all parameters are required. @ToolParam(required = false) or @Nullable makes a parameter optional. See JSON Schema for the full schema customization options.
Programmatic: MethodToolCallback
For dynamic tool registration — when you don’t control the source class, or you want to build the tool definition at runtime — use MethodToolCallback:
Method method = ReflectionUtils.findMethod(WeatherTools.class, "getWeather", String.class);
MethodToolCallback callback = MethodToolCallback.builder()
.toolDefinition(ToolDefinitions.builder(method)
.description("Get the current weather for a given city")
.build())
.toolMethod(method)
.toolObject(new WeatherTools())
.build();
The toolObject is required for instance methods, optional for static methods. See Tool Specification Reference for the full builder API.
Programmatic: FunctionToolCallback
For tools backed by a Function, Supplier, Consumer, or BiFunction — including lambdas and method references — use FunctionToolCallback:
FunctionToolCallback callback = FunctionToolCallback.builder("currentWeather", weatherService::getWeather)
.description("Get the weather in location")
.inputType(WeatherRequest.class)
.build();
The factory takes a name and the function reference. The builder sets the description and the input type (used for JSON schema generation).
ToolCallback Beans
Spring AI automatically discovers ToolCallback beans in the application context and exposes them through the ToolCallbackResolver for name-based lookup. Define a tool once as a @Bean and inject it where needed:
@Configuration(proxyBeanMethods = false)
class WeatherToolsConfig {
@Bean
ToolCallback currentWeather(WeatherService weatherService) {
return FunctionToolCallback.builder("currentWeather", weatherService::getWeather)
.description("Get the weather in location")
.inputType(WeatherRequest.class)
.build();
}
}
Inject and pass the bean to ChatClient:
@Autowired ToolCallback currentWeather;
ChatClient.create(chatModel)
.prompt("What's the weather in Copenhagen?")
.tools(currentWeather)
.call()
.content();
Spring AI 2.0 removes the SpringBeanToolCallbackResolver pattern where bare Function beans were resolved by name via toolNames(…). Tools must now be registered as explicit ToolCallback beans. See Upgrading Tool Calling from 1.x to 2.0.
|
Passing Tools to ChatClient
ChatClient exposes two methods for providing tools:
// Per call — tools available only for this single request
chatClient.prompt(...)
.tools(new WeatherTools(), currentWeather)
.call();
// As defaults — tools available on every request built from this client
ChatClient client = ChatClient.builder(chatModel)
.defaultTools(new WeatherTools(), currentWeather)
.build();
Both methods are heterogeneous: they accept @Tool-annotated POJO instances, ToolCallback instances, ToolCallbackProvider instances, and arrays or collections of any of these. You can mix styles in a single call.
Per-call .tools(…) appends to the client’s defaults — it does not replace them. The final tool list sent to the model is the union of .defaultTools(…) and any .tools(…) added at the call site.
Default tools are shared across every request built from the same ChatClient.Builder. They are useful for tools that should always be available, but they can also be dangerous if used carelessly — risk-tier and destructive tools should typically be added per call, not as defaults.
|
Tools registered via ToolCallingChatOptions.toolCallbacks(…) on the underlying ChatModel are overridden by the request-level tool list. See ChatModel Tool Calling for the precedence rules.
|
The Tool Calling Loop
When you call .call() or .stream() on a ChatClient request, the request flows through the advisor chain. ToolCallingAdvisor — auto-registered by DefaultChatClient — owns the tool execution lifecycle:
-
The advisor sends the request to the model with all registered tool definitions included.
-
The model decides whether to call any tools and returns a response.
-
If the response contains tool calls, the advisor:
-
Hands them to
ToolCallingManager, which finds the matchingToolCallbackand executes it. -
Appends the tool results to the conversation history.
-
Sends the updated history back to the model.
-
-
Steps 2–3 repeat until the model produces a response without tool calls; that response is returned to the caller.
Both blocking (.call()) and streaming (.stream()) modes are fully supported.
ToolCallingAdvisor is a recursive advisor — it re-enters the downstream advisor chain on each iteration of the loop. The same mechanism drives structured-output validation retries and any other looped advisor pattern. See Recursive Advisors for the underlying pattern.
DefaultChatClient enforces that exactly one ToolAdvisor is present in the chain. Attempting to register a second one fails with an explicit error.
For the full ToolCallingAdvisor builder API, hook methods, and configuration options, see ToolCallingAdvisor.
Memory and the Tool Loop
Where you place MessageChatMemoryAdvisor relative to ToolCallingAdvisor (default order HIGHEST_PRECEDENCE + 300) determines how much conversation context the memory store captures.
Outside the Loop (default)
MessageChatMemoryAdvisor’s default order is `HIGHEST_PRECEDENCE + 200 — lower than ToolCallingAdvisor, which places it outside the loop. The memory advisor:
-
Loads history once before the loop starts.
-
Persists only the final user message and the final assistant message after the loop completes.
-
Never sees tool call requests or tool response messages.
This is the safe default and works with every ChatMemoryRepository implementation. It also matches the behavior of Spring AI 1.x, where the tool loop was internal to each ChatModel and memory could not observe tool messages.
var chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) // outside the loop by default
.build();
Inside the Loop
To give the model the full tool transcript on subsequent turns — so it can reason about what was already tried, which tools were called, and what they returned — place the memory advisor inside the loop by giving it an order greater than ToolCallingAdvisor.DEFAULT_ORDER:
var chatMemoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory)
.order(BaseAdvisor.HIGHEST_PRECEDENCE + 400) // inside (after) ToolCallingAdvisor
.build();
var chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(chatMemoryAdvisor)
.build();
To avoid duplicate writes, ToolCallingAdvisor’s internal conversation history must be disabled when a memory advisor sits inside the loop. With the auto-registered `ToolCallingAdvisor, this is automatic — DefaultChatClient detects any MemoryAdvisor inside the loop and disables internal history with no additional configuration. If you construct a ToolCallingAdvisor manually, call .disableInternalConversationHistory() on the builder yourself.
Backend Compatibility
Not every ChatMemoryRepository can persist tool messages. The repository has to know how to serialize ToolResponseMessage and tool call request messages alongside ordinary user and assistant turns — and most current implementations only model the latter.
As of 2.0, the built-in repositories that support the full message set are:
Any of them is safe inside the loop.
| For JDBC-backed persistence with full tool-message support — plus event-sourced history, turn-aware compaction, and multi-agent branch isolation — see the spring-ai-session community project. It is designed for exactly this scenario and is planned for inclusion in Spring AI 2.1. |
Scaling to Hundreds of Tools
The default ToolCallingAdvisor sends all registered tool definitions to the model on every request. With a small tool library this is fine. At 30+ tools — or in multi-server MCP setups where a session may aggregate hundreds of tool definitions — it creates context bloat, accuracy degradation, and unnecessary token cost.
ToolSearchToolCallingAdvisor is a drop-in replacement that implements the progressive tool disclosure pattern: it indexes the full tool set once per session and injects only a built-in toolSearchTool the model uses to retrieve relevant tools by natural language query. Only discovered tools are included in subsequent requests.
Enable it with a single property:
spring.ai.chat.client.tool-search-advisor.enabled=true
See Tool Search Tool for the full configuration, indexing strategies, session-ID semantics, and tuning options.
MCP Tools
The Model Context Protocol (MCP) is a standardized way for AI applications to consume tools, resources, and prompts from remote servers. Spring AI integrates MCP from both directions: your application can consume tools from MCP servers, and it can expose its own tools to MCP clients.
Consuming MCP Server Tools
Add the MCP client starter and configure connections to one or more MCP servers:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
spring.ai.mcp.client.stdio.connections.my-server.command=npx
spring.ai.mcp.client.stdio.connections.my-server.args=-y,@modelcontextprotocol/server-everything
Auto-configuration connects to all configured servers, discovers their tools, and exposes them as a SyncMcpToolCallbackProvider bean (or AsyncMcpToolCallbackProvider for the async client type).
MCP providers are deliberately not auto-registered with the ChatClient — they implement ToolCallbackProvider, but listing tools eagerly would force a network round-trip to every connected MCP server at startup. Inject the provider and wire it explicitly:
|
@Autowired SyncMcpToolCallbackProvider mcpTools;
// Once, as default tools for every request
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultTools(mcpTools)
.build();
// Or per call
chatClient.prompt()
.user("Search the web for the latest Spring AI release notes")
.tools(mcpTools)
.call()
.content();
Tool callback auto-configuration is enabled by default and can be turned off with spring.ai.mcp.client.toolcallback.enabled=false. See MCP Client Boot Starter for the full configuration surface — transports, security, filtering, name-prefix generation.
Exposing Spring Tools as an MCP Server
Going the other direction — exposing your Spring beans as MCP tools — is a matter of using @McpTool (instead of @Tool) and adding the MCP server starter:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
@Component
public class WeatherTools {
@McpTool(description = "Get the current weather for a given city")
public String getWeather(
@McpToolParam(description = "City name") String city) {
return weatherService.fetch(city);
}
}
Auto-configuration scans for @McpTool-annotated beans, generates JSON schemas for their parameters, and registers them with the MCP server. See MCP Server Boot Starter for transport, security, and observability options.
Combining Local and MCP Tools
Local @Tool methods and remote MCP tools share the same ToolCallback interface — the model and the ToolCallingAdvisor don’t distinguish between them. .tools(…) and .defaultTools(…) accept both types in a single call, so you can mix freely:
chatClient.prompt()
.tools(new LocalTools(), mcpTools)
.call()
.content();
A few things to be aware of in hybrid setups:
-
Name conflicts are only handled within the MCP side.
DefaultMcpToolNamePrefixGeneratorprefixes duplicates across MCP servers, but it doesn’t know about local@Toolmethods. If a local tool and a remote MCP tool share a name, you need to rename one of them yourself, or use anMcpToolFilterto drop the remote one. -
Restrict what’s exposed. MCP tools come from external sources whose surface you don’t fully control. An
McpToolFilterbean lets you select which tools enter the namespace based on server identity, tool name, or description — useful for limiting the blast radius of a chatty or untrusted MCP server.
Tool Argument Augmentation
Spring AI provides a utility for dynamic augmentation of tool input schemas with additional arguments. This lets you capture extra information from the model — such as reasoning, confidence, or metadata — without modifying the underlying tool implementation. The model sees the augmented schema and fills in the extra fields; your code receives them via a consumer; the original tool receives only its expected arguments, unchanged.
Common use cases:
-
Inner thinking / reasoning — capture the model’s step-by-step reasoning before executing a tool.
-
Memory enhancement — extract insights to store in long-term memory.
-
Analytics & tracking — collect metadata, user intent, or usage patterns.
-
Multi-agent coordination — pass agent identifiers or coordination signals.
Quick Start
Define the augmented arguments as a Java record:
public record AgentThinking(
@ToolParam(description = "Your reasoning for calling this tool", required = true)
String innerThought,
@ToolParam(description = "Confidence level (low, medium, high)", required = false)
String confidence
) {}
Wrap your tools with AugmentedToolCallbackProvider:
AugmentedToolCallbackProvider<AgentThinking> provider = AugmentedToolCallbackProvider
.<AgentThinking>builder()
.toolObject(new WeatherTools()) // wrap the original tools
.argumentType(AgentThinking.class) // augmentation schema type
.argumentConsumer(event -> { // optional consumer of augmented content
AgentThinking thinking = event.arguments();
log.info("Tool: {} | Reasoning: {}", event.toolDefinition().name(), thinking.innerThought());
})
.removeExtraArgumentsAfterProcessing(true)
.build();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultTools(provider)
.build();
The LLM sees the augmented schema with your additional fields. Your consumer receives the AgentThinking record; the original tool receives only its expected arguments.
Core Components
-
AugmentedToolCallbackProvider<T>— wraps tool objects or providers, augmenting all tools with the specified record type. -
AugmentedToolCallback<T>— wraps individualToolCallbackinstances. -
AugmentedArgumentEvent<T>— containstoolDefinition(),rawInput(), andarguments()for consumers. -
ToolInputSchemaAugmenter— low-level utility for schema manipulation.
The removeExtraArgumentsAfterProcessing option (default true) controls whether augmented arguments are passed to the original tool. Set it to false if the tool can ignore extra fields and you want them preserved in the input.
User-Controlled Tool Execution
The auto-registered loop covers most cases, but some scenarios genuinely need you to own each iteration:
-
Gating tool execution on an external approval step.
-
Forwarding intermediate progress to an SSE or WebSocket endpoint.
-
Applying conditional logic between turns.
-
Stopping the loop based on a side-channel signal.
Opt out per call by setting AdvisorParams.toolCallingAdvisorAutoRegister(false) on the request. You become responsible for detecting tool calls in the ChatResponse and executing them via ToolCallingManager:
ChatClient chatClient = ...
ToolCallingManager toolCallingManager = ToolCallingManager.builder().build();
ToolCallback[] tools = ToolCallbacks.from(new WeatherTools());
ChatOptions chatOptions = ToolCallingChatOptions.builder().toolCallbacks(tools).build();
String question = "What is the weather in Amsterdam and Paris?";
// ToolCallingAdvisor disabled — no tool loop runs automatically
ChatClientResponse response = chatClient.prompt()
.user(question)
.options(chatOptions)
.advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
.call()
.chatClientResponse();
Prompt prompt = new Prompt(List.of(new UserMessage(question)), chatOptions);
// Drive the loop yourself — each iteration is observable and interruptible
while (response.chatResponse() != null && response.chatResponse().hasToolCalls()) {
ToolExecutionResult result = toolCallingManager.executeToolCalls(prompt, response.chatResponse());
prompt = new Prompt(result.conversationHistory(), chatOptions);
response = chatClient.prompt()
.messages(result.conversationHistory())
.options(chatOptions)
.advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
.call()
.chatClientResponse();
}
The same pattern applies to the streaming API, where each iteration’s chunk Flux can be forwarded to a subscriber while being aggregated with ChatClientMessageAggregator. See User-Controlled Streaming for the full streaming variant.
To disable auto-registration globally for every call from an auto-configured ChatClient, set:
spring.ai.chat.client.tool-calling.enabled=false
Driving the loop yourself outside the framework also bypasses observability, advisor composition, and the single-ToolAdvisor invariant. For most needs — including streaming intermediate progress to a UI — placing a custom advisor inside the loop (see Extending the Loop) is simpler and more composable than full opt-out.
|
Extending the Loop: Custom ToolAdvisor
The tool loop is not a black box. ToolCallingAdvisor exposes hook methods at well-defined points, and DefaultChatClient’s auto-configuration accepts custom implementations transparently. This is the same extension point that `ToolSearchToolCallingAdvisor uses to implement progressive tool disclosure.
ToolAdvisor is a marker interface: any custom tool call advisor must implement it so that DefaultChatClient recognizes it, enforces the single-advisor constraint, and registers it in place of the default.
Hook Methods
The base ToolCallingAdvisor exposes four pairs of protected hook methods (one each for the call and stream paths):
| Hook | When it fires |
|---|---|
|
Once, before the first iteration |
|
Before each iteration |
|
After each iteration |
|
Once, after the loop ends |
ToolSearchToolCallingAdvisor uses doInitializeLoop to index the tool set and augment the system message, and doBeforeCall to inject only the tools discovered so far. Any custom subclass follows the same pattern.
Common use cases for a custom loop:
-
Approval gate — pause before executing a destructive tool and wait for human confirmation.
-
Observability — emit structured events for each tool call to a side channel.
-
Budget enforcement — count tokens or LLM calls per session and abort if limits are exceeded.
-
Custom tool resolution — resolve tools from a dynamic source not available at startup.
Auto-Configuration Integration
Custom ToolAdvisor implementations plug into the auto-configuration system without requiring any manual ChatClient wiring. The extension point is the ToolCallingAdvisor.Builder<?> bean.
ChatClientAutoConfiguration declares a default ToolCallingAdvisor.Builder<?> bean guarded by @ConditionalOnMissingBean. To replace it, register your own ToolCallingAdvisor.Builder<?> bean — typed as the base ToolCallingAdvisor.Builder<?> — in an auto-configuration that runs before ChatClientAutoConfiguration:
@AutoConfiguration(beforeName = "org.springframework.ai.model.chat.client.autoconfigure.ChatClientAutoConfiguration")
@ConditionalOnProperty(prefix = "my.advisor", name = "enabled", havingValue = "true")
public class MyToolAdvisorAutoConfiguration {
@Bean
@ConditionalOnMissingBean
ToolCallingAdvisor.Builder<?> toolCallingAdvisorBuilder(ToolCallingManager toolCallingManager) {
return MyCustomToolCallingAdvisor.builder()
.toolCallingManager(toolCallingManager);
}
}
ChatClient.Builder then uses your custom builder to auto-register your advisor transparently. `ToolSearchToolCallingAdvisor’s auto-configuration uses exactly this mechanism.
For the full ToolCallingAdvisor builder API and hook signatures, see ToolCallingAdvisor.
Tool Specification Reference
The sections above cover the common paths for defining and using tools. This reference covers the underlying interfaces and customization points.
ToolCallback
The ToolCallback interface models a tool: it carries the definition the model sees and the execution logic invoked when the model calls it.
public interface ToolCallback {
/** Definition used by the AI model to decide when and how to call the tool. */
ToolDefinition getToolDefinition();
/** Metadata controlling how the tool is handled (e.g. return-direct). */
ToolMetadata getToolMetadata();
/** Execute with the given JSON input and return the result. */
String call(String toolInput);
/** Execute with the given input and tool context. */
String call(String toolInput, ToolContext toolContext);
}
Spring AI provides MethodToolCallback and FunctionToolCallback as built-in implementations. Implement ToolCallback directly when you need full control — for example, to proxy a remote tool source (as MCP integration does).
ToolDefinition
ToolDefinition is the contract the model sees: name, description, and input schema.
public interface ToolDefinition {
/** Unique tool name within the tool set provided to the model. */
String name();
/** Description used by the model to decide when to call the tool. */
String description();
/** JSON schema of the tool's input parameters. */
String inputSchema();
}
Build one with ToolDefinition.builder():
ToolDefinition toolDefinition = ToolDefinition.builder()
.name("currentWeather")
.description("Get the weather in location")
.inputSchema("""
{
"type": "object",
"properties": {
"location": { "type": "string" },
"unit": { "type": "string", "enum": ["C", "F"] }
},
"required": ["location", "unit"]
}
""")
.build();
For method-based tools, ToolDefinitions.from(method) generates a definition automatically. The class is annotated with @Tool? The annotation’s name and description override the method-name defaults.
JSON Schema
The input schema tells the model how to call the tool. Spring AI’s JsonSchemaGenerator produces the schema from a method or function’s parameter list and supports several customization annotations.
Parameter descriptions
Use any of these (Spring AI’s annotation has highest precedence):
-
@ToolParam(description = "…")— Spring AI -
@JsonClassDescription(description = "…")— Jackson -
@JsonPropertyDescription(description = "…")— Jackson -
@Schema(description = "…")— Swagger
class CustomerTools {
@Tool(description = "Update customer information")
void updateCustomerInfo(
Long id,
String name,
@ToolParam(description = "Email address, RFC 5322 format", required = false) String email) {
// ...
}
}
Required vs optional
By default every parameter is required. Make a parameter optional via (in order of precedence):
-
@ToolParam(required = false)— Spring AI -
@JsonProperty(required = false)— Jackson -
@Schema(required = false)— Swagger -
@Nullable— Spring Framework / JSpecify
| Defining the correct required status is critical for reducing hallucinations. A parameter marked required forces the model to provide a value; an optional one lets it omit. If you mark a parameter required but the model has no way to determine its value, it will make one up. |
Tool Context
ToolContext lets you pass non-model data (tenant IDs, user IDs, request scope) to tool methods. The data is not sent to the model — it’s purely for the tool’s internal use.
The chat request carries the tool definitions plus the tool context, and Spring AI sends only the tool definitions to the model (1). The model decides to call a tool and returns the tool call requests to Spring AI (2), which dispatches them to the matching tool (3). The tool context is handed directly to the tool at invocation time (3"), so it reaches the tool without ever passing through the model. The tool returns its result to Spring AI (4), which sends that result back to the model (5), and the model’s final answer is returned as the chat response (6).
In the example below, the Map.of("tenantId", "acme") passed to .toolContext() is the tool context from step (1), and getCustomerInfo reads it through its ToolContext parameter at step (3") — the tenantId reaches the tool without ever being exposed to the model.
class CustomerTools {
@Tool(description = "Retrieve customer information")
Customer getCustomerInfo(Long id, ToolContext toolContext) {
return customerRepository.findById(id, toolContext.getContext().get("tenantId"));
}
}
String response = ChatClient.create(chatModel)
.prompt("Tell me more about the customer with ID 42")
.tools(new CustomerTools())
.toolContext(Map.of("tenantId", "acme"))
.call()
.content();
If toolContext is set both as a default and at the call site, the two maps are merged; runtime values take precedence over defaults.
Return Direct
By default, the result of a tool call is sent back to the model so it can continue the conversation. With returnDirect = true, the result bypasses the model and is returned directly to the caller — useful when the tool’s output is the final answer (e.g. a RAG retrieval) and the extra round-trip would add latency without value.
For declarative tools:
@Tool(description = "Retrieve customer information", returnDirect = true)
Customer getCustomerInfo(Long id) { ... }
For programmatic tools, set returnDirect via ToolMetadata:
ToolMetadata toolMetadata = ToolMetadata.builder()
.returnDirect(true)
.build();
If the model requests multiple tool calls in a single round, returnDirect is only honored if all the called tools have returnDirect = true. Otherwise the results are sent back to the model.
|
Result Conversion
Tool results are converted to a String (the format the model expects) by a ToolCallResultConverter:
@FunctionalInterface
public interface ToolCallResultConverter {
String convert(Object result, Type returnType);
}
The default DefaultToolCallResultConverter uses Jackson to serialize the result. Configure a custom converter on the @Tool annotation (resultConverter) or via ToolMetadata.
Method Tool Limitations
Certain types are not supported as method parameters or return values when using MethodToolCallback:
-
Optional— use@Nullableor@ToolParam(required = false)instead. -
Asynchronous types (
CompletableFuture,Future). -
Reactive types (
Flow,Mono,Flux). -
Functional types (
Function,Supplier,Consumer).
These types are not serializable to the model and have no schema representation that the model can interpret.
Exception Handling
When a tool throws, the exception is wrapped in ToolExecutionException and handed to the ToolExecutionExceptionProcessor:
@FunctionalInterface
public interface ToolExecutionExceptionProcessor {
String process(ToolExecutionException exception);
}
The processor has two choices: produce an error message that the framework feeds back to the model (so the model can recover or apologize), or re-throw the exception so the caller handles it.
The default DefaultToolExecutionExceptionProcessor:
-
Sends the message of any
RuntimeExceptionback to the model. -
Always re-throws checked exceptions and `Error`s.
Configure via property:
Property |
Description |
Default |
|
If |
|
Or by replacing the bean:
@Bean
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() {
return new DefaultToolExecutionExceptionProcessor(true);
}
If you implement your own ToolCallback, throw ToolExecutionException from call() when execution fails — that’s what the processor expects.
|
Tool Resolution
Most of the time, tools are passed explicitly via .tools(…) and .defaultTools(…). For more dynamic scenarios — name-based resolution at runtime — Spring AI uses ToolCallbackResolver:
public interface ToolCallbackResolver {
@Nullable
ToolCallback resolve(String toolName);
}
By default, StaticToolCallbackResolver is auto-configured with all ToolCallback beans in the application context, plus tools produced by ToolCallbackProvider beans (with the exception of MCP providers, which are excluded to avoid eager listing — see Consuming MCP Server Tools).
Replace the resolver with a custom bean if you need a different strategy (database-backed, classpath-scanned, remote):
@Bean
ToolCallbackResolver toolCallbackResolver(List<ToolCallback> toolCallbacks) {
return new StaticToolCallbackResolver(toolCallbacks);
}
The resolver is used internally by ToolCallingManager to support both advisor-controlled execution and user-controlled execution.
Observability
Spring AI emits Micrometer observations for tool calls under the spring.ai.tool name. Each observation captures:
-
Tool name and definition metadata.
-
Execution duration.
-
Tracing context (when a
Traceris available).
See Tool Calling Observability for the full attribute reference.
Tool call arguments and results can optionally be exported as span attributes; this is disabled by default for sensitivity reasons. See Tool Call Arguments and Result Data to enable it.
ChatModel Tool Calling
For users who want to drive ChatModel directly — without ChatClient and without ToolCallingAdvisor — Spring AI supports a low-level tool calling path. This is the right choice when you need full control over the request/response cycle and don’t need the advisor chain’s composition features.
See ChatModel Tool Calling for the API, precedence rules between default and runtime tools, and worked examples.
The per-ChatModel internal tool execution loop of Spring AI 1.x has been removed. Calling ChatModel with tools sends the tool definitions to the model and returns the model’s response — tool calls in that response are not executed automatically. You drive the loop yourself, or use ChatClient (which auto-registers ToolCallingAdvisor for you).
|