DEV Community

Christina Lin
Christina Lin

Posted on

Is Java Spring Ready for the AI Overlords in Late 2025? My Take(and rant) on Building an MCP Server.

Banner

Alright, confession time. Before I jumped on the Python bandwagon a couple of years ago, I was a die-hard Java dev. In this day and age, you’ve gotta be bilingual and flexible enough to pick the right tool for the job. And let’s be honest, for most AI stuff, Java hasn’t exactly been the cool kid at the party.

But with the industry flooded with examples of building MCP servers in Python, TypeScript, and whatnot, I got curious. What’s Java been up to?

Since I’m putting together a lab on securing MCP servers, I figured, why not build one in Java? After a couple of days of digging, my hot take is: Jeeez, can you make up your mind? I looked at SDKs, examples, and documentation. Depending on when it was written, you’ll see a huge difference in package names, and even the annotation names change. This API churn isn’t just annoying for us humans and of course it completely confuses the LLMs. They rely on a stable corpus of code to learn from, and right now, that just doesn’t exist for Spring AI.

So, here’s my verdict for now: I’ll probably wait a few more months before I’d push this to production. It’s not that it’s bad, but if you need to migrate to a new version, it feels like a complete rewrite.

Okay, enough of my rant (can’t call myself a millennial developer if I don’t complain a little).

Here’s the technical breakdown of how I got a mock MCP server running.

A quick heads-up: I’m writing this at the end of October 2025, and I’m using version 1.1.0-M3 of Spring AI. If you’re reading this in the future and all my code looks wrong, it’s probably because the Spring team decided to refactor everything. Again. 😉

Now, before we get our hands dirty with code, let’s make sure we’re on the same page. I’m guessing most of you are already building these things, but for anyone new to the party, here’s the lowdown on what an MCP server is. An MCP server is basically a standalone server that you pack with tools, think specific functions or API calls. The magic is that an AI agent can connect to your server, discover all the tools you’ve exposed, and then intelligently decide to use them to accomplish real world tasks. It’s how you give your LLM superpowers beyond just generating text. Instead of the AI just talking about shipping a package, it can actually call your shipPackage tool and make it happen. Simple as that.

Alright, preamble over. Let’s get to the POM.

Step 1: Bootstrap the Project with Spring Initializr

Like any good old Spring application, our journey begins at the same sacred place: the Spring Initializr.

Spring Initializr

Head over to start.spring.io and punch in your specs. I’m sticking with my old mate Maven, but you do you. Here are the key settings:

  • Project: Maven
  • Language: Java
  • Packaging: Jar
  • Java: 17 or 21

For the dependencies, you only need to add one to start: Spring MCP Server. The MCP MVC specific stuff isn’t in the main generator yet, so we’ll add that manually in the next step.

Go ahead, fill that out, click ‘GENERATE’, and unzip the project into your favorite IDE. Now we’re ready for the real work.

Step 2: Wrangling the POM

Once you’ve got the project open, the first place we need to operate is the pom.xml. The Spring Initializr gave us a good starting point, but now we need to perform some surgery and add the Spring AI dependency for our MCP server.

  <dependencies>
        <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-web</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>
   </dependencies>
Enter fullscreen mode Exit fullscreen mode

The key dependency here is spring-ai-starter-mcp-server-webmvc. Its main job is to provide the web layer, enabling protocols like streamable HTTP or Server-Sent Events (SSE). It acts as a bridge, pulling in the core spring-ai-mcp-server libraries where the actual tool discovery logic lives. So, by adding this one starter, you’re effectively telling Spring Boot, “I want an MCP server, and I want it to speak HTTP.” It’s the combination of the web-specific and core libraries on the classpath that kicks off the whole auto-configuration chain, wiring everything together from endpoints to tool scanners.

Alight, and another very important point because I don’t want to have everything outdated, I’ll pick the latest version of Spring AI, which currently is

<spring-ai.version>1.1.0-M3</spring-ai.version>
Enter fullscreen mode Exit fullscreen mode

Spring Boot’s auto-configuration lets us control a ton of behavior without writing a single line of Java config. Here’s how I set it up in application.properties:

spring.application.name=mcp-server
server.port=8081
spring.ai.mcp.server.protocol=streamable
spring.ai.mcp.server.name=physical_operations_logistics_mcp
spring.ai.mcp.server.capabilities.tool=true
spring.ai.mcp.server.type=sync
spring.ai.mcp.server.annotation-scanner.enabled=true
spring.ai.mcp.server.streamable-http.keep-alive-interval=20s
spring.ai.mcp.server.version=0.0.1
Enter fullscreen mode Exit fullscreen mode

A couple of these are super important.

spring.ai.mcp.server.annotation-scanner.enabled=true is what tells Spring to go on a scanning at startup. It uses reflection to find any beans you’ve annotated with @McpTool and dynamically builds a machine-readable tool catalog (a JSON schema, basically). spring.ai.mcp.server.streamable-http.keep-alive-interval=20s . It keeps the long-lived HTTP connection from getting killed by an impatient proxy or load balancer.

