Last month I wanted one of my AI agents to check order statuses in a service I maintain. The "integration" was me pasting curl commands into the agent's prompt, then the agent assembling HTTP calls by hand, guessing at auth headers, misreading the error format, and eventually inventing an order status that did not exist. It worked about sixty percent of the time, and every failure was a new surprise.
I had solved this problem before, the hard way. A custom adapter, a JSON schema file the agent never quite respected, error handling the agent ignored. This time I did it differently: I exposed the Spring Boot service as an MCP server, using the annotation support that Spring AI ships today. The working version, three tool methods and one YAML block, took me an afternoon.
This article is that afternoon, written down. I have spent six years building Spring Boot services in production, and I run my own small AI agent infrastructure, so this is written from the perspective of a backend developer who wants agents to stop fumbling through APIs designed for humans.
If you read my piece yesterday on REST API design for agent traffic, this is the other half of the same story. REST hardening keeps dumb agents from hurting themselves. MCP gives them a contract they actually understand.
What MCP is, in one paragraph
The Model Context Protocol is an open standard, originally created at Anthropic and open-sourced in late 2024, that lets AI applications connect to external systems through one consistent interface. Instead of every AI tool building a bespoke connector for every data source, an MCP server exposes "tools" with names, descriptions, and JSON schemas for their parameters, and any MCP client, from Claude Desktop to coding agents to your own Spring AI application, can discover and call them over a standard transport. The protocol moved to a community-driven open-source project and has since been adopted across the industry as the de facto way to give models access to tools and data. The important consequence for a Java developer: your Spring Boot service can speak one protocol and suddenly every serious AI client knows how to use it.
Spring AI supports both sides. You can build MCP clients that consume tools, and MCP servers that expose your Spring services to the wider AI ecosystem. This guide covers the server side, because if you already have a Spring Boot app, that is where the immediate value is.
Step 1: Pick your starter and transport
Spring AI provides several Boot starters for MCP servers (reference docs). For a typical servlet-based Spring Boot service, you want the WebMVC starter. Add this dependency:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
Then choose a protocol mode in application.yml:
spring:
ai:
mcp:
server:
name: order-service
version: 1.0.0
protocol: STREAMABLE
There are three HTTP protocol modes on the same starter, and the choice matters more than it looks:
- SSE is the older Server-Sent Events style transport. It works, but it is the legacy option.
- STREAMABLE is Streamable HTTP, the modern remote transport: one endpoint, JSON-RPC messages flowing both ways, session management built in. This is the sane default for a service running behind a load balancer.
- STATELESS is the stateless variant of Streamable HTTP, where each request carries everything needed to process it. If you are running multiple replicas behind Kubernetes and do not want sticky sessions, this is your mode.
There is also a STDIO mode (spring.ai.mcp.server.stdio=true with the core spring-ai-starter-mcp-server) for servers that run as a local subprocess of a desktop client like Claude Desktop. For a networked Spring Boot service, you almost certainly want STREAMABLE or STATELESS, not STDIO.
I picked STATELESS for my order-status service because it runs as two replicas and I did not want session affinity requirements leaking into my infrastructure decisions. That single YAML property was the whole deployment consideration.
Step 2: Expose a tool with @McpTool
Here is the part that surprised me with how little code it takes. You annotate a method on a Spring bean, and the framework handles registration, JSON schema generation, and the JSON-RPC wiring.
@Component
public class OrderTools {
private final OrderRepository orders;
public OrderTools(OrderRepository orders) {
this.orders = orders;
}
@McpTool(name = "get-order-status",
description = "Look up the current status of an order by its order number")
public OrderStatusDto getOrderStatus(
@McpToolParam(description = "The order number, format ORD-12345", required = true)
String orderNumber) {
return orders.findStatus(orderNumber)
.orElseThrow(() -> new OrderNotFoundException(orderNumber));
}
}
That is a working MCP tool. When the server starts, this method appears in the tool list with its name, its description, and a generated JSON schema declaring one required string parameter. No schema file to maintain, no controller, no manual JSON-RPC handling.
Two details I learned the hard way:
-
The description is prompt engineering, not documentation. The model reads your
descriptiontext to decide when and how to call the tool. "Look up the current status of an order by its order number, given an order number like ORD-12345" beats "getOrderStatus" by a wide margin in my testing. Put format examples in the parameter description, because the model will otherwise guess formats. - Return structured objects, not strings. The schema generator handles a DTO with fields fine, and the model parses a structured result far more reliably than free text.
Step 3: Label your tools honestly, for the agent's sake
The part of the annotation API I did not expect to care about is the hints block. @McpTool accepts annotations that tell the client what kind of operation this is:
@McpTool(name = "recalculate-invoice",
description = "Recalculate all line items and totals for an invoice",
annotations = @McpTool.McpAnnotations(
readOnlyHint = false,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false
))
public InvoiceDto recalculateInvoice(
@McpToolParam(description = "Invoice ID", required = true) String invoiceId) {
return invoiceService.recalculate(invoiceId);
}
The defaults are worth internalizing, because they are conservative in a way that surprised me. If you say nothing, readOnlyHint defaults to false and destructiveHint defaults to true. In other words, an unlabeled tool is assumed to be a potentially destructive write. Clients can use these hints to ask for confirmation before calling, to skip retries, or to sandbox the call. When I labeled my read-only lookups as actually read-only, my agent stopped asking "are you sure?" for every status check, and when I marked one genuinely destructive tool as destructive, the client started pausing on it. The hints are a safety contract between your server and whoever's agent is calling it. Fill them in.
Step 4: Expose data that is not an action
Tools are for actions and queries, but MCP also has resources, exposed with @McpResource and URI templates. This is the right place for data the agent should be able to read without "calling" anything:
@Component
public class ConfigResources {
private final Map<String, String> configData = Map.of(
"currency", "BDT",
"timezone", "Asia/Dhaka"
);
@McpResource(
uri = "config://{key}",
name = "Configuration",
description = "Service configuration values by key")
public String getConfig(String key) {
return configData.get(key);
}
}
In my case, the agent kept needing to know store operating hours and cutoff times to answer "when will this order ship" questions. As a tool, the model had to decide to call it. As a resource, the client can attach it as context. Same service, better fit for read-mostly reference data.
Step 5: Test with the MCP Inspector before touching a real client
The fastest feedback loop is the MCP Inspector, the official debugging client that ships with the MCP tooling. Point it at your running server endpoint and it lists your tools, renders their schemas, and lets you invoke them with real arguments. I caught three description bugs this way, including one where my parameter description said the order number was numeric when it was not.
The failure mode to watch for in the inspector is not exceptions, since those show up clearly. It is the silent one: a schema that technically validates but does not tell the model what it needs. The inspector shows you exactly what the model will see, which is the schema and descriptions and nothing else. If you squint at the schema and cannot figure out how to call the tool, neither can the model.
Step 6: Connect a client
Once the server runs, connecting a client is client-side configuration, not code. Claude Desktop and most coding agents accept an MCP server entry with a URL. If your consumer is another Spring application, Spring AI has client starters (spring-ai-starter-mcp-client and its WebFlux sibling) that pull remote tools into a ChatClient tool chain, with the same STDIO, SSE, and Streamable HTTP transport options.
One honest note on scope: I have run this pattern on my own agent infrastructure and a side deployment, not yet in the medium-large production system I work on day to day. The code above matches the current Spring AI reference documentation, but I am still learning where the sharp production edges are. The next section is where I currently think they are.
The production checklist I would hand my past self
- Put auth on the endpoint. A Streamable HTTP MCP server is an HTTP endpoint that executes whatever the connected model decides to execute. It goes behind the same bearer token or OAuth treatment as any other internal API. An unauthenticated MCP server is a remote code execution queue with extra steps.
- Prefer STATELESS behind load balancers. Unless you need sticky-session stateful streaming, stateless mode removes a whole class of scaling headaches.
- Rate limit per client, not per IP. One agent with a retry loop is not one user. Give each client identity its own limits, and keep them tighter than your human-facing API.
-
Label every tool with hints.
readOnlyHint,destructiveHint,idempotentHint. The defaults assume the worst; your read endpoints deserve better, and your destructive ones deserve the pause they induce. -
Treat descriptions as prompts. Include formats, units, and examples in every
@McpToolParamdescription. This is the highest-leverage ten minutes of the whole build. - Return DTOs, throw mapped exceptions. Structured results and clean error objects survive contact with models. Stack traces do not.
- Log tool calls with arguments. When an agent misbehaves, and one eventually will, you want to know exactly which invocation did it.
What I would do differently
If I were starting the order-status integration today, I would skip the curl-in-a-prompt phase entirely, expose the two read-only tools first, and only then decide whether the agent needs write access. Read-only MCP tools with honest hints are nearly risk-free to ship. Write tools deserve the same review as a public admin endpoint, because functionally, that is what they are.
Have you exposed a Spring service as an MCP server yet, or are your agents still assembling HTTP calls by hand? I am curious what the integration looked like on your side, especially if you hit issues with the stateful transports. And if you want the client-side version of this guide, consuming MCP tools inside a Spring AI ChatClient, say so in the comments, that is the natural next piece.
I write about Java, Spring Boot, and AI every week, mostly from things I actually built or broke. Subscribe, it's free, and it is the only way you will catch the client-side follow-up.
Top comments (0)