Skip to main content

Class 15: Async and Stateless

Duration: ~35 minutes | Level: Advanced | Prerequisites: Class 14: Testing.


What We'll Cover

  • type: ASYNC on 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
Companion code

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
Nothing in this class changes the project

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

Stateless in the current specification

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.

Feature2025-11-25, which Spring AI 2.0.0 implements2026-07-28, the current revision
Session idthe Mcp-Session-Id header, assigned at initializeremoved (SEP-2567)
Handshakeinitialize, then notifications/initializedremoved; every request carries its protocol version and client capabilities in _meta (SEP-2575)
Elicitationthe server sends elicitation/create while the call is openthe server answers resultType: "input_required" and the client retries the same request
Progress notificationson the response stream of their own requestunchanged
Logginglogging/setLevel, then notifications/messagedeprecated (SEP-2577); logging/setLevel removed, and the level set per request in _meta
Roots and samplingthe server calls roots/list and sampling/createMessagedeprecated (SEP-2577)
Cross-call statethe session ida 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, where cancel_order stops and asks a person before it cancels anything
  • context.sample(...) and context.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:

ToolExtra parameter it declaresRegistered under STATELESS
get_orderMcpMeta (Class 13)yes
get_customer_ordersnoneyes
get_orders_by_statusnoneyes
update_order_statusnoneyes
recheck_shipmentsMcpSyncRequestContext (Class 11)no
cancel_orderMcpSyncRequestContext (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.

The list is shorter in 2026-07-28

The 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:

ArtifactRuntimeTransports
spring-ai-starter-mcp-servernonestdio only
spring-ai-starter-mcp-server-webmvcTomcatStreamable HTTP, stateless, SSE
spring-ai-starter-mcp-server-webfluxNettyStreamable HTTP, stateless, SSE

The client side has two:

ArtifactHTTP clientTransportsClient types
spring-ai-starter-mcp-clientthe JDK's HttpClientstdio, Streamable HTTP, SSESYNC or ASYNC
spring-ai-starter-mcp-client-webfluxWebClient, the reactive HTTP client from Spring WebFluxstdio, Streamable HTTP, SSESYNC 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

Sources