{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreic4itocxncgxjrfwsqvz6xdjhcuf3xuuoa5dgkawyd6i5ftfl4ure",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mp3qcf45q772"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreiewidntecnvjz6wxi4buu6kixsfy366tkx3tbildwc4zzh7vve2fm"
},
"mimeType": "image/webp",
"size": 84018
},
"path": "/kevinten10/mcp-best-practices-7-hard-lessons-i-learned-building-5-mcp-servers-full-checklists-included-2f45",
"publishedAt": "2026-06-25T05:34:19.000Z",
"site": "https://dev.to",
"tags": [
"ai",
"opensource",
"https://github.com/kevinten10/Papers",
"@Override",
"@Component",
"@Configuration",
"@PostMapping",
"@RequestBody",
"@RestController",
"@GetMapping"
],
"textContent": "# MCP Best Practices: 7 Hard Lessons I Learned Building 5 MCP Servers (Full Checklists Included)\n\nLet 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\n## 1. Always Handle Empty Responses Properly (Your Clients Will Hang Without It)\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\n\n public 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\n## 2. Manually Building JSON Will Destroy You (Let the Framework Do It)\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\n // ❌ NEVER DO THIS. I BEG YOU.\n String badJson = \"{\\\"result\\\": \\\"\" + userInput + \"\\\"}\";\n\n\nEverything worked fine until someone searched for `My project is called \"Papers\"`. 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\n // ✅ Do this instead\n record SearchResponse(List<Result> results, String query) {}\n return new SearchResponse(foundResults, query); // Framework handles serialization\n\n\nIf you're using Go, use `encoding/json`. If you're using Python, use `json.dumps`. If you're using Node.js, use `JSON.stringify`. 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\n## 3. Support API Key in Multiple Places (Because Different Clients Do It Differently)\n\nOkay, this one surprised me. I read the spec, implemented API key authentication in the `Authorization: Bearer {key}` header — that's the standard way, right?\n\nThen I tried connecting with four different MCP clients. Guess what?\n\n * Client A uses `Authorization: Bearer {key}` ✅\n * Client B uses `X-API-Key: {key}` ❌ didn't work\n * Client C puts it in the query string as `api_key={key}` ❌ didn't work\n * Client D uses `apiKey={key}` (different name!) ❌ also didn't work\n\n\n\nI 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 @Component\n public 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\n## 4. CORS Preflight Needs Special Handling (OPTIONS Must Skip Auth)\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` preflight requests.\n\nHere's the gotcha: `OPTIONS` requests _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` requests don't require authentication. Here's how I do it in Spring Boot:\n\n\n\n @Configuration\n public 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\n @Component\n public 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\n## 5. For Slow Operations, Flush the Response Early (Avoids Timeouts)\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\n @PostMapping(\"/mcp/call\")\n public 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\n## 6. Set Content-Length Explicitly If You Can (Avoids Truncation)\n\nThis is a super subtle one. Some MCP clients have issues with chunked encoding. If you don't explicitly set the `Content-Length` header, 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\n\n app.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\n\n String json = objectMapper.writeValueAsString(result);\n response.setContentLength(json.getBytes(StandardCharsets.UTF_8).length);\n response.getWriter().write(json);\n\n\nIt's one extra line, and it eliminates an entire category of weird \"response truncated\" errors. Why not?\n\n## 7. Health Check Endpoints Are Free (They Save You from False Alerts)\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\n @RestController\n public 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\n## My Complete MCP Production Checklist\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 * [ ] **Empty responses:** No empty arrays/lists — return human-readable message when no results\n * [ ] **JSON serialization:** Let framework do it, never manually build JSON strings\n * [ ] **Authentication:** Support all 4 locations: `Authorization: Bearer`, `X-API-Key`, `api_key` query, `apiKey` query\n * [ ] **CORS:** `OPTIONS` preflight requests skip authentication, allow necessary headers\n * [ ] **Timeouts:** Early flush for slow operations/cold starts to keep connection alive\n * [ ] **Content-Length:** Set explicitly when possible to avoid truncation\n * [ ] **Health check:** Simple `/health` endpoint returns 200 OK for monitoring\n * [ ] **Logging:** Log all incoming requests (without sensitive data) for debugging\n * [ ] **Error handling:** Return proper JSON error messages, not 500 HTML pages\n * [ ] **Test with multiple clients:** Don't just test with the client you use — test with at least two different ones\n\n\n\n## Pros & Cons of Building MCP Servers in 2026\n\nLet'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### ✅ What Works Great\n\n * **Standard protocol:** Build once, works in any MCP-compatible client. This is _really_ valuable. I don't need to build separate plugins for Claude, ChatGPT, whatever comes next.\n * **Privacy by design:** Your data stays on your server. AI only gets the specific information it needs for the current query. You don't have to upload all your private notes to someone else's cloud. That's huge for personal projects.\n * **Simple architecture:** Your server just needs to expose two main endpoints: `tools/list` and `tools/call`. That's it. You don't need a complex frontend or anything. Keep it simple.\n * **Great for side projects:** It's fun to connect your personal projects to your AI assistant. I use my MCP knowledge base every day with Claude Desktop, and it's awesome.\n\n\n\n### ❌ What Still Needs Work\n\n * **Fragmentation:** Different clients do things differently (like auth). You need to handle all the edge cases. The spec is still evolving.\n * **Documentation:** Official docs are still pretty sparse. You have to figure out a lot of edge cases by trial and error (hello, this article!).\n * **Ecosystem adoption:** Not every AI client supports MCP yet. It's growing fast, but it's not ubiquitous.\n * **Deployment complexity:** You need your MCP server to be publicly accessible. That means hosting, HTTPS, dealing with ngrok for development, all that stuff. It's doable, but it's extra work.\n\n\n\n## Final Thoughts\n\nBuilding 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\n## What About You?\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\n\nGo build something cool! 🚀",
"title": "MCP Best Practices: 7 Hard Lessons I Learned Building 5 MCP Servers (Full Checklists Included)"
}