DEV Community

Cover image for What Breaks When One MCP Server Becomes Two
Cristina Rodriguez
Cristina Rodriguez

Posted on

What Breaks When One MCP Server Becomes Two

I didn't start this because MCP is trendy. I started it because I could see the problem coming.

Anzuelo, a lead-finding agent I built, scrapes Reddit, Hacker News, Bluesky, Mastodon, YouTube, LinkedIn, and Threads looking for people talking about the things I care about, then scores what it finds using the Claude API. Right now it runs as a scheduled job, once a day, one process, no concurrency to worry about. But the moment I, or anyone, wants to expose that kind of search-and-page workflow as something other tools can call on demand, "the server remembers where you left off" stops being a safe assumption. Run two instances behind a load balancer and the second call can land on a machine that's never heard of the first one.

That's not an MCP problem specifically. It's a distributed systems problem, and it's the direction MCP's July 28 spec is pushing: stop relying on one instance's memory to connect one call to the next. The state can still live somewhere. What changes is that the client carries something explicit to point at it, instead of the server assuming the next call comes back to the same process.

Who's talking to who

Quick setup, because the rest of this doesn't land without it.

There's a model, meaning the assistant someone is chatting with. There's an MCP server, which is a small program you write that offers up a few functions the model is allowed to call. Those functions are called tools. And there's a client sitting in the middle, carrying messages between the two.

When the model wants to search your mentions, it never runs your code. It sends a message saying "call start_search, keyword is mcp." The client hands that to your server. Your server runs the function and sends text back. The model reads that text the same way it reads anything else in the conversation.

The part that matters for this post: each of those is a separate round trip. Ask, answer, done. Nothing in that arrangement promises the next question arrives at the same running copy of your server.

Before we go further, one thing I should be straight about. Anzuelo is why I care about this problem, and Anzuelo is also not what I'm showing you. It doesn't speak MCP yet. No server, no tools, nobody calling in. So I built a tiny MCP server that does nothing except search a list and page through it, because I wanted to watch this failure happen somewhere small before I built it on top of data I'd actually have to care about. What follows is the old way, where the server remembers your search for you, and the new way, where every call carries what it needs. I'm writing it in the order things actually happened, including the three places I turned out to be wrong.

Here's what that gap actually looks like for a system like Anzuelo, running today versus what it would need before other people's clients could call it:

Anzuelo today versus what it would need in production: today it runs as a cron trigger once a day, in one process with shared memory, searching and scoring via the Claude API and storing leads in Supabase. Before other tools could call it, it would need to be stateless with no reliance on instance-local memory, use an explicit handle carrying an ID and cursor in the arguments, let any copy answer with no sticky routing, and authenticate clients with an OAuth token.

If you want the longer version of that, I wrote a plain-language intro a while back covering what problem it solves and how tool calls actually work. This post picks up from there and goes straight into the part that's changing. (Anzuelo's searcher-per-platform architecture is where I first ran into this kind of state-management question, even without MCP in the mix.)

If you're a career-changer aiming for an entry-level developer role, this is worth your time for a reason that has nothing to do with chasing a trend. Reasoning about state and designing a clean contract between two systems is what interviewers have always tested, and MCP is a well-documented place to practice it right now.

The failure that shows up when one process becomes two

One more word before the story. When a server gets busy, you run several copies of it at once and spread traffic across them. Kubernetes people call each copy a pod, and I'll use that word because it's short. Read it as "one running copy of my server, sitting on some machine."

Picture two identical copies of the same MCP server running behind a load balancer: call them pod A and pod B. A client sends start_search, the request lands on pod A, and pod A stores the search progress in its own memory. The client's next call, get_next_page, has a coin-flip chance of landing on pod B instead. Pod B has never heard of this search. It doesn't crash. It politely tells the client there's no active search, which is a dead end dressed up as a successful response.

This isn't a hypothetical. It was the most common failure story people wrote about in the run-up to July 28. And it was fixable before the spec even shipped, because the fix isn't really about MCP. It's about not trusting a specific process to remember something a specific client needs.

An MCP client sends two requests through a load balancer to two copies of the same server. Pod A handles request 1, start_search, and saves cursor 0 in its own memory. Pod B handles request 2, get_next_page, but has no affinity and no shared state, so it replies that there is no active search instead of returning the next result. Same client, same load balancer, and pod B never saw request 1.

What 2026-07-28 actually changed

