<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sindhuja Nagaraja Sudhakar</title>
    <description>The latest articles on DEV Community by Sindhuja Nagaraja Sudhakar (@sindhuja_sudhakar).</description>
    <link>https://dev.to/sindhuja_sudhakar</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4069897%2F71ed33a3-9719-4fe7-9c29-9bf605d9bca7.jpg</url>
      <title>DEV Community: Sindhuja Nagaraja Sudhakar</title>
      <link>https://dev.to/sindhuja_sudhakar</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sindhuja_sudhakar"/>
    <language>en</language>
    <item>
      <title>When AI Agents Hammer tools/list: Building a Caching-Aware MCP Server (SEP-2549)</title>
      <dc:creator>Sindhuja Nagaraja Sudhakar</dc:creator>
      <pubDate>Mon, 21 Sep 2026 03:30:00 +0000</pubDate>
      <link>https://dev.to/sindhuja_sudhakar/when-ai-agents-hammer-toolslist-building-a-caching-aware-mcp-server-sep-2549-38k6</link>
      <guid>https://dev.to/sindhuja_sudhakar/when-ai-agents-hammer-toolslist-building-a-caching-aware-mcp-server-sep-2549-38k6</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 2 of 2. &lt;a href="https://dev.to/sindhuja_sudhakar/why-mcp-dropped-the-handshake-building-a-bare-metal-stateless-client-sep-2575-277d"&gt;Part 1&lt;/a&gt; covered statelessness; here I tackle caching.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Part 1 was about statelessness, now shipped in the 2026-07-28 spec. This time it's another feature from that same spec: &lt;strong&gt;caching (SEP-2549, "TTL for List Results")&lt;/strong&gt;. It adds two tiny fields — &lt;code&gt;ttlMs&lt;/code&gt; and &lt;code&gt;cacheScope&lt;/code&gt; — to cacheable results, giving clients a way to avoid re-fetching the same discovery data. So I built a server that emits them, pointed real AI clients at it, and watched what actually happened. The result surprised me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: repeated discovery
&lt;/h2&gt;

&lt;p&gt;AI agents plan in loops. A human calls a tool because they decided to; an &lt;strong&gt;agent&lt;/strong&gt; can call — and re-discover — the same tools dozens of times inside a single planning loop. That changes the economics of the protocol: one user request fans out into many MCP calls. A ReAct-style agent can repeatedly re-check the available tools during a multi-step task, calling my discovery endpoint (&lt;code&gt;tools/list&lt;/code&gt;) over and over.&lt;/p&gt;

&lt;p&gt;In a toy server that's harmless — the tool list is a constant. In a real one it isn't. Building a genuine &lt;code&gt;tools/list&lt;/code&gt; means querying a &lt;strong&gt;service registry&lt;/strong&gt;, filtering tools through &lt;strong&gt;RBAC&lt;/strong&gt;, assembling &lt;strong&gt;input schemas&lt;/strong&gt; from config, applying &lt;strong&gt;feature flags&lt;/strong&gt; — one or more backend round-trips &lt;em&gt;per call&lt;/em&gt;. Now multiply that by a recursive planning loop across many agents, and my discovery endpoint has quietly become a self-inflicted DoS risk for my own databases.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A["AI agent&amp;lt;br/&amp;gt;(recursive planner)"] --&amp;gt;|tools/list x9| S[MCP Server]
    S --&amp;gt;|registry + RBAC + schema| DB[(Corporate DB)]
    style DB fill:#f8d7da,stroke:#842029&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;This isn't hypothetical. When I pointed GitHub Copilot CLI at my server, it fired &lt;strong&gt;nine &lt;code&gt;tools/list&lt;/code&gt; calls in ~40 seconds&lt;/strong&gt; in one short session — plus repeated re-initializes. That's the storm SEP-2549 is meant to tame.&lt;/p&gt;

