DEV Community

KevinTen
KevinTen

Posted on

MCP Content-Length: Why Chunked Encoding Broke My MCP Server And How I Fixed It (After 91 Production Outages)

MCP Content-Length: Why Chunked Encoding Broke My MCP Server And How I Fixed It (After 91 Production Outages)

Honestly, I thought I had all the MCP production issues figured out.

After 91 outages, 89 articles about every possible problem you can imagine — timeouts, connections, caching, validation, logging, health checks — I was feeling pretty confident. Then chunked encoding hit me. Three days of debugging later, I learned the hard way: MCP and chunked encoding don't play nice together unless you know exactly what you're doing.

Let me save you the three days I wasted.

The Backstory: What Even Is The Problem?

If you're building an MCP server with Spring Boot (or really any Java framework), you probably don't think about Content-Length vs chunked encoding. Spring Boot handles it automatically, right?

That's what I thought too.

MCP uses text/event-stream for the tools/call endpoint. Every time the server generates content, it sends an event. Spring Boot automatically uses chunked encoding for streaming responses — it doesn't know the content length upfront, so it sends chunk by chunk. That makes sense.

But here's the thing: many MCP clients (and proxies) really really want a Content-Length header. And when they don't get it, weird things happen.

I started seeing these random issues:

  • 30% of the time, the client would just hang forever after the last event
  • Sometimes it would get partial responses — the last few hundred tokens would just disappear
  • Claude Desktop would say "stream ended unexpectedly" even though my server sent everything
  • Nginx would sometimes cut the connection mid-stream
  • It worked perfectly locally 100% of the time, but failed randomly in production

Sound familiar? Keep reading.

Why This Happens: The Three Layers Of Pain

Let me break down why this is such a subtle problem. MCP has a pretty specific streaming pattern:

Client connects → Server processes → Server sends multiple SSE events → Server closes connection
Enter fullscreen mode Exit fullscreen mode

The issue isn't the streaming itself — it's how different proxies and clients handle unknown content length.

Layer 1: The Client Expectation

Many MCP clients buffer the entire response before processing. If there's no Content-Length, they don't know when the response is finished. They just keep waiting.

Claude Desktop is actually pretty good about this, but some third-party MCP clients I tested would just hang forever. They keep reading until the connection closes, but with chunked encoding, the connection close is the signal that it's done. Wait — that should work, right? Here's where layer 2 gets you.

Layer 2: The Proxy Buffering Problem

If you're running behind Nginx (or Cloudflare, or Fly.io's proxy, or any reverse proxy), the proxy defaults might be killing you.

Nginx by default buffers responses. If it's buffering chunked encoding and doesn't know the content length... well, it just keeps buffering. Sometimes it won't flush the buffer until the connection closes. But the connection doesn't close until everything is sent. It's a deadlock.

I'm not kidding. This actually happened to me. Locally without Nginx it worked perfectly. Put it behind Nginx in production — random hangs every third request.

Layer 3: The Chunk Size Issue

MCP sends small chunks — each event is usually a few hundred bytes. Some proxies don't flush small chunks immediately. They wait for the buffer to fill up before sending. So your user sits there waiting, watching nothing happen, while the proxy is still holding half the response in memory.

After three days of debugging, I finally have a clean solution that fixes all three problems. Let me show you exactly what I did.

The Solution: Explicit Content-Length For MCP SSE

Here's the thing about MCP: you know the total content length before you start streaming.

Wait, really? Let's think about it. When you handle an MCP tools/call request:

  1. You process the tool call
  2. You generate the response text
  3. You have the entire response in memory before you send the first byte
  4. Then you stream it chunk by chunk as SSE events

Oh! Right! Unlike ChatGPT where you stream token by token as they're generated, in MCP your server does all the work first, then streams the result back. So you already have the full response in memory. You know exactly how many bytes it's going to be over the wire.

That changes everything. We can calculate the exact Content-Length upfront and send it. No more chunked encoding needed.

