{"slug": "mcp-best-practices-7-hard-lessons-i-learned-building-5-mcp-servers-full-included", "title": "MCP Best Practices: 7 Hard Lessons I Learned Building 5 MCP Servers (Full Checklists Included)", "summary": "A developer who built five MCP servers shares seven hard-learned lessons for production deployments. Key mistakes include returning empty arrays that cause clients to hang, manually constructing JSON strings leading to parse errors, and implementing authentication incorrectly. The developer provides specific code examples and checklists to help others avoid these pitfalls.", "body_md": "Let me be honest with you — I've built five different MCP (Model Context Protocol) servers in the past three months, and I got almost everything wrong the first time. Like, *really* wrong.\n\nIf you've been living under a rock (like I was three months ago), MCP is the new standard that lets AI clients like Claude Desktop connect to your tools and data through a standardized protocol. The idea is beautiful: build once, works everywhere MCP is supported. No more custom integrations for every AI client.\n\nBut here's the thing — the official docs are still catching up. Tutorials only show you the happy path. Production? That's where all the fun happens. After getting paged at 2 AM three times last month because my MCP server was hanging, I've collected some hard-earned lessons that I wish someone had told me before I started.\n\nToday I'm sharing them with you. Let's dive in.\n\nSo here's the thing — I built my first MCP server for my personal knowledge base. Everything worked great in testing. I connected it to Claude Desktop, asked it to search for \"MCP authentication\", got results back, felt like a genius.\n\nThen I asked it to search for \"xyzabc123\" — something that definitely had no results. Claude just... hung. Spun forever. No timeout, no error, nothing. I restarted everything, checked logs, it looked like my server returned 200 OK with empty content. Turns out Claude didn't handle empty responses well at all.\n\nI learned the hard way: **never return an empty array or empty content when there are no results**. Return a human-readable message explaining that nothing was found.\n\nHere's what I do now (Java example, but the principle applies everywhere):\n\n```\npublic class SearchKnowledgeHandler implements ToolCallHandler {\n    @Override\n    public Object handle(ToolCallRequest request) {\n        List<KnowledgeResult> results = searchService.search(request.getQuery());\n\n        if (results.isEmpty()) {\n            // ❌ BAD: This will hang some clients\n            // return Collections.emptyList();\n\n            // ✅ GOOD: Return a human-readable explanation\n            return Collections.singletonList(Map.of(\n                \"result\", \"No results found for your query: '\" + request.getQuery() + \"'. \" +\n                          \"Try different keywords or check your spelling.\"\n            ));\n        }\n\n        return results;\n    }\n}\n```\n\nThis one change fixed 80% of my \"random hanging\" issues. Different clients handle empty responses differently — some handle it fine, some don't. Why take the risk? A friendly message costs you nothing and saves everyone debugging time.\n\nI know what you're thinking — \"JSON is simple, I can just concatenate strings.\" Stop. Don't do it. I did it. It cost me four hours of debugging why *every other request* failed.\n\nHere's what happened. I was in a hurry, wanted to get something working quickly. Instead of letting Spring Boot serialize my response objects, I manually built the JSON:\n\n```\n// ❌ NEVER DO THIS. I BEG YOU.\nString badJson = \"{\\\"result\\\": \\\"\" + userInput + \"\\\"}\";\n```\n\nEverything worked fine until someone searched for `My project is called \"Papers\"`\n\n. The double quote inside the content broke the entire JSON. The client got a parse error, disconnected, and I spent hours wondering why my server was \"randomly crashing\".\n\n**Lesson:** Always, always, always let your framework serialize JSON for you. Never manually build JSON strings. Never.\n\n```\n// ✅ Do this instead\nrecord SearchResponse(List<Result> results, String query) {}\nreturn new SearchResponse(foundResults, query); // Framework handles serialization\n```\n\nIf you're using Go, use `encoding/json`\n\n. If you're using Python, use `json.dumps`\n\n. If you're using Node.js, use `JSON.stringify`\n\n. Doesn't matter what stack you're on — *let the framework handle it*. One unescaped character and your entire response is garbage. Save yourself the pain.\n\nOkay, this one surprised me. I read the spec, implemented API key authentication in the `Authorization: Bearer {key}`\n\nheader — that's the standard way, right?\n\nThen I tried connecting with four different MCP clients. Guess what?\n\n`Authorization: Bearer {key}`\n\n✅`X-API-Key: {key}`\n\n❌ didn't work`api_key={key}`\n\n❌ didn't work`apiKey={key}`\n\n(different name!) ❌ also didn't workI spent three days going back and forth, debugging why my server wouldn't connect. The problem wasn't any of the clients — they all do it differently. MCP is still young, and there isn't consistent practice yet.\n\n**The fix:** Support all common locations. It's like 20 extra lines of code, and suddenly everyone can connect. Here's what I do in Java with a Spring `OncePerRequestFilter`\n\n:\n\n```\n@Component\npublic class McpAuthenticationFilter extends OncePerRequestFilter {\n\n    @Override\n    protected void doFilterInternal(HttpServletRequest request, \n                                    FilterChain filterChain) \n                                    throws ServletException, IOException {\n\n        // Try all common locations\n        String apiKey = getApiKeyFromAuthorizationHeader(request);\n        if (apiKey == null) {\n            apiKey = request.getHeader(\"X-API-Key\");\n        }\n        if (apiKey == null) {\n            apiKey = request.getParameter(\"api_key\");\n        }\n        if (apiKey == null) {\n            apiKey = request.getParameter(\"apiKey\");\n        }\n\n        if (!validateApiKey(apiKey)) {\n            request.getServletResponse().sendError(HttpStatus.UNAUTHORIZED.value());\n            return;\n        }\n\n        filterChain.doFilter(request, filterChain);\n    }\n\n    private String getApiKeyFromAuthorizationHeader(HttpServletRequest request) {\n        String header = request.getHeader(\"Authorization\");\n        if (header != null && header.startsWith(\"Bearer \")) {\n            return header.substring(7);\n        }\n        return null;\n    }\n}\n```\n\nYes, putting the API key in the query string isn't \"perfectly secure\" because it might get logged in server logs. But guess what — users just want it to *work*. If some clients only support query params, you either support it or they can't use your server. Compatibility wins over purity here. If you're running a public service with sensitive data, you can always document the risks. For most personal MCP servers, it's totally fine.\n\nIf you're running an MCP server that web-based AI clients will connect to (and you probably are — lots of new MCP clients are web apps), you need CORS. And CORS means `OPTIONS`\n\npreflight requests.\n\nHere's the gotcha: `OPTIONS`\n\nrequests *don't send your authentication headers*. That's just how browsers work. So if you have authentication enabled on *all* endpoints, your preflight request will get a 401 Unauthorized, the browser will block the request, and nothing works.\n\nI spent two hours on this. It's so frustrating because everything works fine with desktop clients (they don't do CORS), but web clients just fail silently.\n\n**Fix:** Configure your CORS to allow the necessary headers, and make sure `OPTIONS`\n\nrequests don't require authentication. Here's how I do it in Spring Boot:\n\n```\n@Configuration\npublic class CorsConfig implements WebMvcConfigurer {\n\n    @Override\n    public void addCorsMappings(CorsRegistry registry) {\n        registry.addMapping(\"/**\")\n            .allowedOrigins(\"*\") // Restrict this to your actual clients in production\n            .allowedMethods(\"GET\", \"POST\", \"OPTIONS\")\n            .allowedHeaders(\"*\")\n            .exposedHeaders(\"*\");\n    }\n}\n```\n\nAnd make sure your authentication filter skips OPTIONS requests:\n\n```\n@Component\npublic class McpAuthenticationFilter extends OncePerRequestFilter {\n\n    @Override\n    protected boolean shouldNotFilter(HttpServletRequest request) {\n        // Preflight OPTIONS requests don't have auth headers\n        return request.getMethod().equals(\"OPTIONS\");\n    }\n\n    // ... rest of the filter\n}\n```\n\nThat's it. This one change will save you from the weirdest \"it works on desktop but not on web\" debugging session.\n\nCold starts are real. If you're running on Fly.io Heroku, or any free tier, your server might need 5-10 seconds to spin up and handle the first request. Some MCP clients have pretty strict timeouts — like 5 seconds. If you don't respond in time, they disconnect.\n\nI learned this one when my knowledge base MCP server kept timing out on the first request after cold start. The search was actually working, it just took 7 seconds, and the client gave up at 5.\n\n**The trick:** If you're on Spring Boot (or any framework that supports it), enable flushing and send the headers early. This keeps the connection alive while your server is still processing.\n\nHere's a concrete example in Spring Boot where you can write the HTTP status and headers immediately:\n\n```\n@PostMapping(\"/mcp/call\")\npublic void handleToolCall(@RequestBody McpRequest request, \n                           HttpServletResponse response) throws IOException {\n    // Start writing early to keep the connection alive\n    response.setContentType(\"application/json\");\n    response.setStatus(HttpStatus.OK.value());\n    PrintWriter writer = response.getWriter();\n\n    // Do your slow processing here...\n    ToolCallResult result = slowToolService.execute(request);\n\n    // Write the actual result when you're done\n    writer.write(objectMapper.writeValueAsString(result));\n    writer.flush();\n}\n```\n\nThis works because the client opens the connection, gets the headers immediately, and keeps the connection open waiting for the body. No timeout. It's not pretty, but it solves the problem. For slow operations, this is a lifesaver.\n\nThis is a super subtle one. Some MCP clients have issues with chunked encoding. If you don't explicitly set the `Content-Length`\n\nheader, they might truncate your response — cut off the end, leaving invalid JSON. You get a parse error, and you can't figure out why because when you check your logs, the JSON looks complete.\n\nI only found this after comparing what my server sent vs what the client received byte-for-byte. Yep, the last few characters were missing.\n\n**Why does this happen?** Some clients don't handle chunked encoding correctly. If you generate the entire response before sending it (which you usually do for MCP tool calls), you know the exact length. Just set it.\n\nHere's how to do it in Node.js/Express:\n\n``` js\napp.post('/mcp/call', (req, res) => {\n  const result = handleRequest(req.body);\n  const json = JSON.stringify(result);\n\n  // ✅ Set Content-Length explicitly\n  res.setHeader('Content-Length', Buffer.byteLength(json));\n  res.setHeader('Content-Type', 'application/json');\n  res.send(json);\n});\n```\n\nIn Java Spring Boot, if you're writing directly to the response:\n\n```\nString json = objectMapper.writeValueAsString(result);\nresponse.setContentLength(json.getBytes(StandardCharsets.UTF_8).length);\nresponse.getWriter().write(json);\n```\n\nIt's one extra line, and it eliminates an entire category of weird \"response truncated\" errors. Why not?\n\nIf you're running your MCP server 24/7 in production (or even just want to know when it's down), add a simple health check endpoint. It doesn't need to do much — just return 200 OK.\n\nFly.io, Kubernetes, most cloud providers can ping this endpoint and restart your container if it's unhealthy. I added this after my server went unresponsive for 12 hours because of a memory leak, and I had no idea.\n\nThe simplest possible health check:\n\n```\n@RestController\npublic class HealthCheckController {\n\n    @GetMapping(\"/health\")\n    public Map<String, Object> health() {\n        return Map.of(\n            \"status\", \"ok\",\n            \"timestamp\", System.currentTimeMillis(),\n            \"service\", \"mcp-server\"\n        );\n    }\n}\n```\n\nThat's it. Five lines of code. Now your platform can automatically restart unhealthy instances. The peace of mind is worth it. I also add it to all my side projects now — even if I don't need it today, I'll thank myself later.\n\nHere's everything I go through before I ship any MCP server to production now. Print this out or save it somewhere — you'll need it:\n\n`Authorization: Bearer`\n\n, `X-API-Key`\n\n, `api_key`\n\nquery, `apiKey`\n\nquery`OPTIONS`\n\npreflight requests skip authentication, allow necessary headers`/health`\n\nendpoint returns 200 OK for monitoringLet's be real — MCP is still young. It's exciting, but it's not all perfect. Here's my honest take after building five servers:\n\n`tools/list`\n\nand `tools/call`\n\n. That's it. You don't need a complex frontend or anything. Keep it simple.Building MCP servers has been a really fun learning experience for me. Three months ago, I'd never even heard of MCP. Today, I've got five different servers running for different projects, and I use them every day.\n\nThe biggest realization I had? **MCP flips the architecture on its head.** Before MCP, we tried to put all the smarts *inside* our services. Now the AI client already has all the smarts — your service just needs to expose data and tools. That's it. Simple is better. Complexity is your enemy.\n\nAll the lessons I shared in this article came from real production pain. I stepped in every pothole so you don't have to. Follow the checklist, test with multiple clients, keep things simple, and you'll be fine.\n\nHave you built an MCP server yet? What weird edge cases did you run into that I didn't cover here? I'd love to hear about your experiences in the comments — every bit of shared knowledge helps everyone in this new ecosystem.\n\nIf you want to see how I implemented all this in a real Java MCP server, check out my open-source knowledge base project on GitHub: [https://github.com/kevinten10/Papers](https://github.com/kevinten10/Papers)\n\nGo build something cool! 🚀", "url": "https://wpnews.pro/news/mcp-best-practices-7-hard-lessons-i-learned-building-5-mcp-servers-full-included", "canonical_source": "https://dev.to/kevinten10/mcp-best-practices-7-hard-lessons-i-learned-building-5-mcp-servers-full-checklists-included-2f45", "published_at": "2026-06-25 05:34:19+00:00", "updated_at": "2026-06-25 05:43:11.456792+00:00", "lang": "en", "topics": ["developer-tools", "large-language-models", "ai-agents"], "entities": ["MCP", "Claude Desktop", "Spring Boot", "JSON", "Go", "Python", "Node.js"], "alternates": {"html": "https://wpnews.pro/news/mcp-best-practices-7-hard-lessons-i-learned-building-5-mcp-servers-full-included", "markdown": "https://wpnews.pro/news/mcp-best-practices-7-hard-lessons-i-learned-building-5-mcp-servers-full-included.md", "text": "https://wpnews.pro/news/mcp-best-practices-7-hard-lessons-i-learned-building-5-mcp-servers-full-included.txt", "jsonld": "https://wpnews.pro/news/mcp-best-practices-7-hard-lessons-i-learned-building-5-mcp-servers-full-included.jsonld"}}