Skip to content

How to Migrate an MCP Server to v2: A Step-by-Step Checklist

Purpose

OP on r/mcp framed v2 as roughly a week away and asked “how’s migration prep going on your end?” I had been treating the v2 upgrade as a version bump, but the two themes OP highlighted — stateless transport and sampling deprecation — are both breaking changes, not feature flags. So I wrote myself a checklist before touching the server, and this post is that checklist.

The goal is a focused, ordered migration plan, not a rewrite.

Environment

  • MCP v1 server, multiple instances behind nginx
  • Per-session state currently in process memory
  • Sampling-based tool flow that delegates model calls to the host
  • Target: MCP v2 (stateless transport, sampling removed)

The two hotspots

Everything in this checklist comes from two breaking changes. Naming them up front keeps the steps from feeling random:

MCP v2 breaking-change hotspots
1. Session state -> must leave the process (shared store or client)
2. Sampling -> removed; server must call its own model or expose a normal tool

If a step does not touch one of these, it is regular plumbing and probably already works.

How to solve it?

I run through six steps in order. Each one is small enough to verify on its own.

Step 1: Audit in-process session state

Before changing anything, I list every place my server keeps per-session state in memory.

grep helper to find in-process session state
# Look for patterns like: self.session[...], _ctx[...], session_cache = {}
grep -RnE "session\[|_ctx\[|session_cache|self\._[a-z_]+_cache" src/

I write the matches down. Anything I find has to move before v2 will work for me.

Step 2: Externalize state

For each item from step 1, I decide where it lives now.

  • Durable state (history, account lookups, long-lived context) -> Redis or a database.
  • Ephemeral state (positions in a stream, transient hints) -> the client, sent back on each request.

The rule of thumb I use: if losing the instance would corrupt a user-visible flow, the state belongs in a shared store; if it would only cause a minor re-fetch, it can live on the client.

Step 3: Drop sticky-session routing

Once state is externalized, the load balancer no longer needs affinity. I switch nginx from sticky to a plain round-robin or least-connections upstream.

Before: nginx sticky-session upstream (v1)
upstream mcp_v1 {
sticky cookie=mcp_affinity;
server 10.0.0.1:8080;
server 10.0.0.2:8080;
}
After: plain round-robin (v2, stateless)
upstream mcp_v2 {
least_conn;
server 10.0.0.1:8080;
server 10.0.0.2:8080;
}

The :8080 in the server lines is the port my server listens on; the important change is removing the sticky cookie= line.

Step 4: Remove sampling

I have a separate post on sampling deprecation, so I keep this short. The move is to take every requestSampling(...) call and either bring the model call into my server or refactor the flow so the client passes a model choice as a normal tool argument.

Whatever I do, the sampling code path is a dead path under v2. I delete it rather than leaving it to throw at runtime.

Step 5: Test init-then-failover

This is the step that catches the mistake nobody notices in development: silent re-pinning. I run a literal failover test.

Failover test procedure
1. Start two v2 instances: A and B.
2. Client sends init -> say it lands on A.
3. Kill A.
4. Client sends a tool call (the same session).
5. Expect: B serves it successfully, because state is in the shared store.
6. If it fails with "unknown session", step 2 was not actually stateless.

I treat step 5 as the only proof that migration worked. Console logs saying “handled request” on B are not enough — B has to serve the continuing session without an error.

Step 6: Keep a rollback window

During the cutover I keep the v1 image warmed up. The migration touches both code and deployment, so if anything breaks under real load, I want to flip the upstream back without rebuilding.

The reason

I think the key reason a checklist matters here is that the two failure modes — sticky affinity and dead sampling — are silent until production. In dev, a single instance hides the first one, and a host with a model configured hides the second. The checklist forces me to touch both before they can hide.

Two common mistakes I want to avoid:

  1. Forgetting background subscriptions/resources. They held state in v1 and also need to be externalized or accepted as non-survivable. They are easy to miss because they are not in the main request path.
  2. Leaving sampling code as dead paths. It compiles, so it looks harmless, but under v2 it throws at runtime. Deleting it is faster than debugging a runtime error later.

Summary

In this post, I showed a six-step checklist for migrating an MCP server to v2: audit session state, externalize it, drop sticky routing, remove sampling, run a failover test, and keep a rollback window. The key point is that session state and sampling are the two hotspots, and both need explicit handling rather than a version bump.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments