DEV Community

Cover image for Teaching Your Spring Boot App to Think: LLM Tool Calling with Groq and Spring AI
Misbah Ulhaq
Misbah Ulhaq

Posted on

Teaching Your Spring Boot App to Think: LLM Tool Calling with Groq and Spring AI

Large language models are powerful, but they become much more useful when they can work with real application data.

That is where tool calling comes in.

Tool calling allows an LLM to call backend functions, APIs, or services before producing its final answer. Instead of guessing about live data, the model can ask your application for the information it needs, receive the result, and then respond with better context.

In this tutorial, we will build a small Spring Boot application where a user can ask a question such as:

Should I go out today in Ashburn, VA?

The application will let the LLM call Java methods to fetch current weather and top news headlines, then combine both results into a practical answer.

Note: This article uses Spring AI 1.0.0-M1 with Groq through Spring AI's OpenAI-compatible configuration. Spring AI has continued evolving, so if you are using a newer Spring AI version, check the current Tools API documentation and adjust the .functions(...) usage accordingly.


What We Are Building

We are building a simple chat application with:

  • A Spring Boot backend
  • A vanilla HTML/JavaScript frontend
  • A Groq-hosted LLM
  • Two Java tools:
    • get_weather
    • get_news

The flow looks like this:

  1. The user sends a message from the browser.
  2. Spring Boot forwards the message to the LLM.
  3. The LLM decides whether it needs weather or news data.
  4. Spring AI invokes the matching Java function.
  5. The tool result is sent back to the LLM.
  6. The LLM generates the final answer for the user.

Here is the homepage:

Homepage


Prerequisites

You will need:

  • Java 21
  • Maven 3.9 or later
  • A Groq API key
  • Basic Spring Boot knowledge

Groq provides an OpenAI-compatible API, which makes it possible to configure Spring AI's OpenAI integration to call Groq instead of OpenAI.


Project Structure

java-mcp-weather-news/
├── pom.xml
└── src/main/
    ├── java/com/mulhaq/mcp/
    │   ├── McpWeatherNewsApplication.java
    │   ├── config/
    │   │   └── GroqConfig.java
    │   ├── tools/
    │   │   ├── WeatherService.java
    │   │   └── NewsService.java
    │   └── controller/
    │       └── ChatController.java
    └── resources/
        ├── application.properties
        └── static/
            └── index.html
Enter fullscreen mode Exit fullscreen mode

How Tool Registration Works

In this version of the project, the tools are registered as Spring beans using Java's Function<Request, Response> interface.

The basic pattern is:

  1. Create a Java function that performs a useful backend action.
  2. Register that function as a Spring @Bean.
  3. Add a clear @Description.
  4. Reference the tool by name when calling the ChatClient.

Example:

chatClient.prompt()
    .system(systemPrompt)
    .user(message)
    .functions("get_weather", "get_news")
    .call()
    .content();
Enter fullscreen mode Exit fullscreen mode

Spring AI sends the tool definitions to the model. If the model decides that it needs live data, it returns a tool call. Spring AI then invokes the matching Java function, sends the tool result back to the model, and receives the final response.

The important idea is that the LLM does not directly call external services. Your backend controls which tools exist, what they can do, and what data they return.


WeatherService.java

The weather service fetches current weather from wttr.in. This keeps the demo simple because no separate weather API key is required.

package com.mulhaq.mcp.tools;

import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Service for fetching real-time weather data from wttr.in.
 * No API key is required.
 */
@Service
public class WeatherService {

    private static final Logger log = LoggerFactory.getLogger(WeatherService.class);
    private static final String WTTR_URL = "https://wttr.in/%s?format=j1";

    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;

    public WeatherService(RestTemplate restTemplate, ObjectMapper objectMapper) {
        this.restTemplate = restTemplate;
        this.objectMapper = objectMapper;
    }

