Skip to main content
  1. Blog/

Creating and using an MCP server using Spring AI and Embabel

·3245 words·16 mins
Jettro Coenradie
Author
Jettro Coenradie
Software architect and search enthusiast. I write about AI, search, cloud, and software development.

I wrote how to start writing an Agent on the JVM with Embabel in a previous blog post Building Agents with Embabel: A Hands-On Introduction. You can read all the steps to start with Embabel. Some things I did not cover are:

  • MCP integration
  • Working with Agents from a web application.

In this blog post, you continue the Embabel journey. You’ll start with an MCP server using Spring AI. Next, you’ll consume the MCP tools in an Agent. Finally, you’ll read about the integration of the agent in a controller and a Thymeleaf Spring MVC application.

The MCP Server
#

MCP is short for Model Context Protocol. It is a standard defined to make it easier for an LLM to communicate with the outside world. In the previous blog, I already introduced you to tools. MCP takes it one step further.

MCP enables you to build agents and complex workflows on top of LLMs and connects your models with the world. ~ https://modelcontextprotocol.io

Many companies offer MCP servers to connect you to their products. Some servers are developed using an open-source license, while others require you to be a member and provide a license key. You can connect to remote running servers; in this blog, I’ll focus on the local running servers. Some examples include MCP servers that connect to Slack, Outlook, files, and home automation systems.

We are creating a Meeting Location Service using MCP. The server contains three tools:

  1. Get all available meeting locations.
  2. Check room availability for a specific location, date, time, duration and number of people.
  3. Book a room at a specific location on a day and time.

In the sample, we adhere to an in-memory implementation; you can modify it to utilise a persistent store.

Spring AI depedencies
#

Spring AI has a starter dependency for your MCP server. The pom.xml below contains the dependency. The parent dependency contains a bom to keep the versions in sync. At the moment of writing, the version of Spring AI is 1.0.1.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.rag4j.meeting</groupId>
        <artifactId>meeting-planner-embabel</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>location-mcp</artifactId>
    <packaging>jar</packaging>

    <name>Location MCP Server</name>
    <description>MCP Server exposing tools for interacting with locations.</description>

    <dependencies>
        <!-- Internal dependencies -->
        <dependency>
            <groupId>org.rag4j.meeting</groupId>
            <artifactId>common</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-core</artifactId>
            <scope>test</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <goals><goal>repackage</goal></goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>

Ensure that you do not forget the spring-boot-maven-plugin. Without it, your jar will not be runnable as the main class will be missing. You will get a nice error message.

Service containing the tools
#

The first class you need is a service or bean with the methods annotated with “@Tool”. The code below shows the frame of the class. The implementation can be found on GitHub. I’ll point you to the project at the end of the blog. Not the three tools and how they are defined.

package org.rag4j.meetingplanner.location;

import org.rag4j.meetingplanner.common.model.Agenda;
import org.rag4j.meetingplanner.location.model.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Service;

import java.util.*;

@Service
public class LocationService {
    private static final Logger logger = LoggerFactory.getLogger(App.class);

    private final Map<String, Location> locations = new HashMap<>();
    private final Map<String, Map<String, Room>> locationRooms = new HashMap<>();

    public LocationService() {
        initializeLocations();
    }

    @Tool(
            name = "all-locations",
            description = "Get all available meeting locations."
    )
    public LocationResponse getAllLocations() {
        logger.info("Fetching all available locations.");

        return new LocationResponse(new ArrayList<>(locations.values()));
    }

    @Tool(
            name = "check-room-availability",
            description = "Check room availability for a specific location, date, time, duration and number of people."
    )
    public RoomAvailableResponse checkRoomAvailability(RoomAvailableRequest request) {
        logger.info("Checking room availability {}", request);

        // Implementation is not important

        logger.info("Response for check room availability {}", response);
        return response;
    }

    @Tool(
            name = "book-room",
            description = "Book a room at a specific location on a day and time."
    )
    public BookRoomResponse bookRoom(BookRoomRequest request) {
        logger.info("Book a room {}", request);

        // Implementation is not important

        logger.info("Booking confirmed for location {}", bookRoomResponse);
        return bookRoomResponse;
    }

    private void initializeLocations() {
        // Implementation contains some sample locations and rooms
    }
}

Code to run the application
#

This is standard Spring Boot code to run an application. We need one bean to expose the tools from the service, that is it.

package org.rag4j.meetingplanner.location;

