MCP Server Error Handling: What I Learned Building a Production MCP Server for My 1,800-Hour Knowledge Base
Honestly, I thought I was done after getting my MCP server running. You know the feeling — you get /tools/list responding, tools/call returns some text, you call it a day and write a blog post about it.
I was wrong.
So here's the thing: I've been running my MCP server in production (well, "production" — it's a free Heroku dyno that sleeps when not used, but still) for a few weeks now, and I've learned that error handling is where most MCP tutorials drop the ball. Everyone shows you the happy path. Nobody talks about what happens when things go wrong.
Let me share what I learned the hard way, with actual code from my 1,800-hour knowledge base project Papers. If you're building an MCP server right now, this might save you a few late nights debugging why your AI client keeps disconnecting.
The Project Context, For The Uninitiated
If you're new here, I've been building this knowledge base for six years. Started with grand ambitions of an AI-powered semantic search everything. Ended up with 2,847 saved articles, 1,847 hours of development, and an actual usage rate of about 2.9%. Not great.
Then Model Context Protocol (MCP) came along, and everything changed. Instead of trying to make my knowledge base smart, I just expose it as MCP tools to AI assistants. Now Claude automatically pulls my relevant articles into context when I'm working. It's perfect.
But when I started actually using it daily, I hit error after error that none of the basic examples prepared me for.
The First Problem: Empty Responses Break Everything
The first issue I hit was when someone asked about a topic that wasn't in my knowledge base. My code would return an empty list of results, and... nothing. The AI client would just sit there spinning.
Here's what I was doing initially:
@PostMapping("/mcp/tools/call")
public McpResponse callTool(@RequestBody McpRequest request) {
String query = request.getParams().getQuery();
List<Article> results = articleRepository.search(query);
if (results.isEmpty()) {
return McpResponse.empty(); // THIS IS BAD
}
return McpResponse.content(formatResults(results));
}
Can you guess what's wrong here? MCP clients expect something. Even if there are no results, you need to send a proper content message explaining that. Empty responses look like a failed request to most clients.
Here's the fix:
@PostMapping("/mcp/tools/call")
public McpResponse callTool(@RequestBody McpRequest request) {
String query = request.getParams().getQuery();
List<Article> results = articleRepository.search(query);
if (results.isEmpty()) {
return McpResponse.content(
"No articles found matching query: \"" + query + "\". " +
"Try a different search term or check spelling. " +
"My knowledge base currently contains " + articleRepository.count() + " articles."
);
}
return McpResponse.content(formatResults(results));
}
Seems obvious in hindsight, right? But none of the examples I read mentioned this. They all just assume you have results. Lesson learned: always return human-readable content, even when you have nothing to give.
The Second Problem: Timeout Killing the Connection
My knowledge base is on a free Heroku dyno. If it's been a while, it needs to wake up. That can take 10-15 seconds.
Guess what? Most MCP clients have default timeouts way shorter than that. My first implementation had no chunking, no streaming — just the whole response sent at once. If the dyno was cold, the connection would timeout before any data was sent.
Again, nothing in the basic examples about this. So I implemented two things:
1. Early Acknowledgment for Slow Operations
@PostMapping("/mcp/tools/call")
public ResponseEntity<McpResponse> callTool(
@RequestBody McpRequest request,
HttpServletResponse response
) {
// If this might take time, send headers immediately to keep connection alive
response.setContentType("application/json");
response.flushBuffer();
// Do the potentially slow search
List<Article> results = articleRepository.search(request.getParams().getQuery());
// Format and return...
return ResponseEntity.ok(formatResponse(results));
}
2. Respect Client Timeouts with Size Limits
I also added a hard limit on response size. If there are too many results, I truncate and tell the user to narrow their search instead of sending 50KB of content that might hit the timeout:
private String formatResults(List<Article> results) {
StringBuilder sb = new StringBuilder();
int count = 0;
for (Article article : results) {
if (sb.length() > 15000) { // ~15KB limit
sb.append("\n\n... and " + (results.size() - count) + " more results. " +
"Narrow your search query to see fewer results.");
break;
}
sb.append(formatArticle(article)).append("\n\n---\n\n");
count++;
}
return sb.toString();
}
This has been a game-changer. Before, big searches would just timeout and fail. Now, they give the user partial results and guidance. Much better.
The Third Problem: Malformed JSON = Silent Failure
I made a stupid mistake: one of my article titles had a double quote in it, and I forgot to escape it properly in JSON.
What happened? The entire response was invalid JSON. The MCP client would just silently disconnect. No error message, nothing. Just... nothing. You spend an hour trying to figure out why your server isn't responding when the real problem is one unescaped character.
Here's how I fixed it — stop manually building JSON. Let your framework do it:
Before (bad, don't do this):
// DON'T DO THIS! I did it so you don't have to.
public String badIdea(McpRequest request) {
return "{\"result\": \"" + article.getTitle() + "\"}"; // if title has ", boom.
}
After (good):
// Use your framework's serialization - it handles escaping correctly
@PostMapping("/mcp/tools/call")
public McpResponse callTool(@RequestBody McpRequest request) {
// Jackson automatically handles escaping special characters
return service.search(request);
}
// With proper record classes that get serialized correctly
public record McpResponse(List<Content> content) {}
public record Content(String type, String text) {}
I know, I know — this is basic Java stuff. But when you're rushing to get an MCP server working, it's tempting to quick-serialize some strings manually. Don't do it. The five minutes it saves you now becomes three hours of debugging later. Trust me.
The Fourth Problem: Authentication is Tricker Than You Think
I thought adding API key auth would be straightforward. Put an Authorization: Bearer <key> header in the client config, check it on the server, done.
Wrong again. Different MCP clients handle headers differently. Some expect the key in query params. Some don't support custom headers at all (looking at you, early implementations). Some have specific formats they expect.
Here's what I ended up with that works across most clients I've tried:
@Component
public class McpAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// Try multiple places for the API key - clients do it differently
String apiKey = extractApiKey(request);
if (apiKey == null || !apiKeyService.isValid(apiKey)) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
// IMPORTANT: Send error JSON in MCP format
response.getWriter().write("""
{
"error": {
"code": "unauthorized",
"message": "Invalid or missing API key"
}
}
""");
return;
}
filterChain.doFilter(request, response);
}
private String extractApiKey(HttpServletRequest request) {
// 1. Try Authorization header first (standard)
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
// 2. Try api-key header (some clients use this)
String apiKeyHeader = request.getHeader("api-key");
if (apiKeyHeader != null && !apiKeyHeader.isEmpty()) {
return apiKeyHeader;
}
// 3. Try query parameter (clients that can't do headers easily)
String apiKeyQuery = request.getParameter("apiKey");
if (apiKeyQuery != null && !apiKeyQuery.isEmpty()) {
return apiKeyQuery;
}
return null;
}
}
The important part isn't just trying multiple places — it's returning a proper JSON error response that clients can parse. Before I added this, I was just sending a 401 with no body, and clients had no idea what went wrong.
The Fifth Problem: Content Length Confusion (Yes, Really)
This one took me forever to debug. Sometimes responses would get truncated. Sometimes they'd work fine. It was completely inconsistent.
Turns out... I was using Spring Boot with compression enabled, and some MCP clients don't handle Transfer-Encoding: chunked correctly when the content length is unknown.
Wait, what? Yeah, me too. I thought all modern HTTP clients handled chunked encoding fine. Some MCP implementations are still young, and not all edge cases are handled.
The fix? In your MCP endpoints, precompute the response size and set the Content-Length header explicitly when possible:
@PostMapping("/mcp/tools/call")
public ResponseEntity<String> callTool(@RequestBody McpRequest request) {
McpResponse response = service.process(request);
String json = objectMapper.writeValueAsString(response);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.contentLength(json.getBytes(StandardCharsets.UTF_8).length)
.body(json);
}
That's it. Since adding explicit content length, I haven't had a single truncated response. It's the little things that kill you, right?
Honest Pros & Cons of What I Built
After several weeks of running an MCP server daily, let me break this down honestly:
Pros ✅
It actually works better than I expected — Once you get all the error handling right, Claude just automatically pulls in your relevant context. It feels like magic. I can't imagine working without it now.
Privacy is amazing — All my personal notes stay on my server. Only the specific snippets needed for the current query get sent to the AI. No uploading my entire knowledge base to third-party servers. That's a big win for me.
Standard protocol means interoperability — I've tested my MCP server with three different MCP clients, and it just works. Change clients, no changes needed to my server. That's the whole point of standards, and it delivers on that.
Simple architecture is sustainable — My entire MCP server is about 150 lines of code. That's it. Before MCP, I had 2000 lines of AI search logic that nobody used. Simpler = easier to maintain.
Cons ❌
Ecosystem is still young — Not all clients support all MCP features. Error handling varies. You will hit edge cases that aren't covered in documentation. Be prepared to debug.
Hosting is still your responsibility — You need a publicly accessible endpoint for most clients to connect. Local development works with ngrok, but 24/7 availability needs actual hosting. If you're like me and using free tiers, cold starts are a real issue.
No built-in observability — MCP doesn't specify anything about logging, monitoring, or rate limiting. You have to build all that yourself. For a personal project it's fine, but for anything bigger, that's more work.
Still lots of breaking changes — The protocol is still evolving. I've had to update my implementation three times already because of changes in the spec. If you're building production stuff, be ready to keep up.
Would I Do It Again?
Honestly? Yes. Even with all the headaches I just walked you through. MCP transformed my "failed" 1,800-hour knowledge base into something actually useful every single day.
Before MCP, I'd forget I even had articles on topics I wrote six months ago. Now Claude just automatically pulls them in when we're working on related problems. It's like having an assistant that actually knows everything I've already learned.
The error handling headaches were mostly growing pains. Now that I've worked through them, it's been rock solid. And the bigger lesson — simple standards beat complex "smart" systems every time, especially in the AI age. Your job isn't to be smart anymore. It's to make your data available through standard interfaces so AI can be smart with it.
Your Turn
Have you built an MCP server yet? Hit any weird error handling issues that I didn't cover here? Or are you thinking about building one and still have questions? Drop a comment below — I'd love to hear what you're building and what problems you're running into.
I'm still learning this stuff myself, so if you've got a trick for better error handling in MCP servers, share it. We all benefit from learning from each other's mistakes.
And if you want to see the full code for my MCP server, check out the project on GitHub — everything is open source, feel free to steal whatever you need for your own MCP projects.
Top comments (0)