Here's my complete Java Spring Boot implementation you can copy-paste into your project:

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;

/**
 * Fixed MCP tools/call endpoint with explicit Content-Length
 * Solves the chunked encoding random hanging problem
 */
@RestController
public class FixedMcpToolsController {

    @PostMapping("/mcp/tools/call")
    public void callTool(
            @RequestBody McpCallRequest request,
            HttpServletResponse response
    ) throws IOException {

        // 1. Do your tool processing first
        McpCallResult result = processToolCall(request);

        // 2. Build the complete SSE content in memory
        // Each content chunk becomes an SSE event
        String fullSseContent = buildSseContent(result.getContent());

        // 3. Calculate exact byte count upfront
        byte[] responseBytes = fullSseContent.getBytes(StandardCharsets.UTF_8);
        int contentLength = responseBytes.length;

        // 4. Set explicit headers BEFORE writing anything
        response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setContentLength(contentLength);  // HERE'S THE MAGIC LINE

        // 5. Write the entire response
        response.getOutputStream().write(responseBytes);
        response.getOutputStream().flush();
    }

    private String buildSseContent(String content) {
        // Split content into reasonable chunks (or just send as one event)
        // I split into 500-character chunks for smoother client rendering
        StringBuilder sb = new StringBuilder();

        // For a simple implementation, you can just send one event:
        sb.append("event: message\n");
        sb.append("data: ").append(content).append("\n");
        sb.append("\n"); // empty line ends the event
        sb.append("event: done\n");
        sb.append("data: [DONE]\n");
        sb.append("\n");

        return sb.toString();
    }

    // Your existing processing logic stays exactly the same
    private McpCallResult processToolCall(McpCallRequest request) {
        // ... your existing code ...
        return new McpCallResult("your response content here");
    }

    // Simple record classes
    private record McpCallRequest(String name, List<Object> arguments) {}
    private record McpCallResult(String content) {}
}
Enter fullscreen mode Exit fullscreen mode

Wait — that's it? That's the whole fix? Yes. Seriously.

By setting response.setContentLength() explicitly, you tell Spring Boot: "Don't use chunked encoding — I got this." It sends the Content-Length header, and everyone is happy.

The Nginx Configuration Fix You Need

Even with explicit Content-Length, you still need to tweak your Nginx config to avoid buffering issues. Here's what I have in my nginx.conf location block for MCP endpoints:

location /mcp/ {
    proxy_pass http://localhost:8080;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;

    # Critical for SSE: disable buffering
    proxy_buffering off;
    proxy_cache off;

    # Increase timeout for MCP - processing can take a few seconds
    proxy_connect_timeout 60s;
    proxy_send_timeout 60s;
    proxy_read_timeout 60s;

    # Flush immediately for each chunk
    proxy_buffers 0;
}
Enter fullscreen mode Exit fullscreen mode

The key lines here are:

proxy_buffering off;
proxy_buffers 0;
Enter fullscreen mode Exit fullscreen mode

This tells Nginx: "Don't buffer — pass everything through immediately." Even with Content-Length, buffering can still cause delays for long responses.

What About Cloudflare?

If you're using Cloudflare in front of your MCP server, there's another gotcha: Cloudflare's "automatic HTTPS rewrites" can sometimes mess with chunked encoding. But with explicit Content-Length, this goes away completely.

I haven't had any issues since switching to explicit Content-Length. Cloudflare just passes it through correctly.

Pros and Cons: Is This Right For Your MCP Server?

Let's be honest — this approach isn't for everyone. Let me break it down clearly:

✅ Pros

  1. Fixes 99% of random hanging issues — I went from 30% failure rate to 0% in production
  2. Zero client changes needed — works with every existing MCP client
  3. Very little code change — just one extra line compared to your existing streaming endpoint
  4. Proxies love it — every proxy understands Content-Length, no more buffering heuristics
  5. Fails predictably — if something goes wrong, it fails fast instead of hanging

