Skip to main content

Tool Calling in Spring AI 2.0: @Tool, ToolCallingAdvisor and the Tool Search Advisor

The ChatClient in this series has already called a couple of tools in passing. This article is the one that actually explains how tool calling works in Spring AI 2.0: the plain case, where a tool answers a question the model then paraphrases; returnDirect, where the tool’s own answer skips that paraphrase entirely; ToolContext, for data a tool needs that the model must never see or supply; and Tool Search Advisor, which stops a 230-tool library from putting all 230 definitions in front of the model on every single call. Nothing below calls a real LLM. Every test in this article’s companion repo drives the real Spring AI advisor classes — the same ToolCallingAdvisor and ToolSearchToolCallingAdvisor a production app would use — against a hand-written ChatModel that returns a queued, pre-programmed response instead of calling an API. That sounds like it should be a weaker kind of proof. It’s the opposite: it means every number and every tool count in this article was read directly off the real advisor’s real behavior, not off documentation describing what it’s supposed to do.
Versions. Spring Boot 4.1.1 and Spring AI 2.0.1, on Java 25 (LTS) — the same baseline as the rest of this series. The Tool Search Advisor pieces (ToolSearchToolCallingAdvisor, RegexToolIndex) live in two separately versioned Maven Central artifacts, spring-ai-tool-search-advisor and spring-ai-tool-search-tool, both at 2.0.1 — confirmed against Maven Central’s own metadata, since neither is covered by spring-ai-bom and both need pinning explicitly in pom.xml.

The plain case: a tool, a round trip, an answer

The simplest possible tool is an ordinary method with an @Tool annotation and nothing else:
@Tool(name = "current_weather", description = "Get the current weather conditions for a named city.")
public WeatherReport currentWeather(@ToolParam(description = "The city name, e.g. Boston") String city) {
    return FIXTURE_DATA.get(city);
}
Source: WeatherTools.java. Ask a ChatClient wired with this tool “what’s the weather in Boston?” and two separate calls happen to the model, not one:
caller “what’s the weather in Boston?” model — call 1 replies with a tool call ToolCallingAdvisor executes current_weather model — call 2 gets the tool’s JSON, replies in words caller gets the final answer two model calls, one tool execution — the caller only ever sees the last one
The two calls are exactly what a captured run shows: the model’s first reply carries a tool call and nothing else, and only the second reply is prose.
model call count: 2

call 2 -- advisor sends the tool result back to the model:
  tool response: ToolResponse[id=call-1, name=current_weather, responseData={"city":"Boston","temperatureCelsius":14.5,"conditions":"Overcast","humidityPercent":71}]

final answer returned to the caller:
  It's 14.5C and overcast in Boston right now, with 71% humidity.
Output: 01-plain-tool-call-round-trip.txt. The class driving this loop is ToolCallingAdvisor, registered on the ChatClient like any other advisor:
ChatClient.builder(chatModel)
        .defaultToolCallbacks(toolLibrary)
        .defaultAdvisors(ToolCallingAdvisor.builder().build())
        .build();
Source: ChatClientFactory.java. That “advisor” word is doing more work than it looks like, and it’s the whole subject of the next section.

Going deeper on this section

What actually changed from Spring AI 1.x

In Spring AI 1.x, the call/execute/recall loop above lived inside each ChatModel implementation — OpenAiChatModel, AnthropicChatModel, and every other provider each had their own internal copy of essentially the same logic, invoking a ToolCallingManager from inside their own call()/stream() methods. Every provider integration had to get that loop right on its own, and a bug fix or a new capability in the loop meant touching every provider. In Spring AI 2.0, that logic moved out of every ChatModel and into ToolCallingAdvisor, which is confirmed to live entirely at the ChatClient advisor layer — the class implements CallAdvisor, StreamAdvisor, and ToolAdvisor, and its logic never touches a provider-specific type. One implementation drives the loop for every model.
Spring AI 1.x OpenAiChatModel — own tool loop AnthropicChatModel — own tool loop every other provider — own tool loop Spring AI 2.0 ToolCallingAdvisor one loop, at the ChatClient layer OpenAiChatModel.call(Prompt) AnthropicChatModel.call(Prompt) any ChatModel.call(Prompt) same shape this article’s tests exploit: any ChatModel, including a scripted one, drives the real advisor loop
That last line in the diagram is not incidental to how this repository is built — it’s the reason it can exist at all. Because ToolCallingAdvisor only depends on the plain ChatModel interface, and ChatModel has exactly one abstract method (call(Prompt) — confirmed with javap; every other method on the interface has a default implementation), a hand-written test double that queues canned responses is a legitimate, fully-real ChatModel from the advisor’s point of view, not a mock standing in for one.
public class ScriptedChatModel implements ChatModel {
    private final Queue<ChatResponse> script;