Before this release, an MCP conversation started with a hello. The client and the server traded a message called initialize, agreed on which version they were both speaking and what each one could do, and from then on the server kept a thread running underneath every later call. That thread was called the session.

7-28 removes the hello and the thread. Every request now shows up carrying its own version, its own information about who is calling, and its own list of what the client can do. Nothing gets agreed once and remembered.

That is more baggage on every single call. What you get for it is that no request depends on reaching the same copy of your server as the last one did. The load balancer can send traffic wherever it likes. Your server can also run in places that never keep a connection open, which is most of the cheap places to run things.

Three other changes came with it:

  1. Signing in got more standard. Auth now matches the way OAuth is normally set up, so a server can plug into whatever identity system a company already uses instead of inventing something.
  2. New features can ship as add-ons now, without changing the protocol itself. Two arrived with this release. MCP Apps lets a server send back a small interactive interface instead of plain text. Tasks covers work that takes a while to finish.
  3. Every feature got a promise. Once something is marked deprecated, it stays in the spec for at least twelve more months before it can be removed.

Tasks is the one worth a look here. When a job takes a while, the server answers with a task handle, and the client uses that handle to come back and ask how it's going. That's the same move I make below with search_id and a cursor. The client holds onto something small and hands it back on the next call.

And if you already have a server running, it didn't break on July 28. New clients that speak 7-28 fall back to the old hello when they reach an older server.

One thing I want to be careful about, because I got it wrong in my first draft. The spec removed the session the protocol was keeping for you. It did not remove the dictionary I was keeping in my own server. No spec can do that. That one was always mine to fix. What 7-28 does is take away the crutch.

The full release notes are on the MCP blog, and AAIF published a migration guide that goes deeper than I do here.

Old way: state lives in the server's memory

Here's a tiny mention-tracker server, written the naive way, with the cross-call state parked in one process. This isn't what the 2025-11-25 spec told you to build. It's what's easy to reach for when you're on your laptop, one process, one client, and nothing has ever gone wrong. It searches a list of mock mentions for a keyword and lets you page through results.

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("mention-tracker")

MOCK_MENTIONS = [
    {"platform": "reddit", "text": "anyone using agentgateway in prod?"},
    {"platform": "hn", "text": "MCP stateless core is a good call"},
    {"platform": "bluesky", "text": "just shipped an MCP server, no auth yet"},
    {"platform": "mastodon", "text": "agentic tools are eating my weekend"},
    {"platform": "youtube", "text": "tutorial: build an MCP client in 10 min"},
]

# One shared slot for this whole process. Not scoped to a session or a caller.
# Whoever calls last wins, and a second caller mid-search clobbers the first.
_search_state: dict[str, dict] = {}

@mcp.tool()
def start_search(keyword: str) -> str:
    """Start a mention search. Progress is remembered server-side."""
    matches = [m for m in MOCK_MENTIONS if keyword.lower() in m["text"].lower()]
    _search_state["current"] = {"matches": matches, "cursor": 0}
    return f"Search started for '{keyword}'. Found {len(matches)} mention{'s' if len(matches) != 1 else ''}. Call get_next_page() to page through results."

@mcp.tool()
def get_next_page() -> str:
    """Return the next mention. Trusts the server to remember where we left off."""
    state = _search_state.get("current")
    if state is None:
        return "No active search. Call start_search first."
    cursor = state["cursor"]
    matches = state["matches"]
    if cursor >= len(matches):
        return "No more mentions."
    mention = matches[cursor]
    state["cursor"] += 1
    return f"[{mention['platform']}] {mention['text']}"

if __name__ == "__main__":
    mcp.run()
Enter fullscreen mode Exit fullscreen mode

Notice get_next_page() takes zero arguments. It only works because this exact process remembers the last cursor. A cursor is a bookmark, a number saying how far through the results you've already read. The state here is worse than session state, and I'll come back to why once you've seen the wire calls. Here's the actual JSON-RPC exchange this demo sends, captured over stdio. JSON-RPC is just the message format MCP uses: a function name, some arguments, a reply. And stdio means the client and the server are talking through the terminal, one process to one process, which is how you run this on a laptop. So this is one client talking to a single process, and it isn't reproducing the pod A and pod B routing. What it shows is the thing that makes that routing dangerous:

