Class 15: Async and Stateless
Duration: ~35 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 logs that report it
protocol: STATELESS, and what it takes away- The WebFlux starters, and SSE
- Which combination to choose
This class carries on from Class 14. If you followed along, keep working in the project you
already have. If you skipped it, clone the class_14
branch to start from the same place:
git clone --branch class_14 https://github.com/the-mcp-guy/spring-ai-mcp-course.git
We set each of the two properties for a moment to see what happens, then put it back, so order-service ends the class exactly as it started, on SYNC and STREAMABLE. The two-tool design in the stateless section is explained without being built.
Two Choices, Made Separately
In Class 2 we set two properties on the MCP server in order-service without going into what they do:
spring:
ai:
mcp:
server:
protocol: STREAMABLE # SSE | STREAMABLE | STATELESS
type: SYNC # SYNC | ASYNC
type is about threads. A SYNC method holds its thread until the work is done, then returns an ordinary value. An ASYNC method returns a Mono or a Flux as soon as it is called, before the work has run, which leaves the thread free for something else. A Mono is a Project Reactor object that will carry one value later, a Flux one that will carry a stream.
protocol is about whether the server keeps state per client. STREAMABLE and SSE do; STATELESS does not.
An asynchronous stateful server is a normal combination, and so is a synchronous stateless one.
Going Reactive
To run the server reactive, meaning that every tool method hands back a Mono at once and the work finishes elsewhere, we would set the 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> instead of 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 not applied, and the log does not mention it, because the auto-configuration asks for the customizers by their type parameter, and under ASYNC it only looks for AsyncSpec.
Methods That Do Not Match Are Filtered Out
A synchronous provider does not fail on a reactive method. It logs a warning and leaves the method unregistered.
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 normally. The Registered tools: line reports a smaller count, and tools/list returns the reduced set.
It works in both directions: an ASYNC provider skips the plain-return methods the same way.
Spring AI runs this check once per annotated method at startup, in McpPredicates. Its second branch belongs to the stateless section further down:
Both WARN branches end at the same node as the registered one: an application that starts normally. So convert the whole application at once, and keep the registration test from Class 14, because it asserts on the exact set of tool names and fails as soon as a method is filtered out.
Should order-service Be Reactive?
Reactive is worth it 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, the pool Reactor keeps for work that blocks. That adds code but does not increase throughput, because every call still blocks a thread underneath.
People also reach for reactive when a server holds many connections that spend their time waiting. On Java 21 and later, virtual threads answer that without a reactive rewrite: spring.threads.virtual.enabled: true.
So order-service stays SYNC. If you changed the property above to ASYNC, set it back to SYNC now.
If a tool you wrote does not appear in tools/list, and the model behaves as though it does not exist, compare the method's return type with spring.ai.mcp.server.type.
Going Stateless
Spec revision 2026-07-28 removed the protocol-level session. Each request now arrives on its own, carrying everything the server needs to answer it. SEP below stands for Specification Enhancement Proposal, the numbered proposals the MCP specification is edited through.
| Feature | 2025-11-25, which Spring AI 2.0.0 implements | 2026-07-28, the current revision |
|---|---|---|
| Session id | the Mcp-Session-Id header, assigned at initialize | removed (SEP-2567) |
| Handshake | initialize, then notifications/initialized | removed; every request carries its protocol version and client capabilities in _meta (SEP-2575) |
| Elicitation | the server sends elicitation/create while the call is open | the server answers resultType: "input_required" and the client retries the same request |
| Progress notifications | on the response stream of their own request | unchanged |
| Logging | logging/setLevel, then notifications/message | deprecated (SEP-2577); logging/setLevel removed, and the level set per request in _meta |
| Roots and sampling | the server calls roots/list and sampling/createMessage | deprecated (SEP-2577) |
| Cross-call state | the session id | a server-minted handle passed as an ordinary tool argument |
The last row matters most here: an application can still keep state without a session, because a tool returns a handle and the model passes it back as an ordinary argument on the next call. The two tool calls below are an example.
Spring AI 2.0.0 implements the left column, which is why the protocol property below still offers STREAMABLE alongside STATELESS.
Set it, restart, and set it back to STREAMABLE afterwards:
spring:
ai:
mcp:
server:
protocol: STATELESS
A stateless server does not keep anything between requests. It works without a session, an Mcp-Session-Id, or an open channel from the server to the client. So scaling out, running several copies behind one address, does not need session affinity: pinning each client to the copy that holds its session.
What a Stateless Server Cannot Do
In an ordinary call the client asks and the server answers. Five operations go the other way, with the server sending something to the client while it is still handling that call. In 2025-11-25 they all travel on the streamed response of that call, which stays open until the tool returns:
context.elicit(...): the confirmation we added in Class 12, wherecancel_orderstops and asks a person before it cancels anythingcontext.sample(...)andcontext.roots(): Class 13- progress and logging notifications: Class 11
A STATELESS server answers with a single JSON object and is done, so the tool method cannot write to the client while the call runs. Spring AI skips every method that asks for a request context object, the same way it skips a reactive one:
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)
with bidirectional parameters
Two of the six tools are left out, and the extra parameter each declares decides which:
| Tool | Extra parameter it declares | Registered under STATELESS |
|---|---|---|
get_order | McpMeta (Class 13) | yes |
get_customer_orders | none | yes |
get_orders_by_status | none | yes |
update_order_status | none | yes |
recheck_shipments | McpSyncRequestContext (Class 11) | no |
cancel_order | McpSyncRequestContext (Class 12) | no |
McpPredicates looks for exactly four types: McpSyncRequestContext, McpAsyncRequestContext, McpSyncServerExchange and McpAsyncServerExchange. The McpMeta get_order gained in Class 13 is not one of them, so it stays, along with the resources and the prompt.
A stateless server can still take a context parameter, but a different one: McpTransportContext, from io.modelcontextprotocol.common. It carries transport-level information, such as HTTP request metadata, and does not provide any of the operations above.
2026-07-28The list above describes Spring AI 2.0.0 and spec 2025-11-25. In the current revision elicitation survives as Multi Round-Trip Requests, progress notifications survive, and roots, sampling and logging are deprecated under SEP-2577, whichever protocol you choose.
Rewriting the Confirmation as Two Tool Calls
cancel_order stays filtered out of tools/list. Getting the same confirmation back without elicitation means writing two new tools ourselves.
The first, request_cancellation, records the intent and returns a token. The second, confirm_cancellation, takes that token and does the work. The client shows the confirmation dialog between the two calls. The state that used to sit in a blocked server thread now sits in the database row the token identifies.
This is the stateful version from Class 12:
Everything between the first arrow and the last happens inside one call the server holds open, which is what a stateless server cannot do. Here each call finishes immediately, and the question is asked between them:
This design needs more code than the single elicitation call, and it scales horizontally, because neither call depends on the server holding anything open.
A token that is only looked up is an insecure direct object reference: whoever gets hold of it can spend it. Four properties prevent that:
- bound to the authenticated caller, and refused from anyone else
- tied to the one order it was minted for
- short-lived
- marked used inside the same transaction that cancels the order, so a replay cannot cancel twice
The two-tool design also gives up a guarantee: in Class 12 the server asked the person itself and waited, so it knew a person had answered. Here it only sees a token, and a model holding one can call confirm_cancellation without ever showing a dialog: OWASP's LLM06 Excessive Agency. Where the approval must be provable, check it somewhere the model cannot reach.
Multi Round-Trip Requests standardises the same round trip in 2026-07-28, with one difference. The client retries the same tool with a new request id, and the server's state travels back in an opaque requestState string, so the server does not store it. The two-tool design keeps that state in a database row instead, which is the server-minted handle SEP-2567 describes, and the specification asks for the same protections on requestState.
If you changed the property above to STATELESS, set it back to STREAMABLE now.
The Starters, and SSE
In Class 2 we chose spring-ai-starter-mcp-server-webmvc for order-service because it is a Spring MVC application. 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 |
The client side has two:
| Artifact | HTTP client | Transports | Client types |
|---|---|---|---|
spring-ai-starter-mcp-client | the JDK's HttpClient | stdio, Streamable HTTP, SSE | SYNC or ASYNC |
spring-ai-starter-mcp-client-webflux | WebClient, the reactive HTTP client from Spring WebFlux | stdio, Streamable HTTP, SSE | SYNC or ASYNC |
They differ only in which HTTP client carries the messages, so follow the rest of the application.
SSE stands for Server-Sent Events, a long-lived HTTP response the server pushes events onto. As of Spring AI 2.0.0 the SSE transport is deprecated on the server, 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, but do not publish a new server over SSE. Class 2 covered why the protocol property is worth writing down instead of relying on the default. Spring AI 1.1.x defaulted it to sse and published /sse and /mcp/message. Spring AI 2.0.0 defaults it to STREAMABLE and publishes /mcp. Upgrading a server that relied on the default therefore moves its endpoint, and clients still pointing at the old paths stop reaching it.
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 is enough for the traffic a support desk sees.
Two of the three questions below set type and protocol. The third sets spring.ai.mcp.server.stdio, which is a separate boolean property and not a third value of protocol:
order-service takes the SYNC and STREAMABLE path. In Class 17 we take the stdio branch, for Claude Desktop and Claude Code.
Scaling out means publishing the server where several instances can be reached, and Spring AI's default configuration accepts every request on /mcp: anyone who reaches an instance can list the tools and call update_order_status. Class 17 covers the TLS and the authorization that belong in front of it.
What We Built
The configuration is unchanged: order-service stays SYNC and STREAMABLE. What this class adds is what the other settings do, what they take away, and where to look when a method disappears: both kinds of mismatch are reported once at startup, in one warning.
Next: Class 16: Guardrails and Budgets. Writing an advisor, refusing a request before it costs anything, and capping what the agent may spend.
Further Reading
- Stateless Streamable-HTTP MCP Servers :: Spring AI Reference: the properties behind
protocol: STATELESSand the specification types the server builds in place of the stateful ones. - MCP Annotations Special Parameters :: Spring AI Reference: every parameter type a tool method may declare, and which of them a stateless server accepts.
- Deprecated Features :: Model Context Protocol: the register of every feature now in the deprecated state, which is where roots, sampling and logging sit.
- MCP Client Boot Starter :: Spring AI Reference: the client side of the same two choices, and the full SSE connection properties for reaching an older server.
- STDIO and SSE MCP Servers :: Spring AI Reference: the properties behind both deprecated SSE endpoints and the
spring.ai.mcp.server.stdioboolean. - Schedulers (reactor-core): what
boundedElastic()does, for checking the claim that wrapping JPA in it does not add throughput. - JEP 444: Virtual Threads: the Java 21 feature that handles many mostly-idle connections without a reactive rewrite.
- Upgrade Notes :: Spring AI Reference: the MCP changes in Spring AI 2.0.0, including the single generic
McpClientCustomizerthis class uses.
Sources
- Key Changes :: Model Context Protocol 2026-07-28: that
2026-07-28removed the protocol-level session and theMcp-Session-Idheader (SEP-2567) and theinitializehandshake (SEP-2575). Also that it deprecates roots, sampling and logging under SEP-2577, removeslogging/setLevel, keeps progress notifications on the response stream of their own request, and moves cross-call state to server-minted handles. - Multi Round-Trip Requests :: Model Context Protocol 2026-07-28: that the client retries the same request with a new id, and that the server's state travels in an opaque
requestStatethe client hands back. Also the security requirements this class applies to the cancellation token. - Transports :: Model Context Protocol 2025-11-25: that in the revision Spring AI 2.0 implements, a POST is answered with either one JSON object or an SSE stream. Also that the server may send its own requests and notifications on that stream before the response.
- MCP Server Boot Starter :: Spring AI Reference: that SSE WebMVC and SSE WebFlux are both deprecated since 2.0.0, that the default endpoint is
POST /mcp, and that the default configuration accepts every request on it without credentials. - McpPredicates.java, spring-projects/spring-ai: both warning messages quoted in this class, and the four parameter types whose presence makes a stateless provider skip a method.
- McpServerProperties.java, spring-projects/spring-ai: that
protocolhas exactlySSE,STREAMABLEandSTATELESSand defaults toSTREAMABLE. Also thattypehasSYNCandASYNC, and thatstdiois a separate boolean defaulting tofalse. - STDIO and SSE MCP Servers :: Spring AI Reference 1.1: the Spring AI 1.1.x defaults this class names,
/sseand/mcp/message. - McpClientAutoConfiguration.java, spring-projects/spring-ai: that
List<McpAsyncClient>appears only undertype: ASYNC. Also that each configurer takes its customizers through a provider keyed on theSyncSpecorAsyncSpectype parameter. - McpTransportContext.java, modelcontextprotocol/java-sdk: that it lives in
io.modelcontextprotocol.common, carries transport-level metadata such as HTTP request metadata, and exposes only aget(String key)accessor. - ProtocolVersions.java, modelcontextprotocol/java-sdk: that the newest revision the Java MCP SDK declares is
2025-11-25, which is why Spring AI2.0.0implements that one. - mcp-annotations, spring-ai-community: the filtering rules as documentation, and the async handler forms that return
Mono<Void>andMono<ElicitResult>. - LLM06:2025 Excessive Agency, OWASP Top 10 for LLM Applications: the named risk behind the approval guarantee the two-call design moves off the server, and the human-in-the-loop mitigation.
- Insecure Direct Object Reference Prevention Cheat Sheet, OWASP: why a cancellation token that is not bound to its caller, not expired and not single-use is a broken access control.