import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }

    @Bean
    public ToolCallbackProvider locationTools(LocationService locationService) {
        return MethodToolCallbackProvider.builder().toolObjects(locationService).build();
    }
}

The configuration
#

There is minimal configuration required. Below is the application.yml.

spring:
  ai:
    mcp:
      server:
        name: location-mcp
        version: 1.0.0
  main:
    banner-mode: off
    web-application-type: none

Please note that we have switched off the banner. This is an essential step; more on this is discussed in the next section on logging.

Logging
#

The thing I had the most problems with was making sure nothing was printed to StdOut. If you do print a log line to the stdout, you get a warning not to do that. Below, I show you a sample configuration file for Logback. In my case, I added a logline before starting the spring but app with the run command. The logging configuration through Spring Boot does not influence that log line. I needed some time to figure out that I should remove that line. Below is the spring-logback.xml file.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/app.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="FILE"/>
    </root>
</configuration>

Ensure your application does not generate logs; start it manually like a regular Java application.

Testing tools are exposed.
#

A good tool to test your MCP server is the inspector. I prefer to use the command line to test the server. Below is the command to test the server.

First, you need a server configuration file. This file specifies the name and the command to start the server. In this case, a Java command is used; however, Docker commands are also regularly seen here.

{
  "mcpServers": {
    "location": {
      "command": "java",
      "args": [
        "-Dspring.ai.mcp.server.stdio=true",
        "-jar",
        "/absolute-path/location-mcp/target/location-mcp-1.0.0-SNAPSHOT.jar"
      ]
    }
  }
}

Time to test the jar file with the inspector.

npx @modelcontextprotocol/inspector --cli \
  --config ./src/test/resources/mcp-servers-config.json \
  --server location \
  --method tools/list

The output is somewhat verbose, but it provides a clear indication of the tools available. This is also what the LLM will see.

{
  "tools": [
    {
      "name": "book-room",
      "description": "Book a room at a specific location at a day and time.",
      "inputSchema": {
        "type": "object",
        "properties": {
          "request": {
            "type": "object",
            "properties": {
              "date": {
                "type": "string",
                "format": "date"
              },
              "description": {
                "type": "string"
              },
              "durationInMinutes": {
                "type": "integer",
                "format": "int32"
              },
              "locationId": {
                "type": "string"
              },
              "reference": {
                "type": "string"
              },
              "roomId": {
                "type": "string"
              },
              "startTime": {
                "type": "string",
                "format": "time"
              }
            },
            "required": [
              "date",
              "description",
              "durationInMinutes",
              "locationId",
              "reference",
              "roomId",
              "startTime"
            ]
          }
        },
        "required": [
          "request"
        ],
        "additionalProperties": false
      }
    },
    {
      "name": "check-room-availability",
      "description": "Check room availability for a specific location, date, time, duration and number of people.",
      "inputSchema": {
        "type": "object",
        "properties": {
          "request": {
            "type": "object",
            "properties": {
              "date": {
                "type": "string",
                "format": "date"
              },
              "durationInMinutes": {
                "type": "integer",
                "format": "int32"
              },
              "locationId": {
                "type": "string"
              },
              "requestedNumberOfPeople": {
                "type": "integer",
                "format": "int32"
              },
              "startTime": {
                "type": "string",
                "format": "time"
              }
            },
            "required": [
              "date",
              "durationInMinutes",
              "locationId",
              "requestedNumberOfPeople",
              "startTime"
            ]
          }
        },
        "required": [
          "request"
        ],
        "additionalProperties": false
      }
    },
    {
      "name": "all-locations",
      "description": "Get all available meeting locations.",
      "inputSchema": {
        "type": "object",
        "properties": {},
        "required": [],
        "additionalProperties": false
      }
    }
  ]
}

That is it, now we have an MCP server to give access to our Meeting Location Booking system. In the next part, we’ll dive into writing our Agent using Embabel.

The Agent
#

You need to take a few steps to give the agent access to your MCP server. First, you need to tell the spring boot application about the stdio connection. Next, you configure a ToolGroup. Finally, you provide the toolgroup to an action.

Spring boot configuration
#

I prefer to use the spring application config, as you have access to environment variables. That way the absolute path to your server jar file is a bit more relaxed. Below is the config I use.

spring:
  ai:
    mcp:
      client:
        enabled: true
        type: SYNC
        stdio:
          connections:
            location:
              command: java
              args:
                - -Dspring.ai.mcp.server.stdio=true
                - -jar
                - "${LOCATION_MCP_JAR:${user.dir}/location-mcp/target/location-mcp-1.0.0-SNAPSHOT.jar}"