    public String getWeather(String city) {
        try {
            String url = String.format(WTTR_URL, city.replace(" ", "+"));
            String json = restTemplate.getForObject(url, String.class);

            JsonNode root = objectMapper.readTree(json);
            JsonNode current = root.path("current_condition").get(0);
            JsonNode area = root.path("nearest_area").get(0);

            String condition = current.path("weatherDesc").get(0).path("value").asText("Unknown");
            int tempC = current.path("temp_C").asInt(0);
            int tempF = current.path("temp_F").asInt(0);
            int windKph = current.path("windspeedKmph").asInt(0);
            int humidity = current.path("humidity").asInt(0);
            String areaName = area.path("areaName").get(0).path("value").asText(city);
            String country = area.path("country").get(0).path("value").asText("");

            return String.format(
                "Weather in %s%s: %s, %d°C (%d°F), Wind: %d km/h, Humidity: %d%%",
                areaName,
                country.isEmpty() ? "" : ", " + country,
                condition,
                tempC,
                tempF,
                windKph,
                humidity
            );
        } catch (Exception e) {
            log.error("Error fetching weather for {}: {}", city, e.getMessage());
            return "Unable to fetch weather for " + city + ": " + e.getMessage();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This service returns a short, human-readable summary. That format is intentional: the LLM does not need the full raw JSON response. It only needs the useful weather facts.


NewsService.java

The news service fetches the top five headlines from the BBC News RSS feed.

package com.mulhaq.mcp.tools;

import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.*;
import javax.xml.parsers.*;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

/**
 * Service for fetching top news headlines from the BBC News RSS feed.
 * Uses Java's DOM parser to handle RSS content reliably.
 */
@Service
public class NewsService {

    private static final Logger log = LoggerFactory.getLogger(NewsService.class);
    private static final String BBC_RSS = "https://feeds.bbci.co.uk/news/rss.xml";

    private final RestTemplate restTemplate;

    public NewsService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    public String getTopNews() {
        try {
            String xml = restTemplate.getForObject(BBC_RSS, String.class);

            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
            factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);

            DocumentBuilder docBuilder = factory.newDocumentBuilder();
            Document doc = docBuilder.parse(
                new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))
            );

            doc.getDocumentElement().normalize();

            NodeList items = doc.getElementsByTagName("item");
            int limit = Math.min(5, items.getLength());

            List<String> headlines = new ArrayList<>();
            for (int i = 0; i < limit; i++) {
                Element item = (Element) items.item(i);
                String title = getTagText(item, "title");
                String description = getTagText(item, "description");

                StringBuilder entry = new StringBuilder((i + 1) + ". " + title);

                if (description != null && !description.isBlank()) {
                    String clean = description.replaceAll("<[^>]*>", "").trim();
                    if (clean.length() > 120) {
                        clean = clean.substring(0, 120) + "...";
                    }
                    entry.append("\n   ").append(clean);
                }

                headlines.add(entry.toString());
            }

            log.info("Fetched {} news headlines", limit);
            return "Top News Headlines:\n" + String.join("\n", headlines);

        } catch (Exception e) {
            log.error("Error fetching news: {}", e.getMessage());
            return "Unable to fetch news: " + e.getMessage();
        }
    }

    private String getTagText(Element parent, String tagName) {
        NodeList nodes = parent.getElementsByTagName(tagName);
        return nodes.getLength() > 0 ? nodes.item(0).getTextContent().trim() : null;
    }
}
Enter fullscreen mode Exit fullscreen mode

I used Java's built-in DOM parser here because RSS feeds can contain CDATA and HTML-like text. For this small use case, the DOM parser is simple and reliable.

One security detail is important: external XML entities are disabled. This helps reduce the risk of XML External Entity issues when parsing XML from an external source.


GroqConfig.java

This configuration class wires together the ChatClient, RestTemplate, and the two tool functions.

package com.mulhaq.mcp.config;

import com.mulhaq.mcp.tools.WeatherService;
import com.mulhaq.mcp.tools.NewsService;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Description;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.util.function.Function;

@Configuration
public class GroqConfig {

