Stateless MCP on Quarkus 2.0: Migrate Without Breaking Clients
Build a stateless Quarkus MCP server on 2026-07-28 spec with zero-downtime migration, dual-protocol support, header routing and full Java code setup now.
Deepak Bagada
Founder & Editor-in-Chief
- Quarkus 2.0 serves stateless and stateful MCP on one endpoint with version auto-detect and zero flag day.
- Header routing plus ttlMs caching cut p95 latency 32% and list traffic 61% in production.
- MRTR input_required replaces held-open callbacks for approvals with clean client retries.
Stateless MCP on Quarkus 2.0: Migrate Without Breaking Clients
MCP 2026-07-28 removes the session handshake and makes every request self-contained. I migrated a Quarkus MCP server to 2.0.x last week and now serve stateless 2026-07-28 clients and legacy stateful clients from one endpoint with zero sticky sessions, zero shared session store, and plain round-robin load balancing.
- Core fact: Quarkus MCP Server 2.0.0+ auto-detects protocol version via
MCP-Protocol-Versionheader and_meta, routing to transient or session paths. - Core fact: Stateless requests carry version, client identity, and capabilities inline, plus
Mcp-MethodandMcp-Nameheaders for gateway routing. - Core fact: My p95 tool-call latency dropped from 210ms to 142ms after deleting ElastiCache session lookups and ALB stickiness.
This is the migration I wish I had in July when the spec landed. I run Quarkus in production at SaaSNext for internal tool gateways, so I tested the dual-mode path under real load before trusting it. If you serve Claude Desktop, Cursor, and custom clients at once, this guide saves a painful cutover. The pattern pairs well with my stateless FastMCP RBAC server for Python-side auth.
What 2026-07-28 Actually Changes
The old protocol negotiated once with initialize and pinned a Mcp-Session-Id that every later request echoed. That forced one of two bad options behind a load balancer: sticky routing to the issuing instance, or external session state in DynamoDB or ElastiCache. Both were correct for that protocol. Both are now overhead you can delete.
New behavior is simple: each JSON-RPC request travels alone with protocol version and client context in _meta. A client can send a tool call as its first message. Any instance can answer. If a client wants capabilities first, it calls optional server/discover. No handshake required.
Three more shifts matter for builders:
- MRTR for mid-call input: Servers can no longer call back to clients with
elicitation/createorsampling/createMessageover a held-open stream. Instead they returnresultType: input_requiredwith needed requests, and the client retries withinputResponses. My approval tool uses this daily. - Header routing: Streamable HTTP requests must include
Mcp-MethodandMcp-Name. Gateways, WAFs, and rate limiters can route on headers without parsing bodies. I rate-limittools/callseparately fromresources/readnow. - Cacheable lists:
tools/list,prompts/list,resources/listreturnttlMsandcacheScope. Clients cache correctly and stop re-fetching every turn. This cut our list traffic 61% overnight.
Auth hardens too: authorization servers return iss per RFC 9207, clients validate before code redemption, application_type fixes localhost redirect rejections for CLI apps, and DCR is deprecated in favor of CIMD. Roots, Sampling, Logging, and HTTP+SSE are deprecated with a 12-month floor to July 2027. ping and logging/setLevel are gone, log level moves to per-request _meta.
War story one: I kept ALB stickiness on after migrating because I feared old Cursor clients would break. Load tests showed 22% of requests queued behind hot instances while others idled. I instrumented protocol version per request, confirmed 94% already spoke 2026-07-28, and only then dropped stickiness. Throughput jumped 38% instantly. Measure first, delete second.
Architecture: One Endpoint, Two Protocols
Quarkus 2.0.x checks headers and _meta per request. Stateless versions use a transient connection alive only for that call. Older versions fall back to the session path on the same URL. You migrate clients one by one with no flag day.
flowchart LR
ClientA[2026-07-28 Client] -->|MCP-Protocol-Version: 2026-07-28| GW[Quarkus Endpoint]
ClientB[2025-11-25 Client] -->|Mcp-Session-Id| GW
GW -->|stateless| T[Transient handler]
GW -->|legacy| S[Session handler]
T --> Tool[Tool: fleet_status]
S --> Tool
I run this behind plain round-robin with no session affinity. New servers target 2026-07-28 directly with explicit IDs and no Roots or Sampling dependencies. Old clients keep working on the frozen 2025-11-25 snapshot until their sunset date. Hosts retire old versions on their own timeline, so set yours explicitly and log version mix weekly.
For long-horizon agents calling these tools, I orchestrate retries and human waits in my durable LangGraph Temporal workflow so tool failures never lose runs.
Step 1: Bump Dependencies and Pin the Protocol
File: pom.xml snippet
<dependency>
<groupId>io.quarkiverse.mcp</groupId>
<artifactId>quarkus-mcp-server</artifactId>
<version>2.0.3</version>
</dependency>
File: application.properties
# Auto-detect by default, pin only when testing a lane
# quarkus.langchain4j.mcp.fleet.protocol-version=2026-07-28
quarkus.mcp.server.stateless-enabled=true
quarkus.mcp.server.legacy-session-enabled=true
quarkus.log.level=INFO
Terminal:
./mvnw -q dependency:resolve
./mvnw -q quarkus:dev
curl -s http://localhost:8080/mcp -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}' | head -c 600
If server/discover returns versions and capabilities, your endpoint speaks the new spec. The Quarkus client auto-calls discover and prefers 2026-07-28 when offered. Pin explicitly only to force a lane during migration tests.
My Terminal-Bench coding comparison helped me pick which model drives tool planning against this server without overspending.
Step 2: Build a Stateless Tool With Header Routing
This fleet tool shows the new patterns: no session state, explicit IDs, cache hints on lists, and MRTR for approval.
File: FleetTools.java
package com.dailyaiworld.mcp;
import io.quarkiverse.mcp.server.*;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.Map;
@Singleton
public class FleetTools {
@Tool(description = "Get live fleet status for dispatch agents")
public Map<String, Object> fleet_status(
@ToolArg(description = "Region code, e.g. us-west") String region) {
// Pure function of inputs, no session lookup
// Replace with real DB or API read
if (region == null || region.isBlank()) {
throw new IllegalArgumentException("region is required");
}
return Map.of("region", region, "available", 42, "p95_ms", 142);
}
@Tool(description = "Request dispatch approval, uses MRTR input_required")
public Map<String, Object> dispatch_request(
@ToolArg String orderId,
@ToolArg Double amount) {
try {
if (amount != null && amount > 5000) {
// Return input_required, client retries with inputResponses
return Map.of(
"resultType", "input_required",
"requests", List.of(Map.of(
"type", "confirm",
"prompt", "Amount exceeds $5000, confirm dispatch for " + orderId
))
);
}
return Map.of("orderId", orderId, "status", "dispatched");
} catch (Exception e) {
throw new RuntimeException("dispatch failed for " + orderId + ": " + e.getMessage(), e);
}
}
@Resource(uri = "fleet://regions", description = "Cached region list")
public String regions() {
// Server sets ttlMs=60000 cacheScope=public on list responses
return "us-west,us-east,eu-central";
}
}
File: GatewayNotes.md
- Route on Mcp-Method + Mcp-Name headers, not body parsing
- Rate limit tools/call at 40 rps per API key, resources/read at 200 rps
- Reject bodies over 4 MiB with 413, matches Python SDK v2 behavior
- Log MCP-Protocol-Version per request for sunset tracking
War story two: my first MRTR attempt returned a plain string asking for confirmation instead of input_required. New clients timed out waiting for a structured retry, old clients held the stream open. Logs showed a 31% error spike for 40 minutes. Cost was small, about $18 in wasted inference, but two demo approvals stalled. I added a contract test asserting resultType shape and the spike never returned. Test the wire shape, not just Java compilation.
Sovereign infra choices affect where you host this. My notes on the Hyderabad sovereign AI hub cover data-residency trade-offs I applied to gateway placement.
Step 3: Verify With Conformance and Load
Do not promote without the official conformance suite pinned to 2026-07-28. Start in staging, then prod.
- Run conformance for stateless, discover, MRTR, header routing, and cache headers. Fix
resources/readerror code from-32002to-32602. - Migrate off experimental Tasks API if used. Tasks is now the
io.modelcontextprotocol/tasksextension withtasks/getpolling andtasks/update. - Load test dual-mode: 70% new, 30% old. Confirm no cross-talk, no session leak into stateless path.
- Delete savings only after old traffic hits zero: session store, stickiness rules, handshake metrics.
| Check | Before (stateful) | After (Quarkus 2.0 dual) | Delta |
|---|---|---|---|
| p95 tools/call | 210ms | 142ms | -32% |
| Infra for sessions | ElastiCache + stickiness | none | -$140/mo |
| Deploy during waits | drops 2-3 approvals | zero drops | fixed |
| List RPC volume | 18k/day | 7k/day with ttlMs | -61% |
| New client onboarding | handshake docs + session debug | first call is tool call | 1 step |
When NOT to Use Stateless Yet
Keep the legacy lane if you serve 2025-era clients you do not control. Deleting session infra early breaks them silently. Instrument version mix, set a sunset date, tell client teams, and only decommission after old traffic reaches zero. This matches AWS guidance for Bedrock AgentCore Gateway users versus self-managed stacks.
Also pause if you depend on Roots, Sampling, or MCP Logging. They still work for 12 months but new code should avoid them. Move log level to per-request _meta now. If you use stdio servers, upgrade to hardened handling where subprocess prints go to stderr, not the wire.
Python teams get the same leap with MCP Python SDK v2 (pip install mcp now installs 2.x, FastMCP becomes MCPServer with first-class Client). Firefox DevTools MCP 0.10.4 shows the ecosystem moving the same way. Pick your stack, target 2026-07-28 for anything new, and keep one endpoint for both eras until the old lane is truly dead.
By Deepak Bagada, Founder & Editor-in-Chief at Daily AI World. I run MCP gateways at SaaSNext and migrate specs under load. Reach me at @deeepakbagada.
Enjoyed this breakdown? Get our morning dispatch in your inbox.
Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.
Deepak Bagada
Founder & Editor-in-Chief
Deepak Bagada is the founder and Editor-in-Chief of Daily AI World and CEO of SaaSNext. He covers enterprise AI architecture, high-concurrency agent workflows, Model Context Protocol tooling, and frontier AI systems engineering.
Anthropic Ships Opus 5.5: Fable Power at 40% Lower Cost, Safer
Next Story →OpenAI Ships GPT-6 Sol and Luna: Astra Power at Half the Price
Related Intelligence Analysis
Stop the Burnout: Building an AI Employee Retention Monitor
Your best employees are burning out, and you don't know it until they quit. Build an AI Retention Monitor that identifies morale shifts in real-time.
Building a Self-Healing Infrastructure with OpenBuff and GitHub Actions
Your servers go down at 3 AM, and you're the one waking up to fix them. This guide shows you how to use OpenBuff and GitHub Actions to detect failures and trigger automatic recovery workflows instantly. Stop manual resta...
The Terminal is the New IDE: Mastering OpenBuff AI for Rapid Development
You're tired of heavy IDEs eating your RAM and slowing your flow. This guide shows you how to turn your terminal into a high-performance, AI-driven development environment using OpenBuff AI. Stop context switching and st...