Next, you need to enable the Embabel agents

package org.rag4j.meetingplanner.webapp;

import com.embabel.agent.config.annotation.EnableAgents;
import com.embabel.agent.config.annotation.LoggingThemes;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@EnableAgents(
        loggingTheme = LoggingThemes.STAR_WARS
)
public class WebAppApplication {

    public static void main(String[] args) {
        SpringApplication.run(WebAppApplication.class, args);
    }

}

The ToolGroup
#

A ToolGroup is a group of tools, yes, really. Each ToolGroup has a role, through the role you can inject all the tools of the group into an action. Below is the configuration of the Location ToolGroup.

package org.rag4j.meetingplanner.agent.config;

import com.embabel.agent.core.ToolGroup;
import com.embabel.agent.core.ToolGroupDescription;
import com.embabel.agent.core.ToolGroupPermission;
import com.embabel.agent.tools.mcp.McpToolGroup;
import io.modelcontextprotocol.client.McpSyncClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.util.Assert;

import java.util.List;
import java.util.Set;

@Configuration
public class McpToolsConfig {

    private final List<McpSyncClient> mcpSyncClients;

    @Autowired
    public McpToolsConfig(@Lazy List<McpSyncClient> mcpSyncClients) {
        Assert.notNull(mcpSyncClients, "McpSyncClients must not be null");
        this.mcpSyncClients = mcpSyncClients;
    }

    @Bean(name = "mcpLocationsToolsGroup")
    public ToolGroup mcpLocationsToolsGroup() {
        return new McpToolGroup(
                ToolGroupDescription.Companion.invoke(
                        "A collection of tools to interact with the MCP location service",
                        "location"
                ),
                "Spring",
                "location",
                Set.of(ToolGroupPermission.HOST_ACCESS),
                mcpSyncClients,
                callback -> callback.getToolDefinition().name().contains("location") ||
                  callback.getToolDefinition().name().contains("room")
        );
    }
}

The last line is essential. You choose the tools to expose through the ToolGroup. In this case I only choose the tools with location or room in the name. In our case, that is all three of them.

When starting the application, you can check the logs for a well configured ToolGroup. If everything is well configured, you should see these lines.

role:location, artifact:location, version:0.1.0, provider:Spring - A collection of tools to interact with the MCP location service
spring_ai_mcp_client_location_all_locations, spring_ai_mcp_client_location_book_room, spring_ai_mcp_client_location_check_room_availability

Pay attention to the names, these are our tool names prepended with spring_ai_mcp_client-. Also, note the name of the role: location.

The ToolGroup is ready to be injected into our actions.

Creating the LocationAgent
#

There are three annotions important to create the agent:

  • Agent: Clarifies the purpose of the agent
  • Action: Explains what actions an agent can perform
  • AchievesGoal: Specifies the goals an agent can accomplish

Our goal is to book a meeting room at a location that is closest to our needs. The agent has three actions to accomplish this goal. Finding a location, checking for an available room at that location, and book the room. The first code block is the class plus the first action.

package org.rag4j.meetingplanner.agent;

import com.embabel.agent.api.annotation.AchievesGoal;
import com.embabel.agent.api.annotation.Action;
import com.embabel.agent.api.annotation.Agent;
import com.embabel.agent.api.common.OperationContext;
import com.embabel.agent.config.models.OpenAiModels;
import org.rag4j.meetingplanner.agent.model.location.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Agent(
        name = "LocationAgent",
        description = "An agent that helps to find appropriate meeting locations.",
        version = "1.0.0"
)
public class LocationAgent {
    private static final Logger logger = LoggerFactory.getLogger(LocationAgent.class);

    @Action(toolGroups = {"location"}, description = "Find the best matching location for a meeting based on the provided description.")
    public Location findLocation(RoomRequest request, OperationContext context) {
        logger.info("Received meeting request: {}", request);

        Location response = context.ai().withLlm(OpenAiModels.GPT_41_MINI)
                .createObject(String.format("""
                                 You will be given a description for a location.
                                 You have access to all available locations through tools.
                                 Match the provided description to the available locations to find the best match.
                                 Return the best matching location, always suggest one of the locations.
                                 Stick to found locations, do not make up new ones.

                                 # Location description
                                 %s

                                """,
                        request.locationDescription()
                ).trim(), Location.class);
        logger.info("Response generated: {}", response);
        return response;
    }

}

