DEV Community

MCP Best Practices: 7 Hard Lessons I Learned Building 5 MCP Servers (Full Checklists Included)

Always Handle Empty Responses Properly (Your Clients Will Hang Without It)

So 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.

Then 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.

I 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.

Here's what I do now (Java example, but the principle applies everywhere):

public class SearchKnowledgeHandler implements ToolCallHandler {
    @Override
    public Object handle(ToolCallRequest request) {
        List<KnowledgeResult> results = searchService.search(request.getQuery());
        if (results.isEmpty()) {
            // ❌ BAD: This will hang some clients
            // return Collections.emptyList();

            // ✅ GOOD: Return a human-readable explanation
            return Collections.singletonList(Map.of(
                "result", "No results found for your query: '" + request.getQuery() + "'. " +
                          "Try different keywords or check your spelling."
            ));
        }
        return results;
    }
}

This 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.

Manually Building JSON Will Destroy You (Let the Framework Do It)

I 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.

Here'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:

// ❌ NEVER DO THIS. I BEG YOU.
String badJson = "{\"result\": \"" + userInput + "\"}";

Everything 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".

Lesson: Always, always, always let your framework serialize JSON for you. Never manually build JSON strings. Never.

// ✅ Do this instead
record SearchResponse(List<Result> results, String query) {}
return new SearchResponse(foundResults, query); // Framework handles serialization

If 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.

Support API Key in Multiple Places (Because Different Clients Do It Differently)

Okay, this one surprised me. I read the spec, implemented API key authentication in the Authorization: Bearer {key} header - that's the standard way, right?

Then I tried connecting with four different MCP clients. Guess what?

  • Client A uses Authorization: Bearer {key}
  • Client B uses X-API-Key: {key} ❌ didn't work
  • Client C puts it in the query string as api_key={key} ❌ didn't work
  • Client D uses apiKey={key} (different name!) ❌ also didn't work

I 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.

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:

@Component
public class McpAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    FilterChain filterChain)
            throws ServletException, IOException {
        // Try all common locations
        String apiKey = getApiKeyFromAuthorizationHeader(request);
        if (apiKey == null) {
            apiKey = request.getHeader("X-API-Key");
        }
        if (apiKey == null) {
            apiKey = request.getParameter("api_key");
        }
        if (apiKey == null) {
            apiKey = request.getParameter("apiKey");
        }

        if (!validateApiKey(apiKey)) {
            request.getServletResponse().sendError(HttpStatus.UNAUTHORIZED.value());
            return;
        }
        filterChain.doFilter(request, filterChain);
    }

    private String getApiKeyFromAuthorizationHeader(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            return header.substring(7);
        }
        return null;
    }
}

Yes, 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.

CORS Preflight Needs Special Handling (OPTIONS Must Skip Auth)

If 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.

Here'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.

I 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.

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:

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins("*") // Restrict this to your actual clients in production
            .allowedMethods("GET", "POST", "OPTIONS")
            .allowedHeaders("*")
            .exposedHeaders("*");
    }
}

And make sure your authentication filter skips OPTIONS requests:

@Component
public class McpAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        // Preflight OPTIONS requests don't have auth headers
        return request.getMethod().equals("OPTIONS");
    }
    // ... rest of the filter
}

That's it. This one change will save you from the weirdest "it works on desktop but not on web" debugging session.

For Slow Operations, Flush the Response Early (Avoids Timeouts)

Cold 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.

I 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.

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.

Here's a concrete example in Spring Boot where you can write the HTTP status and headers immediately:

@PostMapping("/mcp/call")
public void handleToolCall(@RequestBody McpRequest request,
                           HttpServletResponse response) throws IOException {
    // Start writing early to keep the connection alive
    response.setContentType("application/json");
    response.setStatus(HttpStatus.OK.value());
    PrintWriter writer = response.getWriter();

    // Do your slow processing here...
    ToolCallResult result = slowToolService.execute(request);

    // Write the actual result when you're done
    writer.write(objectMapper.writeValueAsString(result));
    writer.flush();
}

This 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.

Set Content-Length Explicitly If You Can (Avoids Truncation)

This 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.

I only found this after comparing what my server sent vs what the client received byte-for-byte. Yep, the last few characters were missing.

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.

Here's how to do it in Node.js/Express:

app.post('/mcp/call', (req, res) => {
    const result = handleRequest(req.body);
    const json = JSON.stringify(result);

    // ✅ Set Content-Length explicitly
    res.setHeader('Content-Length', Buffer.byteLength(json));
    res.setHeader('Content-Type', 'application/json');
    res.send(json);
});

In Java Spring Boot, if you're writing directly to the response:

String json = objectMapper.writeValueAsString(result);
response.setContentLength(json.getBytes(StandardCharsets.UTF_8).length);
response.getWriter().write(json);

It's one extra line, and it eliminates an entire category of weird "response truncated" errors. Why not?

Health Check Endpoints Are Free (They Save You from False Alerts)

If 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. Fly.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.

The simplest possible health check:

@RestController
public class HealthCheckController {
    @GetMapping("/health")
    public Map<String, Object> health() {
        return Map.of(
            "status", "ok",
            "timestamp", System.currentTimeMillis(),
            "service", "mcp-server"
        );
    }
}

That'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.

My Complete MCP Production Checklist

Here'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:

  • [ ] Empty responses: No empty arrays/lists - return human-readable message when no results
  • [ ] JSON serialization: Let framework do it, never manually build JSON strings
  • [ ] Authentication: Support all 4 locations: Authorization: Bearer, X-API-Key, api_key query, apiKey query
  • [ ] CORS: OPTIONS preflight requests skip authentication, allow necessary headers
  • [ ] Timeouts: Early flush for slow operations/cold starts to keep connection alive
  • [ ] Content-Length: Set explicitly when possible to avoid truncation
  • [ ] Health check: Simple /health endpoint returns 200 OK for monitoring
  • [ ] Logging: Log all incoming requests (without sensitive data) for debugging
  • [ ] Error handling: Return proper JSON error messages, not 500 HTML pages
  • [ ] Test with multiple clients: Don't just test with the client you use - test with at least two different ones

Pros & Cons of Building MCP Servers in 2026

Let'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:

✅ What Works Great

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.

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.

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.

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.

❌ What Still Needs Work

Fragmentation: Different clients do things differently.

Comments

No comments yet. Start the discussion.