Building MCP Servers with Spring Boot 4.1 and Spring AI 2.0: Expose Your Java Backend to AI Agents
A production-ready guide to turning existing Spring Boot services into MCP servers using Spring AI 2.0's @McpTool annotations and Streamable HTTP transport — so AI agents like Claude and GPT can discover and call your business logic directly.

Three months ago, our platform team at a fintech client got a request that sounded simple: "Let the internal AI assistant check a customer's KYC status and retrieve their transaction history." The assistant was Claude, running through Claude Code on the ops team's machines. The initial plan was to write a custom integration layer — a small service that translated natural language intent into REST calls, parsed the responses, and fed structured data back to the model.
We estimated two weeks. The integration itself was straightforward, but maintaining it was the problem. Every time the underlying API changed — a field renamed, a new query parameter, an enum expanded — someone had to update the glue code, redeploy the integration layer, and hope the model's function definitions still matched reality. We'd built this kind of translation layer before for chatbot integrations, and it always rotted faster than the services it wrapped.
Then someone on the team pointed out that Spring AI 2.0 had shipped MCP server support. The Model Context Protocol — Anthropic's open standard for connecting AI agents to external tools — meant we could expose our existing Spring Boot services directly, with tool discovery, type-safe parameter binding, and schema generation handled by the framework. No glue code. No separate integration layer. The AI agent connects to the MCP server, discovers what tools are available, and calls them with structured arguments.
We replaced the two-week estimate with a three-day spike. It took two days, including tests. This post covers everything we learned building production MCP servers with Spring Boot 4.1 and Spring AI 2.0, including the mid-2026 deprecation of SSE transport in favor of Streamable HTTP that caught half the early adopters off guard.
What MCP Actually Solves
If you've built integrations between AI models and backend services, you've felt the pain MCP addresses. Without a protocol, every integration is bespoke. You define function schemas in JSON, write serialization logic, handle error mapping, manage authentication, and maintain version compatibility — all by hand, for every tool, for every model provider.
MCP standardizes this. It defines a protocol for tool discovery (the agent asks "what can you do?"), tool invocation (the agent calls a specific tool with typed arguments), and resource exposure (the server provides data the agent can read). Think of it as what OpenAPI did for REST clients, but designed specifically for AI agent consumption.
The critical difference from OpenAPI: MCP tools carry semantic descriptions that help models understand when and why to call them, not just how. An OpenAPI spec describes endpoints. An MCP tool description says "Use this to check whether a customer has completed identity verification. Returns the verification status, the timestamp of last check, and any pending document requirements." That context is what lets an agent choose the right tool without a human writing routing logic.
Spring AI 2.0 makes this a first-class Spring concern. You annotate a method with @McpTool, and the framework handles schema generation from your Java types, parameter validation, transport negotiation, and error serialization. Your business logic stays exactly where it is — in your existing service beans.
The Transport Shift: SSE Is Dead, Streamable HTTP Is the Standard
If you followed MCP adoption in early 2026, you probably saw tutorials using Server-Sent Events (SSE) as the transport layer. Two endpoints — one for the SSE stream, one for client-to-server messages — with the client maintaining a persistent connection for server-pushed responses.
That approach is officially deprecated as of the MCP spec revision published in June 2026. The replacement is Streamable HTTP, and the reasons are practical, not theoretical.
SSE required two separate HTTP endpoints and a long-lived connection. Load balancers had to pin sessions. Horizontal scaling meant sticky routing. Kubernetes ingress controllers needed special configuration to avoid dropping the SSE connection during pod rollouts. Every ops team I talked to had at least one war story about SSE connections silently dying behind an AWS ALB with a 60-second idle timeout.
Streamable HTTP collapses everything into a single endpoint that handles both directions. The client sends a POST with the JSON-RPC request. The server can respond with a single JSON-RPC response (for simple request/reply tools) or upgrade to an SSE stream within that same HTTP response for long-running operations that need progress updates. No persistent connections. No sticky sessions. Standard HTTP load balancing works out of the box.
Spring AI 2.0.1 (released August 2026) defaults to Streamable HTTP. If you're starting fresh, you won't even see SSE configuration options unless you explicitly opt in with a compatibility flag. If you're migrating from an earlier Spring AI MCP setup, the change is a dependency swap and a property update — I'll cover that below.
Setting Up the MCP Server
Dependencies
Start with a standard Spring Boot 4.1 project. The MCP server support lives in a single starter:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.3</version>
</parent>
<dependencies>
<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-web</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
The spring-ai-starter-mcp-server-webmvc starter pulls in the MCP protocol handler, the Streamable HTTP transport, JSON-RPC serialization, and auto-configuration for tool discovery. If you're using WebFlux instead of WebMVC, swap to spring-ai-starter-mcp-server-webflux — same annotations, reactive return types.
Application Configuration
spring:
ai:
mcp:
server:
name: fintech-kyc-service
version: 1.0.0
transport: streamable-http
endpoint: /mcp
session:
stateless: true
The stateless: true setting is the key for production deployments. It tells the server not to track session state between requests, which means any pod in your Kubernetes deployment can handle any MCP request. No sticky sessions, no session affinity, no shared session store. Each request carries everything the server needs to process it.
The endpoint: /mcp puts the Streamable HTTP handler at /mcp — a single URL that handles tool discovery, tool invocation, and capability negotiation via JSON-RPC method dispatch.
Turning Business Logic into MCP Tools
This is where the magic happens, and where Spring AI earns its keep. You don't write protocol handlers, schema generators, or serialization code. You annotate existing service methods.
Here's a real example from our KYC service. The original code was a standard @Service bean called from REST controllers:
@Service
public class KycVerificationService {
private final CustomerRepository customerRepository;
private final DocumentStore documentStore;
private final AuditLogger auditLogger;
public KycVerificationService(CustomerRepository customerRepository,
DocumentStore documentStore,
AuditLogger auditLogger) {
this.customerRepository = customerRepository;
this.documentStore = documentStore;
this.auditLogger = auditLogger;
}
@McpTool(description = "Check whether a customer has completed identity verification. "
+ "Returns current KYC status, verification tier, the timestamp of the last "
+ "document review, and any pending requirements the customer must fulfill "
+ "before their account is fully verified.")
public KycStatusResponse getVerificationStatus(
@McpToolArg(description = "The unique customer identifier, formatted as CUS-XXXXXXXX")
String customerId) {
Customer customer = customerRepository.findByExternalId(customerId)
.orElseThrow(() -> new CustomerNotFoundException(customerId));
auditLogger.logToolAccess("kyc_status_check", customerId, "mcp");
KycRecord record = customer.getKycRecord();
List<String> pendingDocs = documentStore.findPendingRequirements(customer.getId());
return new KycStatusResponse(
customer.getExternalId(),
record.getStatus().name(),
record.getVerificationTier(),
record.getLastReviewedAt(),
pendingDocs,
record.getStatus() == KycStatus.APPROVED
);
}
@McpTool(description = "Retrieve a customer's recent transaction history within a date range. "
+ "Returns up to 100 transactions sorted by timestamp descending. Each transaction "
+ "includes the amount, currency, counterparty name, transaction type, and status. "
+ "Use this when a support agent or compliance officer needs to review account activity.")
public TransactionHistoryResponse getTransactionHistory(
@McpToolArg(description = "Customer identifier in CUS-XXXXXXXX format")
String customerId,
@McpToolArg(description = "Start date for the query range, ISO-8601 format (yyyy-MM-dd)")
LocalDate from,
@McpToolArg(description = "End date for the query range, ISO-8601 format (yyyy-MM-dd)")
LocalDate to) {
if (from.isAfter(to)) {
throw new InvalidDateRangeException(from, to);
}
if (ChronoUnit.DAYS.between(from, to) > 90) {
throw new DateRangeExceededException(90);
}
Customer customer = customerRepository.findByExternalId(customerId)
.orElseThrow(() -> new CustomerNotFoundException(customerId));
auditLogger.logToolAccess("transaction_history", customerId, "mcp");
List<TransactionSummary> transactions = customer.getTransactions().stream()
.filter(tx -> !tx.getTimestamp().toLocalDate().isBefore(from))
.filter(tx -> !tx.getTimestamp().toLocalDate().isAfter(to))
.sorted(Comparator.comparing(Transaction::getTimestamp).reversed())
.limit(100)
.map(tx -> new TransactionSummary(
tx.getReferenceId(),
tx.getAmount(),
tx.getCurrency(),
tx.getCounterpartyName(),
tx.getType().name(),
tx.getStatus().name(),
tx.getTimestamp()
))
.toList();
return new TransactionHistoryResponse(
customerId,
from,
to,
transactions.size(),
transactions
);
}
}
That's it. Two annotations — @McpTool on the method, @McpToolArg on each parameter — and the framework handles everything else. Spring AI inspects the method signature, generates a JSON Schema for the input parameters and the return type, registers the tool with the MCP server's capability manifest, and handles invocation through the Streamable HTTP transport.
The KycStatusResponse and TransactionHistoryResponse are plain Java records. Spring AI serializes them to JSON automatically, and the generated schema tells the AI agent exactly what fields to expect:
public record KycStatusResponse(
String customerId,
String status,
int verificationTier,
Instant lastReviewedAt,
List<String> pendingRequirements,
boolean fullyVerified
) {}
public record TransactionHistoryResponse(
String customerId,
LocalDate queryFrom,
LocalDate queryTo,
int resultCount,
List<TransactionSummary> transactions
) {}
When an AI agent connects to http://your-service:8080/mcp and sends a tools/list request, it receives a manifest describing both tools with their full parameter schemas and semantic descriptions. The agent then decides, based on the user's request, which tool to call, constructs the arguments, and invokes it — all without a human writing routing or parsing logic.
Security: Don't Skip This Section
Exposing business logic to AI agents introduces a new attack surface. The MCP endpoint is an API, and it needs the same security posture as any other API — arguably more, because the caller is making autonomous decisions about what to invoke.
Authentication and Authorization
In production, we put the MCP endpoint behind the same OAuth 2.0 / JWT authentication that protects our REST APIs. Spring Security configuration applies to the /mcp path like any other:
@Configuration
@EnableWebSecurity
public class McpSecurityConfig {
@Bean
public SecurityFilterChain mcpFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/mcp/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/mcp").hasAuthority("SCOPE_mcp:invoke")
.anyRequest().denyAll()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(mcpJwtConverter()))
)
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
private Converter<Jwt, AbstractAuthenticationToken> mcpJwtConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
List<String> scopes = jwt.getClaimAsStringList("scope");
if (scopes == null) return Collections.emptyList();
return scopes.stream()
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
.collect(Collectors.toList());
});
return converter;
}
}
The mcp:invoke scope is issued to service accounts assigned to specific AI agent deployments. The ops team's Claude instance gets a token with mcp:invoke and kyc:read. A compliance team's agent might get mcp:invoke, kyc:read, and transactions:read. The authorization logic in your tool methods can inspect the security context to enforce fine-grained access based on who — or what — is calling.
Rate Limiting and Input Validation
AI agents can be enthusiastic callers. An agent trying to answer a question like "summarize this customer's last year of activity" might call your transaction history tool twelve times in rapid succession with different date ranges. Set rate limits on the MCP endpoint just like you would for any external API.
Input validation in the tool methods themselves is critical. The @McpToolArg descriptions guide the model toward correct inputs, but models hallucinate parameter values. I've seen Claude construct a customer ID in the right format (CUS-12345678) that doesn't exist in our system, and I've seen it pass date ranges that span five years when the description says 90 days max. Validate everything. Throw clear exceptions with messages the model can interpret — "Date range exceeds maximum of 90 days" is more useful to the model than a stack trace.
What We Learned Running This in Production
Tool Descriptions Are Prompt Engineering
The text in your @McpTool description is, functionally, a system prompt for that specific capability. Vague descriptions lead to incorrect tool selection. Overly terse descriptions lead to missing context. We went through three iterations of our tool descriptions before the agent consistently chose the right tool for the right query.
The pattern that works: state what the tool does, what it returns, and when to use it. "Check whether a customer has completed identity verification" is the what. "Returns the verification status, the timestamp of last check, and any pending document requirements" is the returns. "Use this when a support agent or compliance officer needs to verify a customer's KYC standing" is the when. All three matter.
Return Types Shape Agent Behavior
If your tool returns a giant blob of JSON with fifty fields, the agent has to parse and summarize it, which increases latency and sometimes produces hallucinated summaries. If your tool returns a focused response with exactly the fields relevant to the use case, the agent passes it through cleanly.
We refactored several tool return types to be narrower than their REST counterparts. The REST endpoint for transaction history returns full transaction objects with internal IDs, audit metadata, and processing timestamps. The MCP tool returns TransactionSummary — just the fields an agent or end-user would actually need. Same underlying data, different projection. This is not a new idea. It's the same principle behind GraphQL, BFF patterns, and view models. The consumer's needs should shape the response, not the storage model.
Monitoring MCP Calls Separately
We tag MCP-originated requests in our distributed tracing setup with a channel=mcp attribute. This lets us track agent-driven traffic separately from human-driven REST traffic in Grafana. The usage patterns are different — agents tend to make bursts of calls in tight sequences, while humans spread requests across longer sessions. Having separate dashboards for each channel made capacity planning much clearer.
Stateless Mode and Horizontal Scaling
Running with session.stateless: true was the single most important operational decision. Early MCP server implementations (including Spring AI's pre-2.0 builds) defaulted to stateful sessions, which meant session affinity in the load balancer, session replication across pods, and a whole category of failure modes around session loss during deployments. Stateless mode eliminates all of that. Each JSON-RPC request is independent. Any pod handles any request. Pod restarts, scaling events, and rolling deploys are invisible to the client.
The tradeoff is that you lose server-side conversation context between calls. If a tool call sequence depends on state from a previous call, that state needs to live in the client (the AI agent) or in your database — not in an MCP session object. For our use cases, this was never a problem. Each tool call is self-contained: give me a customer ID, I'll give you their KYC status. No multi-step wizards, no transactional conversations.
Migrating from SSE to Streamable HTTP
If you built an MCP server with Spring AI before August 2026, you're on SSE transport. The migration is minimal but important — SSE support will be removed entirely in Spring AI 2.1, scheduled for Q1 2027.
Step 1: Update your dependency from spring-ai-starter-mcp-server-webmvc-sse to spring-ai-starter-mcp-server-webmvc. The non-suffixed starter defaults to Streamable HTTP.
Step 2: Remove the old SSE configuration properties (spring.ai.mcp.server.sse-endpoint, spring.ai.mcp.server.message-endpoint) and replace with:
spring:
ai:
mcp:
server:
transport: streamable-http
endpoint: /mcp
Step 3: Update your clients. If AI agents were connecting to your SSE endpoint, they need to point at the new Streamable HTTP URL. Claude Code and the Anthropic SDK have supported Streamable HTTP since mid-2026. GPT-based agents using the OpenAI function calling bridge need a client update — check your MCP client library version.
Step 4: Remove any sticky session configuration from your load balancer. You don't need it anymore.
When MCP Is the Wrong Choice
MCP is not a replacement for REST APIs, GraphQL, or gRPC. It's a protocol designed for AI agent consumption. If your consumers are other microservices, mobile apps, or web frontends, keep using the protocols designed for those consumers.
MCP also adds overhead compared to direct function calling. The JSON-RPC layer, tool discovery negotiation, and schema exchange add latency — typically 10-30ms per call in our measurements. For an AI agent making five tool calls to answer a question, that's an extra 50-150ms on top of the model's own inference time. Barely noticeable in an agent workflow where the model itself takes 2-4 seconds to reason. Completely unacceptable for a hot path in a low-latency trading system.
Use MCP when: AI agents need to discover and call your services. Your business logic changes frequently and you don't want to maintain separate tool definitions. You want type-safe, framework-managed integration instead of hand-rolled JSON schemas.
Don't use MCP when: your consumers are deterministic programs, not AI models. Latency is measured in single-digit milliseconds. You need binary protocols or streaming data pipelines.
Where This Is Heading
We now have six MCP servers running across three teams, exposing forty-two tools to internal AI agents. The ops team uses Claude to investigate customer issues by querying KYC, transaction, and account services through MCP. The compliance team runs automated checks that pull data from multiple services and flag anomalies. A product manager built a Slack bot backed by an AI agent that can answer "how many customers completed onboarding this week?" by calling our analytics MCP server.
None of these required custom integration code beyond the @McpTool annotations. When we added a new field to the KYC response last month, every agent that calls that tool automatically received the new field in its next invocation. No client updates. No redeploys of glue code. The schema stays in sync with the code because the schema is the code.
The shift from building AI integrations to exposing AI-ready interfaces mirrors what happened with REST a decade ago. We stopped building custom SOAP envelopes and started exposing resources. Now we're stopping building custom function-calling adapters and starting to expose tools. The protocol handles the plumbing. The framework handles the wiring. You handle the business logic.
If you're running Spring Boot services and fielding requests to "make this accessible to AI," start with one service, two or three tools, and the stateless Streamable HTTP configuration. Have the agent call your tools, watch the traces, iterate on the descriptions. You'll have a working MCP server in production before the end of the week.
The code patterns in this post come from real production systems I've built across fintech and airline platforms. If you're working through a similar integration and want to compare notes, reach out.
Related Articles
- How a Single Trace Ended a 3-Day Debugging Nightmare Across 8 Microservices — The distributed tracing setup we use to monitor MCP traffic separately from REST calls.
- AI-Driven Development with Claude Code: Running Autonomous Agents Across Microservices — How I use Claude Code agents in daily development, including the agent workflows that consume these MCP servers.
- Zero-Downtime Database Migrations: How We Changed 40 Tables Without Losing a Single Request — The deployment patterns that make stateless MCP servers safe to roll out without downtime.