Some things in code are essential to programming agents with Embabel. Take note of the agent annotation. The action annotation configures the ToolGroups that are available to the action. Note that the location ToolGroup is passed.

The actions work best when using domain type objects. Limit using basic Java classes like String, List, Integer. Each method receives the OperationContext to the LLM. We construct the prompt and tell the LLM to create an object of type Location.

Below is the log of the agent. Pay attention to the tool calling.

19:36:27.401 [task-1] INFO  LocationAgent - Received meeting request: RoomRequest[locationDescription=a room at a knowledge center, numberOfParticipants=3, date=2025-09-02, startTime=10:00, numberOfMinutes=30]
19:36:27.405 [task-1] INFO  starwars - [fervent_snyder] Ask LLM we will: Requesting LLM gpt-4.1-mini to transform org.rag4j.meetingplanner.agent.LocationAgent.bookMeeting-org.rag4j.meetingplanner.agent.model.location.Location from Location -> LlmOptions(modelSelectionCriteria=null, model=gpt-4.1-mini, temperature=null, frequencyPenalty=null, maxTokens=null, presencePenalty=null, topK=null, topP=null, thinking=null, timeout=null)
19:36:29.445 [task-1] INFO  starwars - [fervent_snyder] (bookMeeting) calling tool spring_ai_mcp_client_location_all_locations({})
19:36:29.474 [task-1] INFO  starwars - [fervent_snyder] (bookMeeting) tool spring_ai_mcp_client_location_all_locations returned [{"text":"{\"locations\":[{\"id\":\"meet-nature\",\"name\":\"Meeting in ...]}"}] in 28ms with payload {}
19:36:30.849 [task-1] INFO  starwars - [fervent_snyder] received LLM response org.rag4j.meetingplanner.agent.LocationAgent.bookMeeting-org.rag4j.meetingplanner.agent.model.location.Location of type Location from ByNameModelSelectionCriteria(name=gpt-4.1-mini) in 3 seconds
19:36:30.849 [task-1] INFO  LocationAgent - Response generated: Location[name=Library, description=Quiet spaces for focused meetings., id=library]

If you remember well, we also write logs to a file for the MCP server. Below is the logs for a full request with all the tools. Can you spot the three tools?

2025-09-01 19:36:32 INFO  o.rag4j.meetingplanner.location.App - Fetching all available locations.
2025-09-01 19:36:34 INFO  o.rag4j.meetingplanner.location.App - Checking room availability RoomAvailableRequest[locationId=library, requestedNumberOfPeople=3, date=2025-09-02, startTime=10:00, durationInMinutes=30]
2025-09-01 19:36:34 INFO  o.rag4j.meetingplanner.location.App - Response for check room availability RoomAvailableResponse[locationId=library, available=true, roomId=study, startTime=10:00, durationInMinutes=30]
2025-09-01 19:36:39 INFO  o.rag4j.meetingplanner.location.App - Book a room BookRoomRequest[locationId=library, roomId=study, date=2025-09-01, startTime=10:00, durationInMinutes=30, reference=Ref-StudyRoom-1000, description=Meeting in Study Room at Library]
2025-09-01 19:36:39 INFO  o.rag4j.meetingplanner.location.App - Booking confirmed for location BookRoomResponse[locationId=library, roomId=study, success=true, message=Booking confirmed for Ref-StudyRoom-1000]

The next code block shows the second action. Note the input for this action. Did you spot the Location object as an input argument. Scroll up if you did not see this is the output from the previous action. That helps the Embabel planning system to determine the right order for the actions.

    @Action(toolGroups = {"location"}, description = "Check availability of a room at the preferred location")
    public SuggestedRoom findRoomAtLocation(Location location, RoomRequest roomRequest, OperationContext context) {
        logger.info("Received find room request: {}", roomRequest);

        SuggestedRoom response = context.ai().withLlm(OpenAiModels.GPT_41_MINI)
                .createObject(String.format("""
                                 You will be given an Id for a location.
                                 You have access to all rooms for that location through tools.
                                 Check availability of a room at the preferred location.
                                 Return the best matching room, or roomId "not-available".

                                 # LocationId
                                 %s
                                 # Number of participants
                                 %d
                                 # Date, start time, number of minutes
                                 %s, %s, %d

                                """,
                        location.id(), roomRequest.numberOfParticipants(), roomRequest.date(), roomRequest.startTime(), roomRequest.numberOfMinutes()
                ).trim(), SuggestedRoom.class);
        logger.info("Response generated: {}", response);
        return response;
    }