start_search {"keyword": "mcp"} ->
{
  "meta": null,
  "content": [
    {
      "type": "text",
      "text": "Search started for 'mcp'. Found 3 mentions. Call get_next_page() to page through results.",
      "annotations": null,
      "meta": null
    }
  ],
  "structuredContent": {
    "result": "Search started for 'mcp'. Found 3 mentions. Call get_next_page() to page through results."
  },
  "isError": false
}
Enter fullscreen mode Exit fullscreen mode
get_next_page {} ->
{
  "meta": null,
  "content": [
    {
      "type": "text",
      "text": "[hn] MCP stateless core is a good call",
      "annotations": null,
      "meta": null
    }
  ],
  "structuredContent": {
    "result": "[hn] MCP stateless core is a good call"
  },
  "isError": false
}
Enter fullscreen mode Exit fullscreen mode

Look at that second call: "arguments": {}. Nothing in it identifies the search. The only reason it can answer is that this process still holds the cursor from the first call. Point it at a process that didn't serve start_search and there is nothing to page through.

Surprise one: it was worse than session state

I noticed this while I was writing the old server, and it bothered me more than the load balancer story did. _search_state["current"] isn't scoped to a session or a caller. It's one slot for the entire process, keyed on the literal string "current". Session-pinning means the load balancer keeps sending the same client back to the same copy. That at least guarantees that your requests keep landing on the pod that knows about your search. This doesn't even give you that: two clients hitting the same process share the slot, so whoever calls start_search last wins and the other one's cursor silently jumps into someone else's result set. The load balancer story is the failure you notice. This one you don't.

New way: state travels with the call

So I rebuilt the server, and the first thing I had to settle was what the client would carry between calls.

Surprise two: I had to decide what a handle even is

Here's the same server, rebuilt so the continuation state travels in the arguments instead of living in the process. Writing it, I hit a question I hadn't planned for: what is the handle actually made of? Somebody has to decide, and that somebody was me. I picked the keyword. search_id is just the string the caller searched for, and the server recomputes matches from a static list on every call. That's a demo answer and I wasn't comfortable with it. A real one would mint an opaque ID, keep the result set somewhere every instance can read, and validate the ID before touching it. I left the simple version in anyway, because what I want to show is where the continuity lives, and that part is the same in both. I didn't wait for the spec release to write this, and you don't need to either. It works in any current MCP SDK.

from typing import TypedDict

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("mention-tracker")

MOCK_MENTIONS = [
    {"platform": "reddit", "text": "anyone using agentgateway in prod?"},
    {"platform": "hn", "text": "MCP stateless core is a good call"},
    {"platform": "bluesky", "text": "just shipped an MCP server, no auth yet"},
    {"platform": "mastodon", "text": "agentic tools are eating my weekend"},
    {"platform": "youtube", "text": "tutorial: build an MCP client in 10 min"},
]


class SearchHandle(TypedDict):
    search_id: str
    cursor: int
    match_count: int
    message: str


class MentionPage(TypedDict):
    platform: str | None
    text: str | None
    cursor: int
    done: bool


@mcp.tool()
def start_search(keyword: str) -> SearchHandle:
    """Start a mention search. Returns a handle the CLIENT carries forward."""
    matches = [m for m in MOCK_MENTIONS if keyword.lower() in m["text"].lower()]
    return {"search_id": keyword, "cursor": 0, "match_count": len(matches),
            "message": f"Search started for '{keyword}'. Found {len(matches)} mention{'s' if len(matches) != 1 else ''}."}


@mcp.tool()
def get_next_page(search_id: str, cursor: int) -> MentionPage:
    """Return the next mention. search_id and cursor are explicit arguments,
    so this call is self-contained. Any instance can answer it."""
    cursor = max(cursor, 0)
    matches = [m for m in MOCK_MENTIONS if search_id.lower() in m["text"].lower()]
    if cursor >= len(matches):
        return {"platform": None, "text": None, "cursor": cursor, "done": True}
    mention = matches[cursor]
    return {"platform": mention["platform"], "text": mention["text"],
            "cursor": cursor + 1, "done": False}


if __name__ == "__main__":
    mcp.run()
Enter fullscreen mode Exit fullscreen mode

Same tool names. The model calls them the same way it called the old ones, though the contract underneath has changed: get_next_page now requires two arguments it used to pull from memory.

Surprise three: the handle came back as a string

The old server annotated start_search as -> str. Its reply carried a populated structuredContent:

"structuredContent": {
  "result": "Search started for 'mcp'. Found 3 mentions. Call get_next_page() to page through results."
}
Enter fullscreen mode Exit fullscreen mode