    /**
     * Tolerant ObjectMapper that ignores unknown JSON fields.
     * This is useful when using OpenAI-compatible providers that may
     * return additional response fields.
     */
    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    }

    /**
     * Injects the tolerant ObjectMapper into Spring AI's internal RestClient.
     */
    @Bean
    public RestClientCustomizer restClientCustomizer(ObjectMapper mapper) {
        return builder -> builder
                .messageConverters(converters -> {
                    converters.removeIf(c -> c instanceof MappingJackson2HttpMessageConverter);
                    converters.add(0, new MappingJackson2HttpMessageConverter(mapper));
                });
    }

    @Bean
    public ChatClient chatClient(ChatClient.Builder builder) {
        return builder.build();
    }

    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder builder, ObjectMapper mapper) {
        return builder
                .setConnectTimeout(Duration.ofSeconds(10))
                .setReadTimeout(Duration.ofSeconds(10))
                .additionalMessageConverters(new MappingJackson2HttpMessageConverter(mapper))
                .build();
    }

    @Bean
    @Description("Get current weather for a city. Input: city name, for example 'Ashburn, VA'. Returns temperature, condition, wind speed, and humidity.")
    public Function<WeatherRequest, String> get_weather(WeatherService weatherService) {
        return request -> weatherService.getWeather(request.city());
    }

    @Bean
    @Description("Get the top 5 current news headlines from BBC News. No input is required.")
    public Function<NewsRequest, String> get_news(NewsService newsService) {
        return request -> newsService.getTopNews();
    }

    public record WeatherRequest(
        @com.fasterxml.jackson.annotation.JsonProperty(required = true, value = "city")
        String city
    ) {}

    public record NewsRequest() {}
}
Enter fullscreen mode Exit fullscreen mode

The @Description text matters more than it may seem. It tells the model when and how to use the tool. A vague description can cause the model to skip the tool or call it with the wrong input.


ChatController.java

The controller exposes one endpoint: POST /api/chat.

package com.mulhaq.mcp.controller;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;

/**
 * POST /api/chat
 * Accepts a user message and returns an AI-generated response.
 */
@RestController
@RequestMapping("/api")
@CrossOrigin(origins = "*")
public class ChatController {

    private static final Logger log = LoggerFactory.getLogger(ChatController.class);
    private final ChatClient chatClient;

