Class 15: Async and Stateless
Duration: ~25 minutes | Level: Advanced | Prerequisites: Class 14: Testing.
What We'll Cover
type: ASYNCon the server and the client, and the reactive return types- The filtering that drops methods without failing, and the log lines that report it
protocol: STATELESS, and the three capabilities it removes- The WebFlux starters, and SSE
- Which combination to choose
Two Choices, Made Separately
Class 2 set two properties and moved on:
spring:
ai:
mcp:
server:
protocol: STREAMABLE # SSE | STREAMABLE | STATELESS
type: SYNC # SYNC | ASYNC
They are independent and they mean different things.
type is about threads. SYNC methods block and return ordinary values; ASYNC methods return Mono or Flux and do not.
protocol is about whether the server keeps state per client. STREAMABLE and SSE do; STATELESS does not.
An asynchronous stateful server is normal. So is a synchronous stateless one. Deciding them together is what causes confusion.
Going Reactive
Set the server type and change the return types:
spring:
ai:
mcp:
server:
type: ASYNC
@McpTool(name = "get_order", description = "...")
public Mono<Order> getOrder(@McpToolParam(description = "...") String orderId) {
return orderService.findByIdReactive(orderId);
}
The client changes the same way:
spring:
ai:
mcp:
client:
type: ASYNC
and injects List<McpAsyncClient> rather than List<McpSyncClient>. The notification handlers from Classes 11 to 13 return Mono<Void> instead of void, and @McpElicitation returns Mono<ElicitResult>.
The McpClientCustomizer from Class 13 takes McpClient.AsyncSpec instead of McpClient.SyncSpec. A customizer left on the sync type is simply not applied, with nothing to say so.
The filtering nobody expects
This is the part worth knowing before changing anything. A synchronous provider does not fail on a reactive method. It skips it.
SYNC Providers don't support reactive return types. Skipping method
public reactor.core.publisher.Mono com.themcpguy.supportdesk.orders.mcp.OrderTools.getOrder(java.lang.String)
with reactive return type class reactor.core.publisher.Mono
The application starts. Registered tools: reports a smaller number. tools/list returns fewer tools, and nothing in the running system says why.
It works in both directions: an ASYNC provider skips the plain-return methods the same way. So converting a class halfway leaves half the tools registered and half silently gone, whichever type is configured.
Two things follow. Convert a whole application rather than a class, and let the registration test from Class 14 catch it. That test asserts on the exact set of tool names, which is precisely the assertion that fails when a method is filtered out.
Whether to bother
Reactive is worth it when the server holds many concurrent connections that spend their time waiting, and when the work behind the tools is reactive already. A JPA repository is not, so order-service would be wrapping blocking calls in Mono.fromCallable(...) on a bounded elastic scheduler, which adds machinery without adding throughput.
The support desk stays SYNC. The reason to know this section is that a server someone else wrote may be ASYNC, and the filtering above is what happens when the two are mixed.
Going Stateless
spring:
ai:
mcp:
server:
protocol: STATELESS
A stateless server keeps nothing between requests. There is no session, no Mcp-Session-Id, and no open channel from the server to the client. Every request stands alone, so any instance can serve any request, and scaling out means adding instances behind a load balancer with no session affinity.
That is a real operational advantage, and it costs the whole of Classes 11 to 13.
What goes
Bidirectional operations need a connection the server can send a request down. Without one:
context.elicit(...): the confirmation in Class 12context.sample(...): Class 13context.roots(): Class 13
Progress and logging notifications also need somewhere to go.
Spring AI handles this the same way as the reactive mismatch. It skips the methods:
Stateless servers doesn't support bidirectional parameters. Skipping method
public java.lang.String com.themcpguy.supportdesk.orders.mcp.OrderTools.cancelOrder(
org.springframework.ai.mcp.annotation.context.McpSyncRequestContext,java.lang.String)
cancel_order and recheck_shipments are gone from tools/list. The other four tools, the resources and the prompt are all still there, because none of them takes a context.
A stateless server can still take a context parameter, but a different one: McpTransportContext, from io.modelcontextprotocol.common. It carries transport-level information and offers none of the bidirectional operations.
The shape this pushes you towards
Losing elicitation does not mean losing confirmation. It means the confirmation moves.
Instead of cancel_order asking mid-call, the server exposes two tools: request_cancellation, which records an intent and returns a token, and confirm_cancellation, which takes the token and does the work. The client shows the dialog between the two calls. The state lives in the database rather than in a parked thread.
That is more work and it scales horizontally, and it is close to where the specification is heading anyway. Class 13 described the Multi Round-Trip Requests pattern in the 2026-07-28 revision, which makes this the standard shape rather than a workaround.
The Starters, and SSE
Class 2 chose spring-ai-starter-mcp-server-webmvc because the starter application is Spring MVC. The full set:
| Artifact | Runtime | Transports |
|---|---|---|
spring-ai-starter-mcp-server | none | stdio only |
spring-ai-starter-mcp-server-webmvc | Tomcat | Streamable HTTP, stateless, SSE |
spring-ai-starter-mcp-server-webflux | Netty | Streamable HTTP, stateless, SSE |
And on the client side, spring-ai-starter-mcp-client uses the JDK's HttpClient, while spring-ai-starter-mcp-client-webflux uses WebClient. Either supports stdio, Streamable HTTP and SSE, with sync or async clients.
SSE is deprecated as of Spring AI 2.0.0, and the transport it implements was replaced in the specification by Streamable HTTP. The client properties still exist:
spring:
ai:
mcp:
client:
sse:
connections:
legacy-server:
url: http://localhost:9000
sse-endpoint: /sse
Use them to reach an older server that has not moved yet. Do not publish a new one over SSE. Class 2 explained that Spring AI 1.1.x defaulted protocol to sse, which is why a server built against that line and upgraded without setting the property changes its endpoint.
Choosing
For most Spring applications, and for this course:
SYNC and STREAMABLE. The code is ordinary Java, the whole protocol surface is available, and one process handles a support desk's traffic without difficulty.
Change one of them when there is a reason:
ASYNCwhen the work behind the tools is already reactive, or the server holds many mostly-idle connections.STATELESSwhen the server has to scale horizontally without session affinity, and nothing it does needs to ask the client anything.stdiowhen the server is launched by the client rather than deployed. Class 17.
Trying to have STATELESS and elicitation is the combination that does not work, and the way it fails, a tool quietly missing from the list, is the reason this class exists.
What We Built
Nothing, in the sense that the support desk is unchanged: it stays SYNC and STREAMABLE. What this class adds is knowing what the other settings do, what they cost, and that both mismatches are reported once at startup in a line that is easy to miss.
Next: Class 16: Production. Health, metrics, retries and timeouts.