&lt;h2&gt;
  
  
  The protocol answer: &lt;code&gt;ttlMs&lt;/code&gt; and &lt;code&gt;cacheScope&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;SEP-2549 lets the server advertise, right in the discovery result, how long the schema may be cached and by whom. The fields sit at the top level of the result (a &lt;code&gt;CacheableResult&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// a tools/list result&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;/* ... */&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ttlMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cacheScope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"private"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ttlMs&lt;/code&gt;&lt;/strong&gt; — how long the result stays fresh, in milliseconds. Semantics are analogous to HTTP &lt;code&gt;Cache-Control: max-age&lt;/code&gt;. Tool schemas rarely change, so a 5-minute window collapses a burst of planning turns into a single fetch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cacheScope&lt;/code&gt;&lt;/strong&gt; — &lt;em&gt;who&lt;/em&gt; may cache it. The spec defines exactly two values: &lt;strong&gt;&lt;code&gt;"public"&lt;/code&gt;&lt;/strong&gt; (identical for everyone; a shared proxy may cache one copy) and &lt;strong&gt;&lt;code&gt;"private"&lt;/code&gt;&lt;/strong&gt; (varies per authorization context; must never be shared across callers). Its semantics are analogous to HTTP &lt;code&gt;Cache-Control: public&lt;/code&gt; vs &lt;code&gt;private&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And a rule worth underlining: the spec marks &lt;strong&gt;read/discovery&lt;/strong&gt; results cacheable — &lt;code&gt;server/discover&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt;, &lt;code&gt;prompts/list&lt;/code&gt;, &lt;code&gt;resources/list&lt;/code&gt;, &lt;code&gt;resources/templates/list&lt;/code&gt;, even &lt;code&gt;resources/read&lt;/code&gt; — but &lt;strong&gt;not&lt;/strong&gt; actions. &lt;code&gt;tools/call&lt;/code&gt; is always &lt;code&gt;no-store&lt;/code&gt;. You cache "what can I do," never "do it."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Schema note:&lt;/strong&gt; In my server I group the hint under &lt;code&gt;_meta.cache&lt;/code&gt; for readability, but the spec puts &lt;code&gt;ttlMs&lt;/code&gt; and &lt;code&gt;cacheScope&lt;/code&gt; at the &lt;strong&gt;top level&lt;/strong&gt; of the result (the &lt;code&gt;CacheableResult&lt;/code&gt; shape shown above), not under &lt;code&gt;_meta&lt;/code&gt;. The values and semantics are what matter for this walkthrough; check the caching spec for the exact field placement.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The code: two coordinated cache layers
&lt;/h2&gt;

&lt;p&gt;My FastAPI server exposes three read-only repo tools (&lt;code&gt;get_git_diff&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;inspect_file_structure&lt;/code&gt;, &lt;code&gt;fetch_logs&lt;/code&gt;) and publishes the hint in &lt;strong&gt;two&lt;/strong&gt; places from one source of truth, so both smart clients and dumb proxies can act on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;TOOLS_TTL_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MCP_TOOLS_TTL_MS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="n"&gt;TOOLS_CACHE_SCOPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MCP_TOOLS_CACHE_SCOPE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;private&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cache_meta&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ttlMs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TOOLS_TTL_MS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cacheScope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TOOLS_CACHE_SCOPE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cache_control_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ttl_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TOOLS_TTL_MS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TOOLS_CACHE_SCOPE&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;visibility&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;public&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;public&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;private&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;visibility&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, max-age=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ttl_ms&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;_meta.cache&lt;/code&gt; rides inside the JSON-RPC result — for MCP-aware clients/proxies.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Cache-Control&lt;/code&gt; maps the same values to an HTTP header — for gateways that only
read headers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But here's the layer that actually saved my database. Because I can't trust the &lt;em&gt;caller&lt;/em&gt; to honor &lt;code&gt;ttlMs&lt;/code&gt; (more on that in a second), the server caches its own expensive discovery build, keyed by &lt;code&gt;cacheScope&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DiscoveryCache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;public&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_hits&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;            &lt;span class="c1"&gt;# served from memory, no DB hit
&lt;/span&gt;            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;backend_builds&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;             &lt;span class="c1"&gt;# pay the backend cost ONCE
&lt;/span&gt;            &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_entries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ttl_s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wired into &lt;code&gt;tools/list&lt;/code&gt;, the expensive &lt;code&gt;build_tool_list()&lt;/code&gt; (my stand-in for the registry + RBAC + schema queries) now runs &lt;strong&gt;at most once per TTL window, no matter how often the client re-discovers&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Worth being precise: SEP-2549 gives the &lt;em&gt;consumer&lt;/em&gt; a caching &lt;em&gt;hint&lt;/em&gt;; this &lt;code&gt;DiscoveryCache&lt;/code&gt; is a separate &lt;em&gt;server-side&lt;/em&gt; defense I control regardless of whether any consumer honors that hint.&lt;/strong&gt; They share the same &lt;code&gt;ttlMs&lt;/code&gt; value but do different jobs.&lt;/p&gt;

&lt;p&gt;Two caveats I'd flag in a real build. First, I key the &lt;code&gt;private&lt;/code&gt; scope by &lt;code&gt;client_id&lt;/code&gt;, but the spec's &lt;code&gt;private&lt;/code&gt; semantics are per &lt;em&gt;authorization context&lt;/em&gt; — so &lt;code&gt;client_id&lt;/code&gt; is really a stand-in for "the auth principal and scopes that decide who may share a response." Second, keeping the cache in-process (no shared Redis) preserves Part 1's statelessness, but it doesn't give me globally shared cache state — it trades cross-instance sharing for duplication: a burst spread across four replicas can trigger up to four backend builds instead of one. No Redis lock — but not free either.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A["AI agent&amp;lt;br/&amp;gt;tools/list x9"] --&amp;gt; S[MCP Server]
    S --&amp;gt;|1st call only| DB[(Corporate DB)]
    S -.-&amp;gt;|calls 2-9: served from memory| A
    style DB fill:#f8d7da,stroke:#842029&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Experiment: does the cache actually stop the storm?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis:&lt;/strong&gt; if the server refuses to rebuild the tool list more than once per TTL window, an agent's repeated &lt;code&gt;tools/list&lt;/code&gt; calls should collapse to a single backend hit — &lt;em&gt;regardless of whether the client caches anything&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This is where it got interesting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finding 1 — the server-side cache works.&lt;/strong&gt; Replaying the nine-call storm collapses it to a single backend build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Replaying a 9-call discovery storm from one client...
  tools/list #1: servedFromServerCache=False
  tools/list #2..9: servedFromServerCache=True
discovery cache stats: {'backendBuilds': 1, 'cacheHits': 8}
=&amp;gt; 9 client discovery requests collapsed to 1 backend build(s)  (88% avoided)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I confirmed it end-to-end with real Copilot CLI: it re-requested &lt;code&gt;tools/list&lt;/code&gt; repeatedly, yet my server log showed &lt;code&gt;fromCache: true&lt;/code&gt; and &lt;code&gt;backendBuilds&lt;/code&gt; pinned at &lt;strong&gt;1&lt;/strong&gt; (8 of 9 builds avoided, 88.9%). The DB was queried once and shielded after that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finding 2 — the clients I tested didn't honor &lt;code&gt;ttlMs&lt;/code&gt;.&lt;/strong&gt; My server advertised &lt;code&gt;ttlMs: 300000&lt;/code&gt;, but both Copilot CLI and Cursor re-requested &lt;code&gt;tools/list&lt;/code&gt; repeatedly and didn't cache it client-side. (Their handshakes differ — Copilot CLI is dual-era, probing &lt;code&gt;server/discover&lt;/code&gt; before falling back to &lt;code&gt;initialize&lt;/code&gt;; Cursor took the legacy &lt;code&gt;initialize&lt;/code&gt; path — but neither honored the cache hint in my runs.) I'll be careful with this one: it doesn't mean MCP clients ignore &lt;code&gt;ttlMs&lt;/code&gt; in general — SEP-2549 is final in the 2026-07-28 spec, and client support is still landing. The two I tested just hadn't wired it up yet, which is exactly why the server can't &lt;em&gt;rely&lt;/em&gt; on the hint being honored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finding 3 — client-side caching is timing-sensitive.&lt;/strong&gt; I wrote a simulated edge proxy that &lt;em&gt;does&lt;/em&gt; honor &lt;code&gt;ttlMs&lt;/code&gt;; normally it collapses 20 planning turns to ~2 fetches. But in a deliberately adversarial timing setup — a slower laptop where each round-trip took ~2 seconds, matching the proxy's own 2-second window — the client cache expired just before every next call, giving &lt;strong&gt;0% avoided&lt;/strong&gt;. The same run showed the &lt;strong&gt;server-side&lt;/strong&gt; cache still serving 19 of 20 calls from memory. &lt;strong&gt;TTL effectiveness depends on the relationship between cache TTL, request frequency, and&lt;br&gt;
round-trip latency&lt;/strong&gt; — so client-side caching is dependent on consumer behavior and timing, while server-side caching stays effective even when the client ignores the hint.&lt;/p&gt;
&lt;h2&gt;
  
  
  Whose database, and who protects it
&lt;/h2&gt;

&lt;p&gt;Worth stating plainly: this protects the &lt;strong&gt;server's&lt;/strong&gt; backend, not the client's. The agent is the &lt;em&gt;source&lt;/em&gt; of the load; the corporate database &lt;em&gt;behind&lt;/em&gt; my server is the victim. And there are three places a cache can step in — each reduces the load reaching the DB, and the further upstream it lives, the more work it eliminates:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A[Agent] --&amp;gt;|1. client cache| P[Edge Proxy]
    P --&amp;gt;|2. Cache-Control| S[MCP Server]
    S --&amp;gt;|3. server-side cache| DB[(Corporate DB)]
    style DB fill:#f8d7da,stroke:#842029&lt;/code&gt;&lt;/pre&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Protects&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;honors &lt;code&gt;ttlMs&lt;/code&gt; / &lt;code&gt;cacheScope&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;DB + network + server CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gateway / proxy&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Cache-Control&lt;/code&gt; / protocol-aware caching&lt;/td&gt;
&lt;td&gt;DB + server CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server&lt;/td&gt;
&lt;td&gt;local discovery cache&lt;/td&gt;
&lt;td&gt;DB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Since I can't count on the clients I tested to honor the hint, the &lt;strong&gt;server-side cache is the only layer I fully control&lt;/strong&gt; — and therefore the only protection I can enforce unilaterally today. So my server does both jobs at once: it &lt;em&gt;advertises&lt;/em&gt; &lt;code&gt;ttlMs&lt;/code&gt; / &lt;code&gt;cacheScope&lt;/code&gt; for the ecosystem that's catching up, and it &lt;em&gt;enforces&lt;/em&gt; the same &lt;code&gt;ttlMs&lt;/code&gt;&lt;br&gt;
internally for right now.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Key takeaway:&lt;/strong&gt; A cache hint is only &lt;em&gt;advice&lt;/em&gt; — it protects nothing until a consumer acts on it. Advertise &lt;code&gt;ttlMs&lt;/code&gt; / &lt;code&gt;cacheScope&lt;/code&gt; for the ecosystem you wish you had, and enforce the same TTL inside the server for the ecosystem you actually have.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What surprised me
&lt;/h2&gt;

&lt;p&gt;I assumed the hard part would be &lt;em&gt;designing&lt;/em&gt; the cache hint. It wasn't — that's two fields. The surprise was that &lt;strong&gt;advertising it changed nothing in the clients I tested&lt;/strong&gt;: both kept requesting &lt;code&gt;tools/list&lt;/code&gt; despite the &lt;code&gt;ttlMs&lt;/code&gt; I returned. The protection had to come from the one place I fully control — the server enforcing its own advertised TTL. The lesson mirrors Part 1: a protocol capability can ship before every client has adopted it, so a server that wants the benefit &lt;em&gt;today&lt;/em&gt; has to implement both sides of the contract itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Recursive AI planning turns &lt;code&gt;tools/list&lt;/code&gt; into a self-inflicted DoS — every planning turn re-runs the registry/RBAC/schema queries behind discovery.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ttlMs&lt;/code&gt; / &lt;code&gt;cacheScope&lt;/code&gt; are the right vocabulary, but they're &lt;strong&gt;advice for consumers&lt;/strong&gt; — and the versions of Copilot CLI and Cursor I tested didn't honor it, so a &lt;strong&gt;server-side discovery cache&lt;/strong&gt; is the protection I can enforce unilaterally.&lt;/li&gt;
&lt;li&gt;Cache read-only results, never actions, and key the cache by &lt;code&gt;cacheScope&lt;/code&gt; (&lt;code&gt;public&lt;/code&gt; vs &lt;code&gt;private&lt;/code&gt;) so per-tenant tool lists never leak.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tying both parts together:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Statelessness removes protocol-level affinity without any client cooperation. Caching has two sides — the server can advertise a policy and defend its own backend alone, but the biggest savings only arrive when clients and proxies honor the hint too.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;MCP Specification (2026-07-28) — &lt;em&gt;Server Utilities: Caching&lt;/em&gt; (&lt;code&gt;ttlMs&lt;/code&gt;,
&lt;code&gt;cacheScope&lt;/code&gt; = &lt;code&gt;public&lt;/code&gt; / &lt;code&gt;private&lt;/code&gt;, cacheable methods).
&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MCP Specification (2026-07-28) — &lt;em&gt;Basic / Overview&lt;/em&gt;.
&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io/specification/2026-07-28/basic&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SEP-2549 — &lt;em&gt;TTL for List Results&lt;/em&gt; (incorporated into the 2026-07-28 spec).
&lt;a href="https://modelcontextprotocol.io/seps/2549-TTL-for-list-results" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io/seps/2549-TTL-for-list-results&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SEP-2575 — &lt;em&gt;Make MCP Stateless&lt;/em&gt;.
&lt;a href="https://modelcontextprotocol.io/seps/2575-stateless-mcp" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io/seps/2575-stateless-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;The 2026-07-28 MCP Specification Release Candidate&lt;/em&gt; — MCP Blog.
&lt;a href="https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/" rel="noopener noreferrer"&gt;https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;HTTP Caching — &lt;code&gt;Cache-Control&lt;/code&gt; (RFC 9111). &lt;a href="https://www.rfc-editor.org/rfc/rfc9111" rel="noopener noreferrer"&gt;https://www.rfc-editor.org/rfc/rfc9111&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;FastAPI documentation. &lt;a href="https://fastapi.tiangolo.com/" rel="noopener noreferrer"&gt;https://fastapi.tiangolo.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;JSON-RPC 2.0 Specification. &lt;a href="https://www.jsonrpc.org/specification" rel="noopener noreferrer"&gt;https://www.jsonrpc.org/specification&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>python</category>
      <category>fastapi</category>
    </item>
    <item>
      <title>Why MCP Dropped the Handshake: Building a Bare-Metal Stateless Client (SEP-2575)</title>
      <dc:creator>Sindhuja Nagaraja Sudhakar</dc:creator>
      <pubDate>Tue, 15 Sep 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/sindhuja_sudhakar/why-mcp-dropped-the-handshake-building-a-bare-metal-stateless-client-sep-2575-277d</link>
      <guid>https://dev.to/sindhuja_sudhakar/why-mcp-dropped-the-handshake-building-a-bare-metal-stateless-client-sep-2575-277d</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 1 of 2.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here's something that snuck up on me: as of the &lt;strong&gt;2026-07-28 MCP specification&lt;/strong&gt;, MCP is stateless &lt;em&gt;by default&lt;/em&gt;. The proposal that started it — &lt;strong&gt;SEP-2575, "Make MCP Stateless"&lt;/strong&gt; — has been folded into the spec itself. The spec now calls the old handshake-based design &lt;strong&gt;legacy&lt;/strong&gt; and the new per-request design &lt;strong&gt;modern&lt;/strong&gt;: there is no &lt;code&gt;initialize&lt;/code&gt; handshake and no &lt;code&gt;Mcp-Session-Id&lt;/code&gt;; every request carries its own protocol version and capabilities in &lt;code&gt;_meta&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So I did two things: built the smallest possible &lt;em&gt;modern&lt;/em&gt; (stateless) client to feel how it works, then pointed today's real AI clients at it to see how they negotiate. Spoiler: it's messier than "they've caught up" or "they haven't" — and the spec's own negotiation rules explain exactly why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: session state creates affinity
&lt;/h2&gt;

&lt;p&gt;In the &lt;strong&gt;legacy&lt;/strong&gt; MCP flow, a client opens a session with &lt;code&gt;initialize&lt;/code&gt;, the server may hand back an &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, and the client &lt;strong&gt;echoes that id on every later request&lt;/strong&gt;. That one header is what pins you to a box.&lt;/p&gt;

&lt;p&gt;Once a request carries a session id, your load balancer has to send every follow-up call back to the &lt;strong&gt;same&lt;/strong&gt; server instance (&lt;em&gt;sticky routing&lt;/em&gt;), or you have to replicate that session across instances — usually with a &lt;strong&gt;Redis session store&lt;/strong&gt;. Either way you pay: sticky routing wrecks even load distribution, and shared session state adds a network hop, a new failure mode, and a scaling bottleneck to &lt;em&gt;every single call&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Here's the stateful world SEP-2575 wants to leave behind:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant Agent
    participant LB as Load Balancer
    participant S1 as Instance 1
    participant Redis

    Agent-&amp;gt;&amp;gt;LB: initialize
    LB-&amp;gt;&amp;gt;S1: (creates session abc123)
    S1-&amp;gt;&amp;gt;Redis: store session abc123
    S1--&amp;gt;&amp;gt;Agent: Mcp-Session-Id: abc123
    Note over Agent,S1: every later call MUST return to Instance 1&amp;lt;br/&amp;gt;(or read the session back from Redis)
    Agent-&amp;gt;&amp;gt;LB: tools/call  (Mcp-Session-Id: abc123)
    LB-&amp;gt;&amp;gt;S1: pinned by session id&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The question I wanted to answer: &lt;strong&gt;can I design the client so no session ever needs to be pinned or synchronized?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: make every request self-describing
&lt;/h2&gt;

&lt;p&gt;The modern move is to stop negotiating context once and remembering it, and instead put that context &lt;em&gt;into every request&lt;/em&gt;. Each call carries its protocol version and capabilities inline in &lt;code&gt;_meta&lt;/code&gt;. Nothing is remembered between requests, so nothing has to be routed to a particular instance. (The spec uses namespaced keys like &lt;code&gt;_meta.io.modelcontextprotocol/*&lt;/code&gt; for this — more on the exact schema below.)&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant Agent
    participant LB as Load Balancer
    participant Pool as Any Instance

    Agent-&amp;gt;&amp;gt;LB: tools/call  (_meta: version + capabilities)
    LB-&amp;gt;&amp;gt;Pool: route to ANY node
    Pool--&amp;gt;&amp;gt;Agent: result (echoes clientId)
    Note over Agent,Pool: no session id, nothing to pin,&amp;lt;br/&amp;gt;no Redis in the path&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  The code: a handshake-free JSON-RPC client
&lt;/h2&gt;

&lt;p&gt;There's deliberately &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;initialize&lt;/code&gt; step. Every request is a plain JSON-RPC 2.0 object with the protocol metadata riding along in &lt;code&gt;params._meta&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;PROTOCOL_VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-07-28&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;CLIENT_CAPABILITIES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;listChanged&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stateless&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;protocolVersion&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PROTOCOL_VERSION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clientCapabilities&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;capabilities&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;CLIENT_CAPABILITIES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clientId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;client_id&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_jsonrpc_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_meta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# inline, every call
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jsonrpc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_id_counter&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;params&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Schema note:&lt;/strong&gt; I flattened the metadata (&lt;code&gt;protocolVersion&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;clientCapabilities&lt;/code&gt;, &lt;code&gt;clientId&lt;/code&gt;) to isolate the statelessness property and keep the snippet readable. The real wire format namespaces these under&lt;br&gt;
&lt;code&gt;_meta.io.modelcontextprotocol/*&lt;/code&gt; (e.g. &lt;code&gt;io.modelcontextprotocol/protocolVersion&lt;/code&gt;). Treat my &lt;code&gt;_meta&lt;/code&gt; as illustrative, not the literal spec schema.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The transport is just an HTTP POST — no session object, no connection state. I also surface the protocol version as a header so a gateway can inspect it without parsing the body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MCP-Protocol-Version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PROTOCOL_VERSION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;MCPError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;list_tools()&lt;/code&gt; and &lt;code&gt;call_tool()&lt;/code&gt; both go straight through &lt;code&gt;_post&lt;/code&gt;, any instance can serve any request. There's nothing sticky to preserve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment: does statelessness remove affinity?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis:&lt;/strong&gt; if every request carries its own protocol context, the &lt;em&gt;same&lt;/em&gt; logical client should be able to hit &lt;em&gt;different&lt;/em&gt; replicas on consecutive calls with no shared session state.&lt;/p&gt;

&lt;p&gt;To test that, I wrote a test that fires &lt;strong&gt;50 concurrent tool calls&lt;/strong&gt;, each carrying its &lt;strong&gt;own randomized &lt;code&gt;_meta&lt;/code&gt;&lt;/strong&gt;, through a deliberately awful transport (35% dropped packets, latency jitter, random instance selection) at a pool of stateless replicas that share &lt;strong&gt;no&lt;/strong&gt; session state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;NUM_REQUESTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;span class="n"&gt;POOL_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;span class="n"&gt;DROP_RATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;chaotic_send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MAX_RETRIES&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.005&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# jitter
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;DROP_RATE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;                                    &lt;span class="c1"&gt;# dropped -&amp;gt; retry
&lt;/span&gt;        &lt;span class="n"&gt;instance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;# no affinity
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ConnectionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;all packets dropped after retries&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each replica answers using &lt;strong&gt;only&lt;/strong&gt; the per-request &lt;code&gt;_meta&lt;/code&gt; — never remembered state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_meta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# the ONLY context the server relies on
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{...,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_meta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;serverInstance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;echoClientId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clientId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The suite asserts all 50 requests completed, that &lt;strong&gt;reused client ids landed on different instances&lt;/strong&gt; (proving no stickiness snuck in), and that every response echoed the exact protocol version its request supplied. Here's the actual run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Stateless MCP concurrency test  (50 concurrent calls)
protocol version      : 2026-07-28
simulated drop rate   : 35% (with up to 10 retries)
server pool           : ['srv-0', 'srv-1', 'srv-2', 'srv-3']
requests completed    : 50/50
client -&amp;gt; instances (proves no sticky routing):
    client-0: ['srv-0', 'srv-1', 'srv-2', 'srv-3']  &amp;lt;-- moved between instances
    client-1: ['srv-0', 'srv-1', 'srv-2', 'srv-3']  &amp;lt;-- moved between instances
    client-2: ['srv-0', 'srv-1', 'srv-2', 'srv-3']  &amp;lt;-- moved between instances
PASS: every instance served any request independently - no session locks.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every client id was served by &lt;strong&gt;all four&lt;/strong&gt; instances. The property we were after: &lt;strong&gt;request routing becomes independent of client identity&lt;/strong&gt; — same logical client, different servers, no shared protocol session. This doesn't prove a production load balancer will never add affinity; it shows the &lt;strong&gt;protocol doesn't require any&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch: negotiation is a two-sided handshake
&lt;/h2&gt;

&lt;p&gt;Then I pointed two real MCP clients at my server — GitHub Copilot CLI and Cursor. My first cut failed hard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="mi"&gt;-32601&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;method&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;found:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'initialize'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logs told a subtler story than "clients are behind." The spec gives a &lt;strong&gt;modern&lt;/strong&gt; server two jobs: it &lt;strong&gt;MUST&lt;/strong&gt; implement a &lt;code&gt;server/discover&lt;/code&gt; RPC (so clients can learn its versions and capabilities &lt;em&gt;without&lt;/em&gt; a handshake), and it serves requests carrying per-request &lt;code&gt;_meta&lt;/code&gt;. My first server implemented &lt;strong&gt;neither&lt;/strong&gt; &lt;code&gt;server/discover&lt;/code&gt; &lt;strong&gt;nor&lt;/strong&gt; the old &lt;code&gt;initialize&lt;/code&gt; — so there was simply no way in.&lt;/p&gt;

&lt;p&gt;The clients then revealed their eras in the request stream:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Copilot CLI is dual-era.&lt;/strong&gt; It tried the modern &lt;code&gt;server/discover&lt;/code&gt; &lt;em&gt;first&lt;/em&gt;, and only when my server answered &lt;code&gt;-32601&lt;/code&gt; did it fall back to the legacy &lt;code&gt;initialize&lt;/code&gt; (which also &lt;code&gt;-32601&lt;/code&gt;'d) before giving up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cursor&lt;/strong&gt; went straight to &lt;code&gt;initialize&lt;/code&gt; — the legacy path, no probe.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the fix had two valid shapes. The spec-correct one: implement &lt;code&gt;server/discover&lt;/code&gt; and be a proper modern server. The quick one I took for this experiment: add a &lt;strong&gt;legacy &lt;code&gt;initialize&lt;/code&gt; shim&lt;/strong&gt; so both clients connect via the handshake path — but crucially, my shim &lt;strong&gt;stores nothing and never mints an &lt;code&gt;Mcp-Session-Id&lt;/code&gt;&lt;/strong&gt;. Even on the legacy path, with no session id handed back the client has nothing sticky to resend, so any instance can still serve any later call. Backward compatibility &lt;em&gt;and&lt;/em&gt; load-balancer freedom.&lt;/p&gt;

&lt;p&gt;That's the subtle bit I want you to take away: &lt;strong&gt;protocol-level stickiness is server-minted.&lt;/strong&gt; The server issues the id; the client just echoes it. Decline to mint it, and the client naturally sends nothing to pin with — no client adoption required. Statelessness at the protocol layer is something the &lt;strong&gt;server&lt;/strong&gt; unlocks on its own. (That's the exact opposite of caching, which — as I'll show in Part 2 — only pays off when the &lt;em&gt;consumer&lt;/em&gt; cooperates.)&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Key takeaway:&lt;/strong&gt; &lt;em&gt;Protocol-level&lt;/em&gt; stickiness is server-minted. As long as your server answers &lt;code&gt;initialize&lt;/code&gt; &lt;strong&gt;without&lt;/strong&gt; returning an &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, the client has nothing to echo — so any instance can serve any request, and you stay backward-compatible with today's legacy clients. Apps that need cross-call state use &lt;strong&gt;explicit&lt;/strong&gt; state handles (server-minted IDs like &lt;code&gt;basket_id&lt;/code&gt;, threaded through tool arguments) — and because any replica can resolve a handle from shared storage, they keep application state &lt;em&gt;without&lt;/em&gt; bringing stickiness back.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Stateful vs. stateless, at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&lt;/th&gt;
&lt;th&gt;Legacy MCP (&lt;code&gt;2025-11-25&lt;/code&gt; and earlier)&lt;/th&gt;
&lt;th&gt;Modern MCP (&lt;code&gt;2026-07-28&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Session tracking&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Mcp-Session-Id&lt;/code&gt; header required&lt;/td&gt;
&lt;td&gt;Protocol metadata inline via &lt;code&gt;_meta&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load balancing&lt;/td&gt;
&lt;td&gt;Sticky routing required&lt;/td&gt;
&lt;td&gt;Any instance / round-robin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared protocol-session state&lt;/td&gt;
&lt;td&gt;Redis / session store&lt;/td&gt;
&lt;td&gt;None required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instance rotation&lt;/td&gt;
&lt;td&gt;Restarts can break active sessions&lt;/td&gt;
&lt;td&gt;Instances rotate freely&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What statelessness does &lt;em&gt;not&lt;/em&gt; solve
&lt;/h3&gt;

&lt;p&gt;Protocol statelessness only removes &lt;em&gt;implicit&lt;/em&gt; session state — it does &lt;strong&gt;not&lt;/strong&gt; make your application stateless. You still own auth, authorization, rate limiting, idempotency, long-running tasks, and real conversation memory. In the modern model, cross-call state travels through &lt;strong&gt;explicit&lt;/strong&gt; state handles — server-minted IDs (a &lt;code&gt;basket_id&lt;/code&gt;, a &lt;code&gt;connection_id&lt;/code&gt;) that the model threads through later tool calls — rather than an implicit session. That's the pattern SEP-2567 ("Sessionless MCP via Explicit State Handles") formalizes; notably it's a tool-design convention, not a new wire construct.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Stateless &lt;em&gt;protocol&lt;/em&gt; ≠ stateless &lt;em&gt;application&lt;/em&gt;.&lt;/strong&gt; Statelessness buys free horizontal routing, not a free pass on application state.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    MCP[MCP request] --&amp;gt; P[Protocol state]
    MCP --&amp;gt; A[Application state]
    P --&amp;gt; P1["Stateless: version + capabilities&amp;lt;br/&amp;gt;in _meta, no session"]
    A --&amp;gt; A1["Explicit state handle&amp;lt;br/&amp;gt;(basket_id, connection_id)"]
    P1 --&amp;gt; R[Any replica serves it]
    A1 --&amp;gt; ST[(Shared storage)]
    R --&amp;gt; App[Your application]
    ST --&amp;gt; App&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  What surprised me
&lt;/h2&gt;

&lt;p&gt;I expected the hard part to be &lt;em&gt;removing&lt;/em&gt; the session. It wasn't — that was a few lines. The harder problem was &lt;strong&gt;negotiation and compatibility&lt;/strong&gt;: a server can be perfectly capable of handling stateless requests and still be unreachable by a client that expects a different protocol path. The real lesson wasn't "statelessness is easy" — it was that protocol design is about how two independently-evolving implementations &lt;em&gt;discover what the other supports&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Protocol-level statelessness removes implicit session affinity — sticky routing and Redis session sync disappear because there's no session id to pin to.&lt;/li&gt;
&lt;li&gt;Application state still exists, but through &lt;strong&gt;explicit&lt;/strong&gt; state handles, not a stateful protocol. Stateless protocol ≠ stateless application.&lt;/li&gt;
&lt;li&gt;The real production problem isn't just statelessness — it's compatibility (Copilot probes &lt;code&gt;server/discover&lt;/code&gt;, Cursor wants &lt;code&gt;initialize&lt;/code&gt;), plus caching, retries, and the sheer volume of tool calls an agent generates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Part 1&lt;/strong&gt; was about routing. &lt;strong&gt;Part 2&lt;/strong&gt; is about agent behaviour — caching, and why advertising &lt;code&gt;ttlMs&lt;/code&gt; / &lt;code&gt;cacheScope&lt;/code&gt; isn't enough to stop AI planning loops from thrashing your database.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;MCP Specification (2026-07-28) — &lt;em&gt;Basic / Overview&lt;/em&gt;.
&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io/specification/2026-07-28/basic&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MCP Specification (2026-07-28) — &lt;em&gt;Versioning and Compatibility&lt;/em&gt; (Modern / Legacy /
Dual-era, per-request &lt;code&gt;_meta&lt;/code&gt;, compatibility matrix).
&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MCP Specification (2026-07-28) — &lt;em&gt;Transports / Overview&lt;/em&gt;
(&lt;code&gt;_meta.io.modelcontextprotocol/*&lt;/code&gt;, &lt;code&gt;MCP-Protocol-Version&lt;/code&gt;).
&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/transports" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io/specification/2026-07-28/basic/transports&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SEP-2575 — &lt;em&gt;Make MCP Stateless&lt;/em&gt; (Final; incorporated into the 2026-07-28 spec).
&lt;a href="https://modelcontextprotocol.io/seps/2575-stateless-mcp" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io/seps/2575-stateless-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;The 2026-07-28 MCP Specification Release Candidate&lt;/em&gt; — MCP Blog.
&lt;a href="https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/" rel="noopener noreferrer"&gt;https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SEP-2567 — &lt;em&gt;Sessionless MCP via Explicit State Handles&lt;/em&gt; (cited in-text).
&lt;a href="https://modelcontextprotocol.io/seps/2567-sessionless-mcp" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io/seps/2567-sessionless-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;JSON-RPC 2.0 Specification. &lt;a href="https://www.jsonrpc.org/specification" rel="noopener noreferrer"&gt;https://www.jsonrpc.org/specification&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>python</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