Note that we pass the locationId, the number of participants, and the date fields to the prompt. This is required information for the tool. Below is the logs from the agent for this action. Pay close attention to the tools that are called.

19:36:30.858 [task-1] INFO  LocationAgent - Received find room request: RoomRequest[locationDescription=a room at a knowledge center, numberOfParticipants=3, date=2025-09-02, startTime=10:00, numberOfMinutes=30]
19:36:30.859 [task-1] INFO  starwars - [fervent_snyder] Ask LLM we will: Requesting LLM gpt-4.1-mini to transform org.rag4j.meetingplanner.agent.LocationAgent.findRoomAtLocation-org.rag4j.meetingplanner.agent.model.location.SuggestedRoom from SuggestedRoom -> LlmOptions(modelSelectionCriteria=null, model=gpt-4.1-mini, temperature=null, frequencyPenalty=null, maxTokens=null, presencePenalty=null, topK=null, topP=null, thinking=null, timeout=null)
19:36:32.315 [task-1] INFO  starwars - [fervent_snyder] (findRoomAtLocation) calling tool spring_ai_mcp_client_location_all_locations({})
19:36:32.321 [task-1] INFO  starwars - [fervent_snyder] (findRoomAtLocation) tool spring_ai_mcp_client_location_all_locations returned [{"text":"{\"locations\":[{\"id\":\"meet-nature\",\"name\":\"Meeting in ...]}"}] in 5ms with payload {}
19:36:34.533 [task-1] INFO  starwars - [fervent_snyder] (findRoomAtLocation) calling tool spring_ai_mcp_client_location_check_room_availability({"request":{"date":"2025-09-02","durationInMinutes":30,"locationId":"library","requestedNumberOfPeople":3,"startTime":"10:00"}})
19:36:34.556 [task-1] INFO  starwars - [fervent_snyder] (findRoomAtLocation) tool spring_ai_mcp_client_location_check_room_availability returned [{"text":"{\"locationId\":\"library\",\"available\":true,\"roomId\":\"st...0}"}] in 23ms with payload {"request":{"date":"2025-09-02","durationInMinutes":30,"locationId":"library","requestedNumberOfPeople":3,"startTime":"10:00"}}
19:36:37.607 [task-1] INFO  starwars - [fervent_snyder] received LLM response org.rag4j.meetingplanner.agent.LocationAgent.findRoomAtLocation-org.rag4j.meetingplanner.agent.model.location.SuggestedRoom of type SuggestedRoom from ByNameModelSelectionCriteria(name=gpt-4.1-mini) in 6 seconds
19:36:37.608 [task-1] INFO  LocationAgent - Response generated: SuggestedRoom[location=Location[name=Library, description=Quiet spaces for focused meetings., id=library], room=Room[roomId=study, name=Study Room, description=A quiet study room, capacity=3], startTime=10:00, durationInMinutes=30]

Did you spot the call to the all_locations tool? To my opinion, this is not necessary. I still need to figure out why this happens.

The final code block is the third action, which is also the AchievesGoal action. Again, note that the SuggestionRoom is the output from the previous step, and the input for this step.

    @AchievesGoal(description = "Book a meeting room at a location for the specified number of people.")
    @Action(toolGroups = {"location"}, description = "Book the available room")
    public BookingResult bookRoom(SuggestedRoom room, OperationContext context) throws Exception {
        logger.info("Received book room request: {}", room);

        BookingResult response = context.ai().withLlm(OpenAiModels.GPT_41_MINI)
                .createObject(String.format("""
                                 You will be given an suggested room.
                                 If the id of the room is 'non-available', write the response message that it did not work and stop processing.
                                 You have access to all rooms for that location through tools.
                                 Assumed the room is available, book the room using the provided tools.
                                 Return a description of the booking that was created or a message if it failed.

                                 # room to book
                                 %s

                                """,
                        room
                ).trim(), BookingResult.class);
        logger.info("Response generated: {}", response);
        return response;
    }

This time, we just pass the complete SuggestedRoom object to the LLM. That works as well. If the complete process works, you should see the next log lines. After that we can connect the web application to our agent.