    @Override
    public ChatResponse call(Prompt prompt) {
        capturedPrompts.add(prompt);
        return script.poll();
    }
}
Source: ScriptedChatModel.java (trimmed — the real file also records every prompt it was called with, so a test can inspect exactly what the advisor sent on each round).
Going deeper: the one line that makes ScriptedChatModel actually work

ToolCallingAdvisor.adviseCall‘s very first check, confirmed by disassembling the class, is an instanceof ToolCallingChatOptions test on the outgoing request’s options — if it fails, the advisor passes the call straight through with no tool loop at all. That options object is built from chatModel.getOptions().mutate(), a separate default method from getDefaultOptions() that a first version of this repository’s ScriptedChatModel overrode instead, by mistake, and watched every test get back an empty answer after exactly one model call. Real providers return their own ToolCallingChatOptions implementation from getOptions(); ScriptedChatModel has to do the same thing explicitly.

Going deeper on this section

returnDirect: when the tool’s answer is the answer

Every round trip in the previous section exists so the model can turn a tool’s raw JSON into a sentence. Sometimes that’s wasted work — a status dashboard, a generated report, a confirmation payload is already the answer, and asking the model to paraphrase it just adds latency and a chance it states one of the numbers wrong. @Tool(returnDirect = true) tells the advisor to skip the second call entirely and hand the tool’s own return value straight back:
@Tool(name = "server_status", description = "Get the live status of an internal service.", returnDirect = true)
public ServerStatus serverStatus(@ToolParam(description = "The service name, e.g. orders-api") String serviceName) {
    return new ServerStatus(serviceName, "UP", ACTIVE_CONNECTIONS.get(serviceName), Instant.now(clock));
}
Source: ServerStatusTools.java. The test proving this isn’t just documentation queues exactly one scripted response on purpose: if the advisor tried to call the model a second time anyway, ScriptedChatModel would throw on an empty queue and the test would fail loudly, not pass by accident.
model call count: 1

