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_weatherget_news
The flow looks like this:
- The user sends a message from the browser.
- Spring Boot forwards the message to the LLM.
- The LLM decides whether it needs weather or news data.
- Spring AI invokes the matching Java function.
- The tool result is sent back to the LLM.
- The LLM generates the final answer for the user.
Here is the 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
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:
- Create a Java function that performs a useful backend action.
- Register that function as a Spring
@Bean. - Add a clear
@Description. - Reference the tool by name when calling the
ChatClient.
Example:
chatClient.prompt()
.system(systemPrompt)
.user(message)
.functions("get_weather", "get_news")
.call()
.content();
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();
}
}
}
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;
}
}
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() {}
}
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());
}
}
}
The key line is:
.functions("get_weather", "get_news")
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
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
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
It sends a request like this:
{
"message": "Should I go out today in Ashburn, VA?"
}
to:
POST /api/chat
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
Demo
Type your question:
The AI responds using real tool results:
Behind the scenes, this happens in one request:
- The browser sends the user message to
POST /api/chat. - Spring AI sends the message and tool definitions to Groq.
- Groq returns tool calls such as
get_weather("Ashburn, VA")andget_news(). - Spring AI invokes the Java methods.
- The tool results are sent back to Groq.
- Groq generates the final answer.
- 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)
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)
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
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
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
Then open:
http://localhost:8080
Happy building.



Top comments (0)