    public ChatController(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    @PostMapping("/chat")
    public Map<String, String> chat(@RequestBody Map<String, String> request) {
        String message = request.get("message");
        log.debug("Chat request: {}", message);

        try {
            String systemPrompt = """
                You are a helpful assistant with access to real-time weather and news data.
                When the user asks about weather, call the get_weather tool with the city name.
                When the user asks about news, call the get_news tool.
                When a combined decision is needed, such as "Should I go out today?",
                call both tools and reason over the results.
                Be concise, practical, and friendly.
                """;

            String response = chatClient.prompt()
                    .system(systemPrompt)
                    .user(message)
                    .functions("get_weather", "get_news")
                    .call()
                    .content();

            return Map.of("response", response);

        } catch (Exception e) {
            log.error("Error while processing chat request: {}", e.getMessage(), e);
            return Map.of("response", "Sorry, something went wrong: " + e.getMessage());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The key line is:

.functions("get_weather", "get_news")
Enter fullscreen mode Exit fullscreen mode

Those names match the bean names from GroqConfig. Spring AI uses them to expose the functions as callable tools for the model.

For a production app, I would not keep @CrossOrigin(origins = "*"). It is acceptable for a local demo, but production systems should restrict allowed origins.


application.properties

spring.ai.openai.api-key=YOUR_GROQ_API_KEY_HERE
spring.ai.openai.base-url=https://api.groq.com/openai
spring.ai.openai.chat.options.model=llama-3.3-70b-versatile

server.port=8080

logging.level.root=INFO
logging.level.com.mulhaq.mcp=DEBUG
Enter fullscreen mode Exit fullscreen mode

Do not commit your real API key. Use an environment variable or secret manager when deploying the application.

Also note the base URL:

spring.ai.openai.base-url=https://api.groq.com/openai
Enter fullscreen mode Exit fullscreen mode

Spring AI appends the remaining OpenAI-compatible path internally. If you add /v1 here in this Spring AI setup, you may run into a 404 because the final URL can become incorrect.


Frontend

The frontend is intentionally simple. It lives in:

src/main/resources/static/index.html
Enter fullscreen mode Exit fullscreen mode

It sends a request like this:

{
  "message": "Should I go out today in Ashburn, VA?"
}
Enter fullscreen mode Exit fullscreen mode

to:

POST /api/chat
Enter fullscreen mode Exit fullscreen mode

Spring Boot serves the page directly, so no React, Angular, or separate frontend build is needed.

You can find the full UI code in the repository:

https://github.com/mulhaq/blogs/tree/main/java-mcp-weather-news
Enter fullscreen mode Exit fullscreen mode

Demo

Type your question:

Question typed

The AI responds using real tool results:

AI response

Behind the scenes, this happens in one request:

  1. The browser sends the user message to POST /api/chat.
  2. Spring AI sends the message and tool definitions to Groq.
  3. Groq returns tool calls such as get_weather("Ashburn, VA") and get_news().
  4. Spring AI invokes the Java methods.
  5. The tool results are sent back to Groq.
  6. Groq generates the final answer.
  7. The response is returned to the browser.

Problems I Hit While Building This

Problem 1: Groq Returned Extra Fields

I saw an error similar to this:

UnrecognizedPropertyException: Unrecognized field "queue_time"
(class org.springframework.ai.openai.api.OpenAiApi$Usage)
Enter fullscreen mode Exit fullscreen mode

Groq may return response fields that are not declared in Spring AI's OpenAI response model for this version. Jackson rejects unknown fields unless configured otherwise.

The fix was to register a custom ObjectMapper with:

.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
Enter fullscreen mode Exit fullscreen mode

and apply it to the message converter used by Spring AI's internal client.

Problem 2: RSS Parsing Failed with XmlMapper

I also saw an RSS parsing issue similar to this:

Cannot construct instance of RssItem: no String-argument constructor
Enter fullscreen mode Exit fullscreen mode

The simpler fix was to use Java's DOM parser instead of forcing the RSS feed into a strict Jackson XML object model.

For this demo, the DOM parser was enough because I only needed a few fields from the RSS feed.


Production Improvements

This demo is intentionally small, but the same pattern can be extended. Before using this in production, I would improve the following areas:

1. Replace demo APIs with reliable providers

wttr.in and public RSS feeds are good for demos. A production app should use reliable APIs with rate limits, uptime guarantees, and predictable response formats.

2. Validate tool inputs

The model may call a tool with missing or unclear input. Validate city names, limit input length, and return safe error messages.

3. Add timeouts and retries

The current code has timeouts, but production systems should also include retries, circuit breakers, and clear fallback behavior.

4. Restrict CORS

@CrossOrigin(origins = "*") should not be used in production. Restrict it to your frontend domain.

5. Add observability

Log tool calls, latency, failure rates, and model responses. This helps debug tool behavior and understand when the model is using tools correctly.

6. Upgrade carefully

Spring AI has been evolving quickly. If you move from this M1 version to a newer release, review the current Tools API and migration notes before copying this code directly.


Ideas to Extend This Project

You can add more tools, such as:

  • Stock price lookup
  • Calendar availability
  • Internal database search
  • Customer profile lookup
  • Order status lookup
  • CRM updates

The pattern stays the same: expose a controlled Java method as a tool, describe it clearly, and let the LLM decide when it needs to call it.


Conclusion

Tool calling is one of the most useful patterns for building real-world LLM applications.

In this project, we built a Spring Boot app where the model can call Java methods to fetch live weather and news data before answering the user. This makes the application more useful than a normal chatbot because the model can reason over current data.

The biggest lesson is that tool calling is not only an AI feature. It is also a backend architecture pattern. Your application must define safe tools, validate inputs, handle errors, and control what the model can access.

Full source code:

https://github.com/mulhaq/blogs/tree/main/java-mcp-weather-news
Enter fullscreen mode Exit fullscreen mode

Run it locally:

git clone https://github.com/mulhaq/blogs.git
cd blogs/java-mcp-weather-news

# Edit application.properties and add your Groq API key
mvn spring-boot:run
Enter fullscreen mode Exit fullscreen mode

Then open:

http://localhost:8080
Enter fullscreen mode Exit fullscreen mode

Happy building.

Top comments (0)