DEV Community

Cover image for Building a Tool Engine with Spring AI — How We Gave Jarvis the Ability to Act in the World
Sujan Lamichhane
Sujan Lamichhane

Posted on

Building a Tool Engine with Spring AI — How We Gave Jarvis the Ability to Act in the World

From knowing to doing — Phase 4 of the Jarvis AI Platform

The Problem with Knowledge-Only AI

After Phase 3, Jarvis could remember you across sessions and search your documents.

But it still had a fundamental limitation.

You: "What is the weather in Kathmandu right now?"
Jarvis: "I don't have access to real-time weather data."

You: "What is 2847 × 391?"
Jarvis: "The answer is approximately 1.1 million." ← WRONG
Enter fullscreen mode Exit fullscreen mode

An AI that only knows things from training data is useful.

An AI that can do things is transformative.

That is what Phase 4 built.


What Is a Tool Engine?

A tool engine gives the AI model the ability to call real functions during a conversation.

The flow looks like this:

User: "What is the weather in Kathmandu?"
            ↓
        AI Model
            ↓
  "I should call WeatherTool"
            ↓
    WeatherTool.getWeather("Kathmandu")
            ↓
    "22°C, Clear sky, Humidity: 45%"
            ↓
        AI Model
            ↓
"The weather in Kathmandu is 22°C and clear."
Enter fullscreen mode Exit fullscreen mode

The key insight: the AI decides when to call a tool and with what input.

We don't hardcode "if user asks about weather, call WeatherTool."

The model figures that out from the tool descriptions we provide.


The Architecture Decision

The most important architectural decision in Phase 4 was the package structure.

ai.jarvis.tools/
├── JarvisTool.java           marker interface (root)
├── ToolRegistry.java         manages all tools (root)
├── builtin/                  built-in tools
   ├── DateTimeTool.java
   ├── CalculatorTool.java
   ├── WeatherTool.java
   └── WebSearchTool.java
└── mcp/                      MCP protocol
    └── McpServerConfig.java
Enter fullscreen mode Exit fullscreen mode

Why not put tools inside ai/?

The ai/ package handles HOW Jarvis talks to AI models.

Tools define WHAT Jarvis can do.

These are fundamentally different responsibilities.

Mixing them would mean every new tool requires changes to AI infrastructure code.

Keeping them, separate means adding a new tool requires exactly one file.


The JarvisTool Pattern

Every tool in Jarvis implements one interface.

/**
 * Marker interface for all Jarvis tools.
 * Spring auto-discovers all @Component implementations.
 * ToolRegistry collects them all automatically.
 * Adding a new tool = just add @Component.
 */
public interface JarvisTool {
    // Marker interface — no methods required
}
Enter fullscreen mode Exit fullscreen mode

This is the Strategy Pattern in its simplest form.

The @Tool annotation on methods tells Spring AI what each function does and when the AI should call it.

@Component
public class WeatherTool implements JarvisTool {

    @Tool(description =
            "Get current weather conditions for any city. "
                    + "Use when user asks about weather, "
                    + "temperature, or climate.")
    public String getWeather(
            @ToolParam(description = "City name in English")
            String city) {
        // Real OpenWeatherMap API call
        return "Kathmandu: 22°C, Clear sky";
    }
}
Enter fullscreen mode Exit fullscreen mode

Three things make this work well:

The description matters enormously.

The AI reads the description to decide whether to call this tool. A vague description like "Weather tool" produces unreliable results. A specific description that explains exactly when to use it produces consistently good results.

Never throw exceptions to the AI.

If the API is down, return a friendly error string. The AI can then tell the user something went wrong instead of crashing the entire session.

Return plain strings.

Every tool returns String. The AI handles formatting. Keep tools focused on data retrieval, not presentation.


The ToolRegistry

When Spring starts, it automatically discovers every class annotated with @Component that implements JarvisTool.

@Component
public class ToolRegistry {

    private final List<JarvisTool> tools;

    // Spring injects ALL JarvisTool beans automatically
    public ToolRegistry(List<JarvisTool> tools) {
        this.tools = Collections
                .unmodifiableList(tools);

        log.info("ToolRegistry: {} tools registered",
                tools.size());
    }

    public Object[] asArray() {
        return tools.toArray();
    }
}
Enter fullscreen mode Exit fullscreen mode