raw content returned to the caller (the tool's own JSON, unparaphrased):
  {"service":"orders-api","status":"UP","activeConnections":214,"checkedAt":"2026-09-23T18:00:00Z"}
Output: 02-return-direct-skips-second-round.txt. Notice what the caller actually gets back: the tool’s raw, machine-shaped JSON, not a sentence. That’s the real trade-off, not just the extra latency — returnDirect is a statement that this particular tool’s output is fit to hand to whatever’s downstream of the model as-is, which is true for a dashboard payload and not true for most conversational answers.
returnDirect is a data-shape decision, not just a performance one. Reach for it when the tool’s return type is already what the caller wants to consume — a UI component render, a file, a structured payload another service parses. Reach for it less when a human is going to read the answer as prose; a model’s paraphrase is often exactly the value-add over the raw data.

Going deeper on this section

ToolContext: data the model must never see or choose

Some data a tool needs shouldn’t come from the model at all — the authenticated caller’s user ID, a tenant, a feature flag. Putting it in an ordinary @ToolParam means trusting the model to supply it correctly, which also means trusting it not to supply someone else’s. ToolContext is Spring AI’s way around that: a parameter the method declares by type, filled in by the caller’s own code, and never exposed to the model at all.
@Tool(name = "my_account", description = "Get the current user's own account summary.")
public AccountSummary myAccount(ToolContext toolContext) {
    Object userId = toolContext.getContext().get("userId");
    return ACCOUNTS.get(userId.toString());
}
Source: UserContextTools.java. The claim “the model never sees this parameter” is checkable directly, by printing the exact JSON schema the advisor builds for this tool:
my_account input schema sent to the model:
  {
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "object",
  "properties" : { },
  "required" : [ ],
  "additionalProperties" : false
}
Output: 03-tool-context-hidden-from-schema.txt. No userId property anywhere — the schema is recognized by type, not skipped by a naming convention. The model’s actual tool call arrives with empty arguments, and the tool still produces the right answer, because the caller supplied the real value out-of-band:
client.prompt()
        .user("What's my account tier?")
        .toolContext(Map.of("userId", "alice"))
        .call()
        .content();
Source: ToolContextNotExposedTest.java. This repository’s UserContextTools trusts whatever’s in that map outright, which is fine for a demo and wrong for production — a real deployment populates ToolContext from the authenticated principal on the server side, the same SecurityContextHolder pattern this series’ MCP security article uses, never from anything a client sends.

Going deeper on this section

A 230-tool library, and what it costs to just register them all

Every tool this far has been one of a handful. Real internal platforms don’t stay that way — an HR system, a finance system, a deploy pipeline, a CRM, a logistics system, and a security console each contribute a dozen or two tools, and the total climbs past a hundred fast. This repository generates 230 of them programmatically, across six fake business domains, with FunctionToolCallback.builder(name, Function) instead of 230 hand-written @Service classes:
FunctionToolCallback.builder(name, (SyntheticToolRequest request) -> verb + " " + nounPhrase + " ok")
        .description(description)
        .inputType(SyntheticToolRequest.class)
        .build();
Source: LargeToolLibrary.java. The exact count isn’t a round number chosen for the article — it’s whatever a test asserts the generator actually produces, broken down by domain:
total tools: 230

crm        40 tools
devops     38 tools
finance    40 tools
hr         36 tools
logistics  40 tools
security   36 tools
Output: 06-tool-library-size.txt. Register all 230 on a ChatClient with the plain ToolCallingAdvisor from earlier, and every one of those 230 names, descriptions, and JSON schemas goes into the model’s context on every single call — whether the question needs one of them or none of them. Measuring the actual text, not guessing at it:
total characters of name + description + input schema, ALL 230 tools: 69958
characters of name + description + input schema, toolSearchTool ONLY: 463
reduction on the first call of a conversation: 99.3%
Output: 05-tool-description-footprint.txt. Characters aren’t tokens, and this is only the first call of a conversation — the next section shows what gets added back once the model actually searches. But the shape of the problem is real: a library this size, offered in full on every call, is a lot of text spent on tools a given question will never touch.

Going deeper on this section

Tool Search Advisor: search first, call second

ToolSearchToolCallingAdvisor is the fix: instead of putting every registered tool’s definition in front of the model, it offers exactly one — a toolSearchTool that searches a ToolIndex built from the real registered library — plus a system-message instruction telling the model to search before it guesses.
ToolSearchToolCallingAdvisor.builder()
        .toolIndex(new RegexToolIndex())
        .maxResults(5)
        .sessionIdKeyName(ToolSearchTool.TOOL_SEARCH_TOOL_SESSION_ID_KEY)
        .systemMessageSuffix("Use the toolSearchTool to find the specific tool you need before calling it. "
                + "Do not guess a tool name that was not returned by a search.")
        .build();
Source: ChatClientFactory.java. RegexToolIndex is the deterministic, no-embedding-model implementation of ToolIndex — the other two real implementations found in the dependency, LuceneToolIndex and VectorToolIndex, need a search engine or an embedding model respectively; this article picks the one with nothing else to stand up.
call 1 — tools offered to the model: 1 [ toolSearchTool ] not the other 229 registered tools model searches query: “look up an invoice” against RegexToolIndex call 2 — tools offered to the model: 6 crm_lookup_support_ticket, finance_lookup_invoice, hr_lookup_employee_record, hr_lookup_open_requisition, hr_lookup_pto_balance, toolSearchTool the model calls finance_lookup_invoice — the one it actually needed
Every count in that diagram is read directly off ToolCallingChatOptions.getToolCallbacks() on the real outgoing prompt, not off a log line describing what the advisor is supposed to have done:
tools registered on the ChatClient: 230

tools OFFERED to the model on call 1 (before any search): 1
  [toolSearchTool]

tools OFFERED to the model on call 2 (after it searched for "look up an invoice"): 6
  [crm_lookup_support_ticket, finance_lookup_invoice, hr_lookup_employee_record, hr_lookup_open_requisition, hr_lookup_pto_balance, toolSearchTool]

final answer: Invoice INV-1001 was found in the finance system.
Output: 04-tool-search-progressive-disclosure.txt. One tool up front instead of 230 on the first call; six, not 230, on the second — and the model still finds and calls finance_lookup_invoice correctly. The session ID keying that search index into the advisor’s per-conversation cache doesn’t come from ChatClient.toolContext(Map) the way UserContextTools‘s data did two sections ago — it comes from a different mechanism entirely:
client.prompt()
        .user("Can you look up invoice INV-1001 for me?")
        .advisors(a -> a.param(ToolSearchTool.TOOL_SEARCH_TOOL_SESSION_ID_KEY, sessionId))
        .call()
        .content();
Source: ToolSearchProgressiveDisclosureTest.java. Disassembling ToolSearchToolCallingAdvisor.initializeSession shows exactly why: it reads the session ID off ChatClientRequest.context() — the advisor-param map that AdvisorSpec.param(...) populates — not off ToolCallingChatOptions.getToolContext(). The two context maps look interchangeable from the outside and are not.
Going deeper: why 6 tools came back, not 1

RegexToolIndex is honestly named — it converts the search query into a regular expression and matches it against tool names, descriptions, and parameter text, with no embedding model and no notion of semantic closeness. Searching “look up an invoice” matched every tool whose name or description contains “lookup” or “invoice”-adjacent terms, which is why hr_lookup_pto_balance and crm_lookup_support_ticket came back alongside the actually-relevant finance_lookup_invoice. That’s not a bug in this article’s setup; it’s the real, honest behavior of the deterministic index this article deliberately chose over VectorToolIndex, which would narrow this list using an embedding model instead of a verb-matching regex.

Going deeper on this section

Should you reach for progressive disclosure at all?

Not for the handful of tools most applications actually register. The plain ToolCallingAdvisor from the start of this article is simpler, has one fewer moving part, and costs nothing extra when a library is small enough that offering all of it fits comfortably in context — which is most applications, most of the time. Tool Search Advisor earns its complexity specifically at the scale this article’s synthetic library sits at: dozens of tools across several unrelated domains, where most of a given conversation’s tools are irrelevant to it.
The regex index is a starting point, not a destination. A tool library that grows past what verb-and-noun regex matching can tell apart on its own — several tools with genuinely similar names doing genuinely different things — is the point at which VectorToolIndex and an embedding model earn their keep over RegexToolIndex‘s determinism. Reach for the deterministic index while you can reason about exactly why a search matched; reach for the vector index once you can’t.

Every Spring AI article on this site

ArticleCovers
Spring AI 2.0 in 10 Minutes: ChatClient on Spring Boot 4.1the beginner-level ChatClient build this article’s versions and setup follow
Build an MCP Server with Spring AI 2.0exposing tools over MCP instead of calling them locally through a ChatClient
Spring AI MCP Client: Calling External MCP Servers from ChatClienttools that live in a separate process, registered the same way as a local @Tool method
Securing an MCP Server with Spring Security 7OAuth2 scopes per tool — where ToolContext‘s real userId should come from in production
Spring AI 1.x to 2.0: The Migration Guidewhat breaks, and what breaks silently, upgrading an existing 1.x application
Production-Grade RAG with Spring AIchunking, ingestion, retrieval, reranking, and a faithfulness check against pgvector
Spring AI RAG in Java: Complete Code Tourthe same RAG project, file by file

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.