Spring AI Tool Calling: From Model Request to Java Method

Trace Spring AI tool calling from model request to Java execution. Test rejection, permission checks, repeated calls, and idempotency with a local example.

When a model returns a tool call, three separate decisions follow: should the request be accepted, is this caller allowed to use the tool, and what happens if the same request arrives again?

This article uses Spring AI 2.0.1 and a scripted local model to trace one order-status request from structured tool call to Java method and back to the model.

The useful boundary is application-side execution. The model proposes a tool name and arguments; Spring AI resolves a callback; the service decides whether the operation is permitted and how its result is returned.

The first fixture is read-only so the round trip stays deterministic. The later cases use the same boundary to show rejection, permission checks and duplicate execution.

The Question: Who Actually Executes the Method?

A tool-capable model can return something conceptually equivalent to:

{
  "id": "call-1",
  "name": "orderStatus",
  "arguments": {
    "orderId": "ORD-42"
  }
}

This is a simplified explanation of the fields, not a provider-specific HTTP payload. In Spring AI’s AssistantMessage.ToolCall, the arguments are represented as a JSON string.

The model has proposed an operation. No order lookup has happened merely because that proposal exists.

Execution happens inside the application when a registered callback is selected and invoked. This is where application permissions, parameter validation and service behavior must remain effective.

The Source Landmarks

Source Area to inspect Responsibility
DefaultChatClient autoRegisterToolCallingAdvisor() Adds the tool-loop advisor unless disabled or already supplied
ToolCallingAdvisor adviseCall() Repeats downstream model calls and handles tool results
DefaultToolCallingManager executeToolCalls() and callback selection Resolves and executes the requested callback
MethodToolCallback call(), argument building and method invocation Converts arguments, invokes Java and converts the result

The version matters. This article describes the 2.0.1 advisor-based loop; do not apply its class-level execution map unchanged to a 1.x application.

Follow One Complete Interaction

The model and the Java tool have different jobs. The model requests an operation; application-side code executes it. This diagram shows the successful path used in our example.

Diagram showing a Spring AI tool call moving from a model request to Java execution and back

Figure 1. One client interaction makes two model calls and one Java tool invocation. This simplified synchronous flow excludes direct-return tools, retries and failures.

Read the diagram from top to bottom. There is no direct model-to-Java connection. Spring AI processes the structured request and returns the tool result to the model.

Define a Read-Only Tool with an Observable Effect

The example tool increments a counter so the test can prove that Java execution occurred:

static final class OrderTools {
    int calls;

    @Tool(description = "Read the status of an illustrative order")
    public String orderStatus(String orderId) {
        calls++;
        if (!"ORD-42".equals(orderId)) {
            throw new IllegalArgumentException("Unknown example order");
        }
        return "PAID";
    }
}

The counter is test instrumentation. The hardcoded order is also a fixture, not an example of production authorization or database access.

The example project compiles with Java parameter names enabled so that orderId is available when constructing and invoking the method-based tool.

What .tools(tools) Registers

An annotated method has two relevant representations:

Representation Purpose
Tool definition Describes the tool name, purpose and expected arguments for the model integration
Tool callback Holds the application-side behavior that can execute the operation

In the object-based registration path, DefaultChatClient delegates annotated objects to ToolCallbacks.from(...). MethodToolCallbackProvider discovers eligible annotated methods and builds callbacks containing the method, target object, definition, metadata and result converter. ToolCallbacks source, MethodToolCallbackProvider source

The model integration exposes a tool description and argument schema; it does not send an executable Java object to the model. Merely adding @Tool somewhere in the codebase also does not demonstrate that a particular request has registered that object.

With a real tool-capable model, the descriptions help it decide whether to request a tool. Registration alone does not guarantee that it will do so.

Script the Model’s Two Responses

On the first invocation, the local model returns a tool call. On the second, it returns a final assistant message:

List<Prompt> requests = new ArrayList<>();