19:36:37.618 [task-1] INFO  LocationAgent - Received book room request: SuggestedRoom[location=Location[name=Library, description=Quiet spaces for focused meetings., id=library], room=Room[roomId=study, name=Study Room, description=A quiet study room, capacity=3], startTime=10:00, durationInMinutes=30]
19:36:37.619 [task-1] INFO  starwars - [fervent_snyder] Ask LLM we will: Requesting LLM gpt-4.1-mini to transform org.rag4j.meetingplanner.agent.LocationAgent.bookRoom-org.rag4j.meetingplanner.agent.model.location.BookingResult from BookingResult -> LlmOptions(modelSelectionCriteria=null, model=gpt-4.1-mini, temperature=null, frequencyPenalty=null, maxTokens=null, presencePenalty=null, topK=null, topP=null, thinking=null, timeout=null)
19:36:39.036 [task-1] INFO  starwars - [fervent_snyder] (bookRoom) calling tool spring_ai_mcp_client_location_book_room({"request":{"date":"2025-09-01","description":"Meeting in Study Room at Library","durationInMinutes":30,"locationId":"library","reference":"Ref-StudyRoom-1000","roomId":"study","startTime":"10:00"}})
19:36:39.051 [task-1] INFO  starwars - [fervent_snyder] (bookRoom) tool spring_ai_mcp_client_location_book_room returned [{"text":"{\"locationId\":\"library\",\"roomId\":\"study\",\"success\":t..."}"}] in 15ms with payload {"request":{"date":"2025-09-01","description":"Meeting in Study Room at Library","durationInMinutes":30,"locationId":"library","reference":"Ref-StudyRoom-1000","roomId":"study","startTime":"10:00"}}
19:36:40.674 [task-1] INFO  starwars - [fervent_snyder] received LLM response org.rag4j.meetingplanner.agent.LocationAgent.bookRoom-org.rag4j.meetingplanner.agent.model.location.BookingResult of type BookingResult from ByNameModelSelectionCriteria(name=gpt-4.1-mini) in 3 seconds
19:36:40.675 [task-1] INFO  LocationAgent - Response generated: BookingResult[description=Booking confirmed for Study Room at Library on 2025-09-01 starting at 10:00 for 30 minutes. Reference: Ref-StudyRoom-1000.]

Connecting the web application
#

The web application has a Thymeleaf front-end with some Bootstrap styling. The location page shows a form to enter a description for the location you are looking for. You can enter the number of participants and the date/time for the meeting.

The submit button sends the data to the LocationController. This controller receives the AgentPlatform from spring. Through this platform you have access to the agent. I’ll paste the complete code of the controller. Pay attention to the lines where we work with AgentInvocation. The platform takes note of the input object, RoomRequest, and the output object, BookingResult. Those objects tell the platform which agent is needed. That is it.

This is the code for the controller.

package org.rag4j.meetingplanner.webapp.controller;

import com.embabel.agent.api.common.autonomy.AgentInvocation;
import com.embabel.agent.core.AgentPlatform;
import org.rag4j.meetingplanner.agent.model.location.BookingResult;
import org.rag4j.meetingplanner.agent.model.location.RoomRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public class LocationController {
    private static final Logger logger = LoggerFactory.getLogger(LocationController.class);

    private final AgentPlatform agentPlatform;

    public LocationController(AgentPlatform agentPlatform) {
        this.agentPlatform = agentPlatform;
    }

    @GetMapping("/locations")
    public String locations(Model model) {
        model.addAttribute("title", "Location Lookup");
        return "locations";
    }

    @PostMapping("/locations")
    public String searchLocations(@ModelAttribute RoomRequest roomRequest, String description, Model model) {
        logger.info("Location search request received: {}", roomRequest);

        var agentInvocation = AgentInvocation.create(agentPlatform, BookingResult.class);
        BookingResult bookingResult = agentInvocation.invoke(roomRequest);
        model.addAttribute("title", "Location Lookup");
        model.addAttribute("searchDescription", description);
        model.addAttribute("success", "Result of request: " + bookingResult.description());
        model.addAttribute("bookingResult", bookingResult);

        return "locations";
    }
}

Below is a screenshot of the application.

Screenshot for the Location page of the web application.

Screenshot for the Location page of the web application.

Concluding
#

I hope you have a better understanding of working with MCP servers in an Embabel application. The source code for the sample application is online. I am still working on this sample. The code can change, together with Embabel. I’ll try to keep the post up to date.

GitHub - RAG4J/meeting-planner-embabel: A project to demonstrate the power of the Embabel Agentic… A project to demonstrate the power of the Embabel Agentic Framework - RAG4J/meeting-planner-embabelgithub.com

This is my first blog post about Embabel:

Building Agents with Embabel: A Hands-On Introduction *Embabel is an emerging framework designed to bring intelligent, goal-driven behaviour to Java applications. It…*jettro.dev

Originally published on Medium