cd /news/developer-tools/mcp-connection-issues-why-my-mcp-ser… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-39379] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

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

A developer discovered that random disconnections in their MCP server were caused by idle timeouts on SSE connections, with proxies dropping connections after 30-100 seconds of inactivity. The fix involved implementing SSE heartbeat comments every 30 seconds to keep the connection alive without interrupting the event stream. The solution was demonstrated using Java Spring Boot's SseEmitter with a scheduled heartbeat task.

read6 min views1 publishedJun 25, 2026

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:

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:

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:

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 s – 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.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @mcp 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/mcp-connection-issue…] indexed:0 read:6min 2026-06-25 Β· β€”