# MCP Connection Issues: Why My MCP Server Kept Dropping Connections and How I Fixed It (After 86 Production Outages)

> Source: <https://dev.to/kevinten10/mcp-connection-issues-why-my-mcp-server-kept-dropping-connections-and-how-i-fixed-it-after-86-2bpp>
> Published: 2026-06-25 13:37:38+00:00

Honestly, I thought I had everything figured out after 86 outages. Logging was done. Health checks were in place. Tool discovery had fuzzy matching. Authentication handled every possible location. Rate limiting was working.

But my MCP server kept dropping connections randomly.

Three days of debugging later, I found the problem — and it wasn't what I expected. It was all about SSE (Server-Sent Events) streaming. If you're building an MCP server, you need to read this. I saved you three days of headache.

Let me set the scene. My MCP server has been running in production for a while. Everything works fine most of the time. But randomly, clients would get disconnected mid-request. Sometimes it happened after 30 seconds. Sometimes after 2 minutes. Sometimes it worked perfectly.

The worst part? It only happened in production. Locally, everything worked fine. Classic "it works on my machine" situation.

Here's what the error looked like from the client side:

```
Error: disconnected before response completed
```

That's it. No additional info. Just ... disconnected.

Like any good developer, I started blaming the usual suspects:

I spent half a day going through this list. Nothing worked. The problem kept happening.

MCP uses SSE for the connection from server to client. The client sends the JSON-RPC request over HTTP POST, and the server streams responses back via SSE.

Here's what I missed: **SSE connections are idle connections**. If nothing is being sent, some proxies/load balancers will drop them.

Wait, but how long is idle? Depends on your hosting. On Fly.io, the default idle timeout is 75 seconds. On Cloudflare, it's 100 seconds. On Heroku, it's 55 seconds.

And here's the thing about MCP: most of the time, the connection is idle. The client connects, waits for the LLM to process the request, then the server sends the response. If the LLM takes more than your proxy's idle timeout ... *boom*. Connection dropped.

I know, I know. You're thinking "just increase the timeout." But you can't always do that. Some platforms don't let you change it. And even if you can, what if the LLM is processing a really big request that takes 5 minutes? You can't just set the timeout to 10 minutes – that creates other problems.

SSE allows any comment line starting with `:`

to be sent as a heartbeat. These comments don't interrupt the actual event stream, they just keep the connection alive.

Here's what it looks like on the wire:

```
: heartbeat
data: {"jsonrpc":"2.0","method":"tools/list","result":{"tools":[...]}

: heartbeat
: heartbeat
data: {"jsonrpc":"2.0","method":"tools/call","result":{"content":[...]}
```

The client just ignores the comment lines. But they count as activity, so the proxy doesn't drop the connection.

Brilliant, right? Simple, effective, almost no code.

I'm building my MCP server with Java Spring Boot, so let me show you the complete implementation. It's about 30 lines of code.

First, the SSE emitter configuration with heartbeats:

``` python
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;

public class McpSseConnection {
    private final SseEmitter emitter;
    private final ScheduledExecutorService scheduler;
    private ScheduledFuture<?> heartbeatTask;
    private static final long HEARTBEAT_INTERVAL = 30; // seconds

    public McpSseConnection() {
        // Use 0 timeout because we handle heartbeats ourselves
        this.emitter = new SseEmitter(0L);
        this.scheduler = Executors.newSingleThreadScheduledExecutor();
        startHeartbeat();
    }

    private void startHeartbeat() {
        heartbeatTask = scheduler.scheduleAtFixedRate(() -> {
            try {
                // Send a comment heartbeat – this keeps the connection alive
                emitter.send(SseEmitter.event().comment("heartbeat"));
            } catch (IOException e) {
                // Connection closed, stop heartbeats
                heartbeatTask.cancel(false);
                scheduler.shutdown();
            }
        }, HEARTBEAT_INTERVAL, HEARTBEAT_INTERVAL, TimeUnit.SECONDS);
    }

    public SseEmitter getEmitter() {
        return emitter;
    }

    public void complete() {
        heartbeatTask.cancel(false);
        scheduler.shutdown();
        emitter.complete();
    }

    public void completeWithError(Throwable ex) {
        heartbeatTask.cancel(false);
        scheduler.shutdown();
        emitter.completeWithError(ex);
    }
}
```

Then in your controller, use it like this:

``` python
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@RestController
public class McpController {

    @PostMapping(value = "/mcp/messages", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter handleMessages() {
        McpSseConnection connection = new McpSseConnection();
        SseEmitter emitter = connection.getEmitter();

        // Process the MCP request asynchronously
        mcpMessageProcessor.process(request, emitter)
            .whenComplete((result, error) -> {
                if (error != null) {
                    connection.completeWithError(error);
                } else {
                    connection.complete();
                }
            });

        return emitter;
    }
}
```

That's it. Thirty lines of code. Problem solved.

Oh right, you're going to hit CORS preflight issues with SSE too. Let me save you another hour. Make sure your CORS configuration allows OPTIONS for the SSE endpoint and doesn't require auth for preflight:

``` python
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOriginPattern("*"); // customize for your domains
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        // Allow preflight to be cached for 1 hour
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }
}
```

The key point: **don't require authentication for OPTIONS requests**. Most proxies and browsers expect OPTIONS to be unauthenticated. If you require auth on OPTIONS, preflight will fail before the connection even starts.

Another gotcha! If you're using Nginx in front of your server, turn off proxy buffering for SSE endpoints:

```
location /mcp/ {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_chunked_transfer_encoding on;
    proxy_buffering off;
    proxy_cache off;
}
```

If you leave proxy buffering on, Nginx will buffer the entire response before sending it to the client. That means no streaming – the client waits until everything is done. Which defeats the whole purpose of SSE streaming.

I learned this the hard way. Everything looked fine locally, but on production with Nginx, nothing worked until I added these settings.

Let me be honest with you – this isn't perfect. Nothing is. Here's what works and what doesn't.

✅ **Simple** – 30 lines of code, no complex infrastructure

✅ **Works everywhere** – every proxy respects heartbeats

✅ **Low overhead** – 30-second heartbeats is basically nothing for your server

✅ **Backward compatible** – all MCP clients work with this, no changes needed

✅ **Cheap** – doesn't consume any extra resources when the connection is active

❌ **Still consumes a connection** – heartbeat keeps it alive, but you still have an open HTTP connection

❌ **Doesn't solve very long pauses** – if your LLM takes 10 minutes to respond, you're still at risk of something dropping it

❌ **Extra scheduler thread** – but one thread per connection isn't a big deal for personal use

**Use this approach if:**

**Consider websockets instead if:**

Honestly, for most personal MCP servers, this approach is perfect. It's simple, it works, and you can implement it in 10 minutes. I've been running it for a month now – zero dropped connections since deploying.

After 86 outages and three days of debugging, here's what I'll remember:

Have you built an MCP server? Did you run into connection dropping issues? Did you find a different solution? I'd love to hear about it in the comments.

And if this saved you three days of debugging – drop a ❤️! It helps other people find this article.

*Check out the full implementation on GitHub: kevinten10/Papers – it's open source and you can copy-paste the heartbeat code directly into your own project.*