The rebuilt server annotated the same tool as -> dict:

@mcp.tool()
def start_search(keyword: str) -> dict:
Enter fullscreen mode Exit fullscreen mode

Its reply carried this instead:

"structuredContent": null,
Enter fullscreen mode Exit fullscreen mode

So the version I was presenting as the improvement produced the worse-shaped output. The handle existed only as text inside content, and a client would have to parse that string before it could read a single field.

The cause is the annotation. A bare dict declares no key names and no types, so FastMCP has no schema to build from and falls back to dumping the return value as text.

The fix is declaring the shape. With SearchHandle and MentionPage as TypedDicts, on SDK 1.29.0, the same call returns:

"structuredContent": {
  "search_id": "mcp",
  "cursor": 0,
  "match_count": 3,
  "message": "Search started for 'mcp'. Found 3 mentions."
}
Enter fullscreen mode Exit fullscreen mode

One difference worth flagging: the old server wraps its value under a result key, while the TypedDict version returns the fields as a bare object.

The part worth noticing is what -> dict allowed. That annotation let get_next_page return two different shapes depending on the branch: the finished branch returned two keys, done and cursor, and the normal branch returned four. Declaring MentionPage forced one consistent shape, with platform and text null when the search is done.

Here's the wire exchange from the finished version, with both shapes declared:

start_search {"keyword": "mcp"} ->
{
  "meta": null,
  "content": [
    {
      "type": "text",
      "text": "{\n  \"search_id\": \"mcp\",\n  \"cursor\": 0,\n  \"match_count\": 3,\n  \"message\": \"Search started for 'mcp'. Found 3 mentions.\"\n}",
      "annotations": null,
      "meta": null
    }
  ],
  "structuredContent": {
    "search_id": "mcp",
    "cursor": 0,
    "match_count": 3,
    "message": "Search started for 'mcp'. Found 3 mentions."
  },
  "isError": false
}
Enter fullscreen mode Exit fullscreen mode
get_next_page {"search_id": "mcp", "cursor": 0} ->
{
  "meta": null,
  "content": [
    {
      "type": "text",
      "text": "{\n  \"platform\": \"hn\",\n  \"text\": \"MCP stateless core is a good call\",\n  \"cursor\": 1,\n  \"done\": false\n}",
      "annotations": null,
      "meta": null
    }
  ],
  "structuredContent": {
    "platform": "hn",
    "text": "MCP stateless core is a good call",
    "cursor": 1,
    "done": false
  },
  "isError": false
}
Enter fullscreen mode Exit fullscreen mode

Nothing is stored in this process. Pod A or pod B, it doesn't matter, search_id and cursor are right there in the arguments. The model carries the handle forward the same way it would carry any other tool result. A production server would still have state behind that handle, sitting in Postgres or Redis where every instance can reach it. What moved is the assumption. The server stops betting that the next call comes back to the same memory. That's the core of the migration.

What this doesn't fix (yet), and what still works

This toy server sidesteps things a real production server would need. There's more to handle around routing and auth, and the spec covers it. Roots, Sampling, and Logging are deprecated in this release, so if you're running them, nothing broke on July 28 and you have the twelve-month window to move.

Try it yourself

The full code for both versions is in this repo. One install note. Both servers were built and tested on mcp 1.29.0. Version 2.x renames FastMCP to MCPServer. The decorator API survives the rename, but the import path does not, so an unpinned install will fail before it gets that far. Clone it and run the old version in two terminal windows. Two terminals means two processes with separate memory, which is the same thing a load balancer does to you by accident. Call start_search in one and get_next_page in the other, and watch it come back with "No active search. Call start_search first." That's a dead end, not a crash, but useless to the client that expected its next mention. Then run the new version and watch it not care which "pod" answers.

On a real server with real data I'd do three things differently. The handle would be an opaque ID, not the keyword, so it doesn't leak what was searched and can't be guessed. The result set behind it would live somewhere every copy of the server can read. And get_next_page would validate the ID before it touched anything, instead of trusting whatever arrived in the arguments.

If you're new to backend development and wondering whether any of this applies to you: it's the same lesson every distributed system eventually teaches, MCP is just handing it to you early, with good docs and a spec you can go read.

The next one is the real thing. Anzuelo is getting an MCP server, with actual mentions in Supabase and actual people paging through them, and I'll write up what breaks when the data stops being five hardcoded strings.

Top comments (0)