Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 0 additions & 31 deletions pkg/backend/transparent.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,27 +125,7 @@ func (p *TransparentBackend) Run(ctx context.Context) (http.Handler, error) {
base: http.DefaultTransport,
targetHost: p.url.Host,
},
// FlushInterval -1 enables immediate flushing for SSE streams.
// Without this the proxy buffers the response, breaking
// Server-Sent Events used by MCP Streamable HTTP.
FlushInterval: -1,
// ErrorHandler prevents the default ReverseProxy behavior of
// panicking when the backend closes the connection (e.g. when
// an SSE stream ends or the client disconnects). Instead we
// log the error and return a 502 to the client.
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
p.logger.Warn("reverse proxy error",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.Error(err),
)
// Only write an error response if headers haven't been sent yet
// (i.e. the stream hasn't started). For in-flight SSE streams
// the ResponseWriter is already flushed, so just return.
if !isHeadersSent(w) {
http.Error(w, "Bad Gateway", http.StatusBadGateway)
}
},
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(p.url)
if p.isTrusted(pr.In.RemoteAddr) {
Expand All @@ -168,17 +148,6 @@ func (p *TransparentBackend) Run(ctx context.Context) (http.Handler, error) {
return &rp, nil
}

// isHeadersSent checks whether the ResponseWriter has already started sending
// the response (status + headers). Once flushed we can no longer write an
// error status, so callers should just close the connection instead.
func isHeadersSent(w http.ResponseWriter) bool {
// A non-zero status in the header map means WriteHeader was called.
// The standard http.response tracks this internally; the only reliable
// external signal is whether Content-Type (or any header set by the
// backend) is already present in the response.
return w.Header().Get("Content-Type") != ""
}

func (p *TransparentBackend) isTrusted(hostport string) bool {
if host, _, err := net.SplitHostPort(hostport); err == nil {
hostport = host
Expand Down
8 changes: 7 additions & 1 deletion pkg/mcp-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,13 @@ func Run(
})

router.Use(ginzap.Ginzap(logger, time.RFC3339, true))
router.Use(ginzap.RecoveryWithZap(logger, true))
router.Use(ginzap.CustomRecoveryWithZap(logger, true, func(c *gin.Context, err any) {
if err == http.ErrAbortHandler {
c.Abort()
return
}
c.AbortWithStatus(http.StatusInternalServerError)
}))
store := cookie.NewStore(secret)
store.Options(sessions.Options{
Path: "/",
Expand Down
16 changes: 0 additions & 16 deletions pkg/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,22 +87,6 @@ func (p *ProxyRouter) handleProxy(c *gin.Context) {
}
}

// Recover from http.ErrAbortHandler panics that occur when SSE
// streams end (backend closes connection, client disconnects).
// The default ReverseProxy panics with ErrAbortHandler on flush
// errors — this is expected for SSE and should not crash the proxy.
defer func() {
if r := recover(); r != nil {
// http.ErrAbortHandler is the sentinel panic used by
// net/http to silently abort a handler. Let it go.
if r == http.ErrAbortHandler {
return
}
// Re-panic for anything unexpected
panic(r)
}
}()

p.proxy.ServeHTTP(c.Writer, c.Request)
}

Expand Down
Loading