The OllamaProvider then passes these to the AI model:

@Override
public Flux<String> streamChat(Prompt prompt) {
    if (toolRegistry.hasTools()) {
        return chatClient
                .prompt(prompt)
                .tools(toolRegistry.asArray()) // ← inject tools
                .stream()
                .content();
    }
    return chatClient.prompt(prompt)
            .stream().content();
}
Enter fullscreen mode Exit fullscreen mode

The entire tool ecosystem is now self-registering.

When we added WeatherTool, zero changes were required to OllamaProvider, AiOrchestrator, or PromptAssembler.

We added one file. That was it.


The Four Built-in Tools

DateTimeTool

@Tool(description =
        "Get the current date and time. "
                + "Use when user asks what time or date it is.")
public String getCurrentDateTime() {
    return ZonedDateTime.now()
            .format(FULL_FORMATTER);
}

@Tool(description =
        "Get time in a specific timezone. "
                + "Use when user asks about time in a city.")
public String getCurrentTimeInZone(
        @ToolParam(description =
                "IANA timezone ID like America/New_York")
        String timezone) {
    ZoneId zoneId = ZoneId.of(timezone);
    return ZonedDateTime.now(zoneId)
            .format(FULL_FORMATTER);
}
Enter fullscreen mode Exit fullscreen mode

Before this tool, Jarvis knew the time because WorkingMemoryBuilderinjected it into every prompt.

But WorkingMemoryBuilder only injects the local server time once per request.

DateTimeTool lets the AI ask for ANY timezone ON DEMAND during the conversation.

CalculatorTool

AI models are notoriously bad at arithmetic.

GPT-4: "What is 2847 × 391?"
Response: "1,113,177" ← correct by luck

Llama 3.1 8B: "2847 × 391 ≈ 1,112,397" ← WRONG
Enter fullscreen mode Exit fullscreen mode

The CalculatorTool solves this completely.

@Tool(description =
        "Evaluate a mathematical expression. "
                + "Always use this instead of calculating yourself.")
public String calculate(String expression) {
    try {
        double result = new ExpressionBuilder(expression)
                .build()
                .evaluate();
        return formatResult(expression, result);
    } catch (Exception e) {
        return "Could not evaluate: " + expression;
    }
}
Enter fullscreen mode Exit fullscreen mode

We switched from the JavaScript ScriptEngine(Nashorn, removed in JDK 15+) to exp4j— a pure Java expression evaluator.

This was actually discovered by a contributor during Phase 4 testing. The original implementation used Nashorn which does not exist in Java 21.

The lesson: community testing finds real issues.

WeatherTool

@Tool(description =
        "Get current weather for any city. "
                + "Returns temperature, description, "
                + "humidity, and wind speed.")
public String getWeather(String city) {
    if (!isConfigured()) {
        return "Set OPENWEATHER_API_KEY in .env "
                + "to enable weather.";
    }
    // Real API call to OpenWeatherMap
}
Enter fullscreen mode Exit fullscreen mode

Two design decisions worth noting:

Graceful degradation. If the API key is not set, the tool returns a helpful setup message rather than failing. The AI can still respond intelligently: "Weather is not configured on this system, but I can tell you that Kathmandu generally has a temperate climate..."

@JsonProperty on API response records. OpenWeatherMap returns feels_like in snake_case. Without @JsonProperty("feels_like"), Jackson maps it incorrectly and feelsLike is always 0.0. A CodeRabbit review caught this before it reached production.

WebSearchTool

@Tool(description =
        "Search the web for current information. "
                + "Use when you need information beyond "
                + "your training data.")
public String search(String query) {
    // DuckDuckGo Instant Answer API
    // FREE — no API key needed
    // Privacy-respecting
}
Enter fullscreen mode Exit fullscreen mode

DuckDuckGo was the right choice here for three reasons:

  • It is free. No API key, no rate limits for reasonable usage.
  • It is private. Fits the Jarvis local-first philosophy. Your searches don't feed into advertising profiles.
  • It returns structured data. The Instant Answer API provides AbstractText, Answer, and RelatedTopics in JSON — easy to parse and summarize.
  • One CodeRabbit catch: DuckDuckGo returns PascalCase field names (AbstractText, AbstractURL, RelatedTopics) but Jackson defaults to camelCase. Every field was deserializing as null until we added @JsonProperty annotations to all response record fields.