Step 3: Building the Tools

Now for the fun part. Since we’re in Java-land, we’re going to lean into its object-oriented nature. It just makes sense to categorize our tools by their domain. Instead of having a bunch of disconnected functions floating around, we’ll group all the related tools into a single, cohesive class. This is classic OOP, a class should represent a single responsibility or concept.

In our case, all the tools for handling employee hardware and building access belong together. So, we’ll create a PhysicalOperationsLogisticsService class. This class acts as a dedicated service layer for that specific business domain. This approach not only keeps the code clean and organized but also fits perfectly with Spring’s component model. We can just slap a @Component annotation on the class, and boom, it becomes a managed bean in the Spring container.

package google.mcp;


import org.springaicommunity.mcp.annotation.McpTool;
import org.springaicommunity.mcp.annotation.McpToolParam;
import org.springframework.stereotype.Component;


import org.slf4j.Logger;
import org.slf4j.LoggerFactory;


import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;


@Component
public class PhysicalOperationsLogisticsService {


   private static final Logger log = LoggerFactory.getLogger(PhysicalOperationsLogisticsService.class);


   @McpTool(name = "provisionBuildingAccess",description = "Activates a security badge for building access at a specific office.")
   public String provisionBuildingAccess(
           @McpToolParam(description = "The ID of the employee.") String employeeId,
           @McpToolParam(description = "The office location for building access.") String officeLocation) {
       log.info("Provisioning building access for employee {} at {}", employeeId, officeLocation);
       return "{\"access_status\": \"active\"}";
   }


   @McpTool(name = "shipPackage",description = "Places an order for a physical hardware package from a vendor.")
   public String shipPackage(
           @McpToolParam(description = "The ID of the order.") String orderId,
           @McpToolParam(description = "The recipient's address as a JSON string.") String recipientAddress,
           @McpToolParam(description = "Description of the package contents.") String packageContents) {
       log.info("Shipping package for order {} to {} with contents {}", orderId, recipientAddress, packageContents);
       String trackingNumber = UUID.randomUUID().toString();
       LocalDate estimatedDelivery = LocalDate.now().plusDays(7);
       return String.format("{\"order_id\": \"%s\", \"tracking_number\": \"%s\", \"estimated_delivery\": \"%s\"}",
               orderId, trackingNumber, estimatedDelivery.toString());
   }


   @McpTool(name = "getShippingStatus",description = "Gets the latest status of a package from a shipping carrier.")
   public String getShippingStatus(
           @McpToolParam(description = "The tracking number of the package.") String trackingNumber) {
       log.info("Getting shipping status for tracking number {}", trackingNumber);
       String[] statuses = {"in_transit", "delivered", "delayed"};
       String status = statuses[(int) (Math.random() * statuses.length)];
       return String.format("{\"shipping_status\": \"%s\"}", status);
   }
}
Enter fullscreen mode Exit fullscreen mode

Let’s break down those annotations:

  • @McpTool: This tells the scanner, “Hey, this method is a tool!” The name and description are critical. The LLM uses the description to figure out when to call your tool. A good description is the difference between a useful tool and a useless one.
  • @McpToolParam: This one works on the method arguments. It generates the schema for the parameters, defining their names, types (inferred from Java), and a description of what the parameter is for.

Step 5: Build, Run, and See It in Action

Building and running is standard Maven fare:

# Compile, test, and package into an executable JAR
mvn clean package

# Fire it up
mvn spring-boot:run
Enter fullscreen mode Exit fullscreen mode

To test it, I wired up the Gemini CLI. I just had to point it to my local server in its settings.json:

{
  "mcpServers": {
    "physical-logistic-server": {
      "httpUrl": "http://localhost:8081/mcp"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Here’s the technical play-by-play of what happens next:

  • The Gemini CLI hits http://localhost:8081/mcp and asks, “What can you do?”
  • My Spring server proudly presents the JSON schema it built from my annotations.

Gemini CLI

  • I give Gemini CLI a prompt like, “what’s the shipping status for tracking number xxxx?”
  • Gemini CLI sees my prompt, looks at the available tools, and says, “Aha! The getShippingStatus tool looks perfect for this.” It then generates a function call payload.
  • Gemini CLI sends that payload to my Spring MCP server, which routes it to the getShippingStatus method.
  • My Java code runs, does its mock magic, and returns a JSON string.
  • Gemini CLI passes that result back to the LLM, which then formulates a nice, human-readable answer.

Result

Final Thoughts

Once you get past the version whiplash, the framework is actually pretty slick. It hides a lot of complexity behind familiar Spring annotations and auto-configuration. For Java builders looking to get their hands dirty with AI, it’s a promising start.

That said, given its current milestone status, I’d keep it in the lab for now. I’ll be watching for a stable GA release before I’d even think about letting it near production.

As for the full code, I’ll be sharing that in my security workshop. Stay tuned.

Top comments (0)