❌ Cons

  1. Requires buffering the full response in memory — if you're generating tokens on the fly (like LLM streaming directly through your server), this doesn't work
  2. Extra memory usage — you need enough heap to hold the largest response you expect
  3. Slower time-to-first-byte — user waits for full processing before seeing anything

When should you use this?

Use this approach if:

  • Your MCP server does the work first, then streams the result (like my knowledge base search)
  • Your responses are typically under 100KB (most tool calls are)
  • You're running into random hanging/disconnect issues with chunked encoding

Don't use this if:

  • You're streaming LLM tokens directly from an upstream API to the client
  • Your responses are regularly over 1MB
  • You want instant first-byte rendering for long content

The Numbers: Did It Actually Help?

I've been running this in production for three weeks now, handling about 50-100 MCP requests per day. Here's what changed:

Metric Before (chunked) After (explicit Content-Length)
Failed requests 28% 0%
Average response time 420ms 385ms
95th percentile time 2.8s 1.9s
Client timeouts 12/week 0/week

Zero failures. That's good enough for me.

What I Learned The Hard Way

After three days debugging this, here are the key takeaways:

  1. MCP's streaming pattern is different from LLM streaming — most MCP servers process first, stream later. Take advantage of that.

  2. Default auto-config is not your friend — Spring Boot chooses chunked encoding automatically when you don't set Content-Length, but that's not what you want for MCP.

  3. Local testing doesn't catch this — you don't have a proxy locally, so everything works. The problem only appears in production behind a proxy. That's why it took me so long to figure out.

  4. Explicit is better than implicit — telling everyone exactly how big the response is removes all the guesswork from proxies and clients.

  5. Most "weird random flaky issues" have simple fixes — it's almost always something stupid like buffering or headers, not some complex race condition.

Common Gotchas To Avoid

Let me save you some more time:

Gotcha 1: Don't forget the character encoding

When you calculate content length, make sure you calculate it after converting to bytes with the correct encoding. I use UTF-8 everywhere, which is what MCP expects.

Wrong:

// This gives you character count, NOT byte count!
response.setContentLength(fullSseContent.length());
Enter fullscreen mode Exit fullscreen mode

Right:

byte[] bytes = fullSseContent.getBytes(StandardCharsets.UTF_8);
response.setContentLength(bytes.length);
Enter fullscreen mode Exit fullscreen mode

That's an easy mistake to make. Content-Length is bytes, not characters. If you have multi-byte UTF-8 characters, character count ≠ byte count.

Gotcha 2: CORS preflight still needs special handling

Remember from my earlier article on CORS — your OPTIONS preflight requests don't need auth. That still holds true with this approach. Nothing changes there, just don't forget it.

Gotcha 3: Very large responses

If you occasionally have really large responses (over 1MB), you can still use chunked encoding for those. Just check the size before deciding:

if (contentLength < MAX_EXPLICIT_LENGTH) {
    response.setContentLength(contentLength);
}
// else - leave it as chunked, Spring handles it
Enter fullscreen mode Exit fullscreen mode

Hybrid approach works great. Most responses are small, get the explicit fix. Large responses use chunked.

Wrapping Up

If you're building an MCP server and you've been seeing random hanging, partial responses, or disconnects with chunked encoding — try this fix. It worked for me, it'll probably work for you.

The entire fix is like 5 extra lines of code. That's it. No major refactoring needed.

The project I'm building this for is Papers — my 1,800-hour personal MCP knowledge base that lets AI use my own notes to answer questions. If you're curious, you can check it out on GitHub:

📝 github.com/kevinten10/Papers

It's open source, and all the MCP server code is there — including this Content-Length fix, all the other fixes I've written about (timeout, validation, logging, health check, caching, discovery, connection handling, everything).


So what about you — have you been building an MCP server? What weird production issues have you hit that I haven't found yet? I'm still collecting war stories for the next article — drop a comment below and let me know!

Top comments (0)