MCP Server — Jarvis as a Tool Provider

The Model Context Protocol (MCP) is an open standard that allows AI systems to share tools with each other.

After building our four built-in tools, we exposed them as an MCP server:

@Configuration
@RequiredArgsConstructor
public class McpServerConfig {

    private final ToolRegistry toolRegistry;

    @Bean
    public ToolCallbackProvider jarvisToolCallbacks() {
        return MethodToolCallbackProvider
                .builder()
                .toolObjects(toolRegistry.asArray())
                .build();
    }
}
Enter fullscreen mode Exit fullscreen mode

What this means:

External AI clients — Claude Desktop, VS Code AI extensions, any MCP-compatible client — can now connect to Jarvis and use its tools.

Your locally-running WeatherTool, CalculatorTool, and WebSearchTool become available to Claude, GPT, or any other AI that speaks MCP.

This was one line of configuration.

The payoff of building JarvisTool as a clean abstraction from the start.


Parallel Context Loading

One concern with adding more context sources (Phase 2: memories, Phase 3: RAG, Phase 4: tools) was performance.

Loading everything sequentially would add seconds to every request.

The solution was Mono.zip:

.then(
    Mono.zip(
        // All three load SIMULTANEOUSLY
        sessionMemoryService.loadHistory(sessionId),
        memoryService.formatForPrompt(userId, message),
        ragSearchService.formatForPrompt(userId, message)
    )
)
Enter fullscreen mode Exit fullscreen mode

Sequential: 1ms + 20ms + 20ms = 41ms

Parallel: max(1ms, 20ms, 20ms) = 20ms

50% latency reduction for context loading at zero cost.


What We Learned

Tool descriptions are prompts.

The description you write for a @Tool method is essentially a micro-prompt. Spend as much time on descriptions as on the implementation. A well-written description means the AI uses the tool correctly every time. A vague one means unpredictable behavior.

Never let tools throw exceptions.

Every @Tool method should be wrapped in try-catch. If a tool fails, return a useful error string. The AI should always receive a String response it can reason about.

The Open/Closed Principle pays off.

Adding WeatherTool required zero changes to existing code. The @Componentannotation, JarvisTool interface, and ToolRegistry auto-discovery made this possible. This is exactly why separating tool concerns from AI infrastructure concerns was the right call.

*Community review catches real bugs.
*

The @JsonProperty issues in WeatherTool and WebSearchTool were caught by CodeRabbit. The Nashorn/exp4j issue was caught by a contributor's integration tests. Both would have caused silent failures in production. Code review — automated and human — is essential.


Results

After Phase 4, a conversation with Jarvis looks like this:

You: What time is it in Tokyo, and what's
     the weather there?

Jarvis: [calls getCurrentTimeInZone("Asia/Tokyo")]
        It is currently 3:45 PM JST in Tokyo.

        [calls getWeather("Tokyo")]
        The weather in Tokyo is 28°C with
        partly cloudy skies and humidity at 65%.

        It is a warm afternoon in Tokyo right now.
Enter fullscreen mode Exit fullscreen mode

Two tool calls, two real-time data sources, one coherent answer.

No Python. No LangChain. Pure Java and Spring AI.


What's Next

Phase 5 added voice — Whisper transcription via Groq API and cross-platform text-to-speech.

Phase 6 built the Agent System — a full ReACT loop where Jarvis can plan and execute multi-step tasks using these exact tools.

Phase 7 is coming: a web UI where you can interact with all of this from a browser.

The foundation is solid. The tools are ready. The agents are running.


Contributing

Jarvis is open source under Apache 2.0.

If you want to add a tool, it is genuinely this simple:

@Component
public class MyTool implements JarvisTool {

    @Tool(description = "What this does and when to use it")
    public String doSomething(String input) {
        // your implementation
        return result;
    }
}
Enter fullscreen mode Exit fullscreen mode

That is the entire contribution. One file. One interface. Automatic registration.

GitHub: github.com/sujankim/jarvis-ai-platform
Enter fullscreen mode Exit fullscreen mode

Good first issues are labeled and waiting.


Part 5: Adding Voice to a Java AI Assistant — Whisper, TTS, and the voice conversation loop.

Your AI. Your Data. Your Machine.

Top comments (0)