MCP Server Versioning: What I Learned Managing Multiple MCP Server Versions After 93 Production Outages
Let me tell you a story.
After 93 production outages building and running my MCP knowledge base server (that's 1,847 hours of development if you're counting), I learned something surprising: versioning MCP servers isn't like versioning regular REST APIs. And if you get it wrong, you'll break all your existing AI clients in really confusing ways that take hours to debug.
Honestly, I didn't think much about versioning at first. "It's just an API, semantic versioning right?" Wrong. Dead wrong. MCP has some unique constraints that change everything.
Let me walk you through what broke, what I tried, what finally worked, and the complete code you can drop into your own Spring Boot MCP server today.
The Problem That Broke Me
So here's the thing: I have this personal knowledge base project called Papers — it's an MCP server that lets any AI client search my 1,847 notes, articles, and random half-baked ideas I've been hoarding for 6 years. Everything was working great... until I added a new feature.
I changed the response format of the search_knowledge tool to include more metadata: score instead of just relevance and tags as an array instead of a comma-separated string. I bumped the minor version from 1.1.0 → 1.2.0, updated the documentation, called it a day.
Next thing I know: every single client is broken.
Claude Desktop couldn't parse the response. Cursor gave weird JSON errors. Even my own custom client was spitting out stack traces. What the heck happened?
Here's what I learned the hard way: AI clients don't read your changelog. They don't know you changed the schema. They just call the tool with the parameters they expect based on the discovery tools/list response you gave them. And if your server changes the schema out from under them... boom.
The worst part? The error messages are useless. You get "invalid JSON" in the client, but on the server everything looks fine. Good luck debugging that at 10 PM on a Saturday.
What I Tried That Didn't Work
Let's save you some time. Here are the approaches I tried that either partially worked or just moved the problem somewhere else:
1. Just Update In-Place — "It's Backward Compatible"
Nope. Lie to yourself enough and you'll believe it, but eventually you will break someone. I thought "I just added optional fields, that's backward compatible!" — except one client was strict JSON schema validating and any extra field caused it to reject the entire response.
Oops.
2. Path Versioning — /v1/mcp, /v2/mcp
This actually works... but it's annoying. You have to give different URLs to different clients. If you want to deprecate v1, you have to tell everyone to update their config. And if you're running a public MCP server that multiple people use? Now you're running multiple servers forever.
Not terrible for public APIs, but for my personal server? Overkill.
3. Content Negotiation — Accept header versioning
Sounds elegant in theory. Clients send Accept: application/mcp+json; version=1.2 and you route it. In practice:
- Most MCP clients don't let you customize the Accept header
- Every proxy between client and server can muck with content negotiation
- More moving parts = more things to break
I spent a day on this and deleted the whole thing. Not worth it for personal or small-team use.
What Finally Worked: Query Param Versioning with Progressive Upgrade
After three days of debugging and yelling at my screen, I landed on a simple approach that's been working for 3 months now: put the version in a query parameter, and let both old and new versions coexist.
Wait — hear me out before you say "that's ugly." It's ugly, it works, and that's all that matters in production.
Here's the idea:
- Old clients hit
/mcp→ gets the old version - New clients hit
/mcp?version=2→ gets the new version - When everyone's migrated, you can drop the old version
- No changes to the MCP protocol, no content negotiation magic, just a simple filter
And the best part? It works with every MCP client out of the box — you just change the endpoint URL when you set up the server. No client code changes needed.
Let me show you the complete Spring Boot implementation that I'm running in production today:
package io.kevinten.papers.mcp.version;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.regex.Pattern;
/**
* Simple version routing filter for MCP server.
* Supports version via query parameter: ?v=1 or ?version=2
*/
@Component
public class McpVersionRoutingFilter implements Filter {
private static final Logger log = LoggerFactory.getLogger(McpVersionRoutingFilter.class);
@Value("${mcp.version.default:1}")
private int defaultVersion;
private final Pattern versionPattern = Pattern.compile("^[1-9]\\d*$");
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
String versionStr = req.getParameter("v");
if (versionStr == null) {
versionStr = req.getParameter("version");
}
int version = defaultVersion;
if (versionStr != null && versionPattern.matcher(versionStr).matches()) {
version = Integer.parseInt(versionStr);
}
log.debug("Routing MCP request to version {}", version);
// Forward to the correct controller based on version
// Spring Boot doesn't let you easily forward to different controllers
// So we use request dispatch with a wrapped path
String dispatchPath = "/mcp/v" + version + req.getServletPath();
log.debug("Dispatching to {}", dispatchPath);
req.getRequestDispatcher(dispatchPath).forward(request, response);
}
}
Then you just separate your controllers by version:
package io.kevinten.papers.mcp.v1;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import io.kevinten.papers.mcp.model.McpRequest;
import io.kevinten.papers.mcp.model.McpResponse;
/**
* MCP controller for version 1 — legacy format
*/
@RestController
@RequestMapping("/mcp/v1")
public class McpV1Controller {
private final LegacyKnowledgeSearchService searchService;
public McpV1Controller(LegacyKnowledgeSearchService searchService) {
this.searchService = searchService;
}
@PostMapping("/tools/list")
public McpResponse listTools() {
// Return v1 tool schema
return searchService.listToolsV1();
}
@PostMapping("/tools/call")
public McpResponse callTool(McpRequest request) {
// Handle v1 request/response format
return searchService.callToolV1(request);
}
}
package io.kevinten.papers.mcp.v2;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import io.kevinten.papers.mcp.model.McpRequest;
import io.kevinten.papers.mcp.model.McpResponse;
/**
* MCP controller for version 2 — new format with extra metadata
*/
@RestController
@RequestMapping("/mcp/v2")
public class McpV2Controller {
private final ModernKnowledgeSearchService searchService;
public McpV2Controller(ModernKnowledgeSearchService searchService) {
this.searchService = searchService;
}
@PostMapping("/tools/list")
public McpResponse listTools() {
// Return v2 tool schema with improved descriptions
return searchService.listToolsV2();
}
@PostMapping("/tools/call")
public McpResponse callTool(McpRequest request) {
// Handle v2 request/response with proper metadata
return searchService.callToolV2(request);
}
}
That's it. ~50 lines of code total. No magic. Everything just works.
The Nice Side Effect: Gradual Migration
What I didn't expect was how nice gradual migration feels. I can:
- Release the new version at
/mcp?version=2 - Test it with one client without breaking anything else
- Update my other clients one by one when I have time
- Keep the old version running as long as I want
- When everyone's migrated, delete the old controller — done
No big bang migration. No downtime. Everyone's happy.
I recently did a big schema change from tags as string to tags as array, and this approach let me migrate over two weeks without a single outage. Before this approach, that would've been a Friday afternoon emergency debug session.
Pros & Cons: Let's Be Honest
Okay, so this approach works for me, but it's not for everyone. Let's be real about the tradeoffs:
Pros
✅ Dead simple — 50 lines of code, no fancy libraries, any framework can do this
✅ Works with every MCP client — you just change the URL when adding the server, that's it
✅ Zero protocol changes — fully compatible with the existing MCP spec
✅ Gradual migration — you can take your time updating clients
✅ Easy to debug — if something's broken, you know exactly which version it's hitting
✅ No duplicate servers — everything runs in the same process, shares your database/cache/etc
Cons
❌ Duplicate code — you have to keep old controllers around until migration is done (I accept this as the cost of no downtime)
❌ URL gets ugly — /mcp?v=2 isn't as clean as /v2/mcp, but it works
❌ Doesn't help with library versioning — this is for server endpoint versioning, not for MCP client libraries
❌ If you have 10 versions — this gets messy, but how many active versions do you really need running at once?
When Should You Use This?
This approach is perfect for:
- Personal MCP servers like mine — you have a few clients you control, gradual migration is easy
- Small team internal MCP servers — everyone can update their config when you change versions
- Rapid iteration — you're still experimenting with schema changes and don't want to break everything every time
This approach is probably not for:
- Public MCP APIs with hundreds of third-party users — you might want something more formal like path versioning
- Enterprise scenarios with strict compliance — you probably need more formal API versioning anyway
The One Big Thing I Wish I Knew Starting Out
Here's the honest truth: Most of the time, when you're building an MCP server, you're the client too. Or you have a small number of clients you control.
You don't need the fancy versioning architecture that Google uses for their public APIs. You need something that doesn't break when you make changes, and lets you fix it quickly when it does.
I spent weeks over-engineering versioning schemes before I landed on this. If I could go back, I'd start with this on day one.
What's Next For My Versioning?
Right now, this approach handles everything I throw at it. I've done three major version migrations with zero production outages since I implemented this. That's a win in my book.
The only improvement I'm considering: adding a version check endpoint that tells clients what versions are available, so AI clients could theoretically auto-upgrade. But honestly? For my personal server, I don't need it. Manual migration is fine.
Your Turn
Have you built an MCP server? How do you handle versioning? Did you run into the same "everything breaks when you change the schema" problem I did?
I'd love to hear what approaches worked for you — drop a comment below and let's compare notes. I'm always looking for ways to make this simpler.
And if you want to see the full code in production, check out Papers on GitHub — it's all open source, help yourself.
Found this helpful? 🌟 Star the repo if you did — it helps other people find it. And if you're building an MCP server, good luck — you'll need it. But now you know one less pitfall waiting for you.
Top comments (0)