ChatModel model = new ChatModel() {
    @Override
    public ChatOptions getOptions() {
        return ToolCallingChatOptions.builder().build();
    }

    @Override
    public ChatResponse call(Prompt prompt) {
        requests.add(prompt);

        AssistantMessage output = requests.size() == 1
                ? AssistantMessage.builder()
                    .content("")
                    .toolCalls(List.of(new AssistantMessage.ToolCall(
                            "call-1", "function", "orderStatus",
                            "{\"orderId\":\"ORD-42\"}")))
                    .build()
                : new AssistantMessage("Order ORD-42 is paid.");

        return new ChatResponse(List.of(new Generation(output)));
    }
};

The model returns tool-capable options because the advisor checks the prompt’s options type. A generic test double with only plain chat options does not exercise this tool loop.

This scripted model does not decide whether a tool is appropriate. That decision is fixed in the fixture so the application-side round trip can be tested deterministically.

Run One Client Interaction

Register the tool object and submit the question:

var tools = new OrderTools();

String answer = ChatClient.builder(model)
        .build()
        .prompt()
        .options(ToolCallingChatOptions.builder())
        .tools(tools)
        .user("What is the status of ORD-42?")
        .call()
        .content();

In the 2.0.1 API used here, the request-level options(...) call receives an options builder. The test model also supplies a tool-capable options type through getOptions().

The executed assertions established:

Client interactions:       1
ChatModel.call invocations: 2
orderStatus invocations:   1
Tool response ID:          call-1
Final content:             Order ORD-42 is paid.

The second model request contained a ToolResponseMessage whose result included PAID. Its ID matched the model’s call-1 request.

That is the important evidence. The final sentence was scripted, so it is not proof that a real model will faithfully describe any tool result. The test verifies dispatch, Java execution and the return path.

Inspect the Messages on the Second Model Call

The useful evidence is not just the final answer. In this fixture, the second prompt contains this message sequence:

USER
  What is the status of ORD-42?

ASSISTANT — tool request
  id: call-1
  name: orderStatus
  arguments: {"orderId":"ORD-42"}

TOOL — tool result
  id: call-1
  name: orderStatus
  result includes: PAID

This is a readable summary of Spring AI message objects, not an exact provider payload. There is no conversation-memory advisor in this fixture; the tool loop carries the messages needed for the current interaction.

The matching ID associates a result with its tool request. The assistant tool-request message and the tool-result message play distinct roles, even if a logging system flattens both into strings.

When debugging, check both the recorded invocation and the next prompt. A correct Java return value does not help generation if a custom integration fails to carry it back into the model conversation.

Trace the Source Path

The interaction has five distinct steps:

  1. The model receives the initial question and configured tools.
  2. It returns an assistant message requesting orderStatus.
  3. The tool manager selects the registered callback; the method callback converts the JSON argument and invokes Java.
  4. The framework adds the assistant tool request and tool response to the conversation used for the next round.
  5. The model receives that tool result and returns a final assistant message.

At the invocation boundary, MethodToolCallback.call() parses the JSON input, builds arguments for the Java parameters, invokes the target method and converts its return value to the tool-result representation. That conversion is distinct from the later model-generated answer. MethodToolCallback source

The model never executes Java bytecode. The backend does, under the authority available to the application and the invoked service.

The ordinary loop continues until a response has no tool calls. Other branches exist: a tool can be configured for direct return, and configured tool-call limits can stop further execution. Those branches are source-reviewed here but are not exercised by this two-round test.

Reject a Tool Call Before Execution

A tool request is a proposal, not an instruction that must run. If the callback is missing, the arguments fail validation or policy denies the operation, the application should stop at the execution boundary and return a structured result.

The following wrapper is application code, not a Spring AI type. It makes a rejection visible in a test:

record ToolOutcome(String status, String code, String message) {}

ToolOutcome readOrder(String actorId, String tenantId, String orderId) {
    if (!permissionService.canReadOrder(actorId, tenantId, orderId)) {
        return new ToolOutcome(
                "rejected", "ORDER_FORBIDDEN",
                "The caller cannot read this order");
    }
    return new ToolOutcome(
            "ok", "ORDER_STATUS",
            orderService.status(tenantId, orderId));
}

The model may receive the rejection as tool-result data and explain it, but it cannot turn a denied result into a successful lookup. Keep stack traces, SQL and internal customer data out of that result.

For an unknown tool name or malformed JSON, record the failure and stop the loop according to product policy. The test should prove that the callback did not run:

assertEquals(0, tools.calls);
assertEquals("ORDER_FORBIDDEN", outcome.code());

Permission Check Case: Same Tool, Different Caller

Registering orderStatus does not grant every caller access to every order. Resolve the actor and tenant from trusted application context, then pass those values into the service check. Do not accept a model-produced customerId or a sentence claiming that the caller is an administrator as proof of permission.

String actorId = requestContext.requiredUserId();
String tenantId = requestContext.requiredTenantId();
String requestedOrder = toolArguments.orderId();

ToolOutcome outcome = readOrder(
        actorId, tenantId, requestedOrder);
Caller Order Expected decision
user-7 / tenant-acme ORD-42 in tenant-acme allow
user-8 / tenant-acme ORD-42 owned by user-7 reject with ORDER_FORBIDDEN
user-7 / tenant-beta ORD-42 in tenant-acme reject with TENANT_MISMATCH

Log the decision with a request ID, actor, tenant, tool name and stable reason code. Return only the fields authorized for the caller. These checks belong in the service or policy layer, so they still apply when the tool is invoked by a retry, a different advisor or a non-chat client.

Duplicate Execution: Replay the Same Tool Call

A single client interaction can contain multiple model rounds, and a network retry can replay a tool request after the first execution has already completed. The visible symptom is simple: the same business operation appears twice even though the user clicked once.

Use a business idempotency key at the service boundary. This small test double shows the decision; a production implementation needs a durable store or a database uniqueness constraint.

Set<String> processed = ConcurrentHashMap.newKeySet();
AtomicInteger writes = new AtomicInteger();

String executeOnce(String tenantId, String requestId,
                   Supplier<String> action) {
    String key = tenantId + ":" + requestId;
    if (!processed.add(key)) {
        return "DUPLICATE_SKIPPED";
    }
    writes.incrementAndGet();
    return action.get();
}
assertEquals("PAID", executeOnce(
        "tenant-acme", "status-42", () -> "PAID"));
assertEquals("DUPLICATE_SKIPPED", executeOnce(
        "tenant-acme", "status-42", () -> "PAID"));
assertEquals(1, writes.get());

The model’s tool-call ID links a request to its tool response inside one conversation. It is useful for tracing, but it should not be treated as a durable business idempotency key across retries, process restarts or regenerated conversations. For refunds, credits or other writes, persist the key with the business result before acknowledging completion.

Advisor Placement Affects What Repeats

The tool advisor invokes a copy of the downstream chain for model rounds. An advisor after the tool-loop advisor can therefore run more than once during one client interaction.

This matters for retrieval cost, diagnostic logs and stateful processing. A logger placed inside the loop can show each model round; a logger outside may see only the overall interaction.

The default memory/tool relationship is also intentional. Changing it can alter which intermediate messages are handled by memory. Review those choices with a tool-containing request rather than assuming the simple request trace from the previous Advisor article is the entire interaction.

Diagnose the Correct Failure

Symptom First check
The model describes a lookup but no Java method runs Was there an actual structured tool call?
A tool call appears but is not executed Tool-capable options, loop configuration and callback registration
A callback cannot be found Requested name versus available callback names
Argument conversion fails JSON shape, Java parameter types and parameter names
Java returns data but the final answer is wrong Tool-response content and subsequent model response
An operation happens twice Multiple rounds, retries and business idempotency

Our local test covers the successful synchronous round trip. It does not test provider tool selection, streaming, concurrency, persistent side effects or authorization integration.

Run the Companion Example

The companion Maven project contains the complete imports and one focused JUnit test. It uses Java 17 and the following Spring AI dependency:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-client-chat</artifactId>
    <version>2.0.1</version>
</dependency>

The POM enables Java parameter names and supplies the test dependencies. From its example directory, run:

mvn test

No provider API key, Spring Boot application or external database is required. The fixture verifies the successful framework round trip; it does not evaluate whether a real model chooses the right tool or produces an accurate final explanation.