Skip to content

Commit 3ccfe66

Browse files
authored
feat(http): add default timeouts and graceful shutdown for MCP server/client (#77)
1 parent 39b0436 commit 3ccfe66

4 files changed

Lines changed: 121 additions & 7 deletions

File tree

cmd/apiutil/client.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ import (
77
"net/http"
88
"net/url"
99
"strings"
10+
"time"
1011

1112
api "seerr-cli/pkg/api"
1213

1314
"github.com/spf13/viper"
1415
)
1516

17+
// DefaultHTTPClientTimeout is the timeout applied to all outbound API requests.
18+
const DefaultHTTPClientTimeout = 30 * time.Second
19+
1620
// OverrideServerURL is used by tests to redirect API calls to a mock server.
1721
var OverrideServerURL string
1822

@@ -101,9 +105,12 @@ func NewAPIClientWithKeyAndTransport(apiKey string, transport http.RoundTripper)
101105
if key != "" {
102106
configuration.AddDefaultHeader("X-Api-Key", key)
103107
}
108+
// Always set an explicit timeout so outbound requests cannot hang indefinitely.
109+
httpClient := &http.Client{Timeout: DefaultHTTPClientTimeout}
104110
if transport != nil {
105-
configuration.HTTPClient = &http.Client{Transport: transport}
111+
httpClient.Transport = transport
106112
}
113+
configuration.HTTPClient = httpClient
107114
if OverrideServerURL != "" {
108115
configuration.Servers = api.ServerConfigurations{{URL: OverrideServerURL, Description: "Mock Server"}}
109116
}

cmd/mcp/serve.go

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import (
55
"crypto/subtle"
66
"fmt"
77
"net/http"
8+
"os"
9+
"os/signal"
810
"strings"
11+
"syscall"
12+
"time"
913

1014
"seerr-cli/cmd/apiutil"
1115

@@ -14,6 +18,28 @@ import (
1418
"github.com/spf13/viper"
1519
)
1620

21+
// Default timeout values for the MCP HTTP server.
22+
const (
23+
httpReadHeaderTimeout = 5 * time.Second
24+
httpReadTimeout = 15 * time.Second
25+
httpWriteTimeout = 30 * time.Second
26+
httpIdleTimeout = 60 * time.Second
27+
httpShutdownTimeout = 30 * time.Second
28+
)
29+
30+
// NewHTTPServer creates an http.Server bound to addr with safe default timeouts.
31+
// It is exported so tests can assert that the server is properly configured.
32+
func NewHTTPServer(addr string, handler http.Handler) *http.Server {
33+
return &http.Server{
34+
Addr: addr,
35+
Handler: handler,
36+
ReadHeaderTimeout: httpReadHeaderTimeout,
37+
ReadTimeout: httpReadTimeout,
38+
WriteTimeout: httpWriteTimeout,
39+
IdleTimeout: httpIdleTimeout,
40+
}
41+
}
42+
1743
var buildVersion = "dev"
1844

1945
// SetVersionInfo injects the linker-set build version so the MCP server can
@@ -178,14 +204,36 @@ func runServe(_ *cobra.Command, args []string) error {
178204
if cors {
179205
handler = corsMiddleware(handler)
180206
}
181-
srv := &http.Server{
182-
Addr: addr,
183-
Handler: handler,
184-
}
207+
srv := NewHTTPServer(addr, handler)
208+
209+
// Catch SIGINT and SIGTERM so the server shuts down gracefully.
210+
sigCh := make(chan os.Signal, 1)
211+
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
212+
213+
serveErrCh := make(chan error, 1)
185214
if tlsCert != "" && tlsKey != "" {
186-
return srv.ListenAndServeTLS(tlsCert, tlsKey)
215+
go func() { serveErrCh <- srv.ListenAndServeTLS(tlsCert, tlsKey) }()
216+
} else {
217+
go func() { serveErrCh <- srv.ListenAndServe() }()
218+
}
219+
220+
select {
221+
case err := <-serveErrCh:
222+
// Server exited on its own (e.g. port already in use).
223+
if err != nil && err != http.ErrServerClosed {
224+
return err
225+
}
226+
return nil
227+
case <-sigCh:
228+
mcpLog.Info("shutting down MCP HTTP server")
229+
}
230+
231+
shutdownCtx, cancel := context.WithTimeout(context.Background(), httpShutdownTimeout)
232+
defer cancel()
233+
if err := srv.Shutdown(shutdownCtx); err != nil {
234+
return fmt.Errorf("graceful shutdown: %w", err)
187235
}
188-
return srv.ListenAndServe()
236+
return nil
189237
default:
190238
return fmt.Errorf("unknown transport %q: must be stdio or http", transport)
191239
}

tests/apiutil_client_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package tests
22

33
import (
4+
"net/http"
45
"testing"
6+
"time"
57

68
"seerr-cli/cmd/apiutil"
79

810
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
912
)
1013

1114
func TestNormalizeServerURL(t *testing.T) {
@@ -56,3 +59,21 @@ func TestNormalizeServerURL(t *testing.T) {
5659
})
5760
}
5861
}
62+
63+
func TestDefaultHTTPClientHasTimeout(t *testing.T) {
64+
// Verify the default HTTP client carries a 30 s timeout so requests cannot hang indefinitely.
65+
client := apiutil.NewAPIClientWithKeyAndTransport("", nil)
66+
cfg := client.GetConfig()
67+
require.NotNil(t, cfg.HTTPClient)
68+
assert.Equal(t, 30*time.Second, cfg.HTTPClient.Timeout)
69+
}
70+
71+
func TestCustomTransportAlsoGetsTimeout(t *testing.T) {
72+
// Verify a custom transport still gets wrapped in a client with the default timeout.
73+
transport := &http.Transport{}
74+
client := apiutil.NewAPIClientWithKeyAndTransport("", transport)
75+
cfg := client.GetConfig()
76+
require.NotNil(t, cfg.HTTPClient)
77+
assert.Equal(t, 30*time.Second, cfg.HTTPClient.Timeout)
78+
assert.Equal(t, transport, cfg.HTTPClient.Transport)
79+
}

tests/mcp_http_server_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package tests
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
"time"
7+
8+
cmdmcp "seerr-cli/cmd/mcp"
9+
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestHTTPServerHasNonZeroTimeouts(t *testing.T) {
14+
// Verify the HTTP server is created with non-zero timeout values to prevent
15+
// resource exhaustion from slow or stuck connections.
16+
srv := cmdmcp.NewHTTPServer(":8811", http.NewServeMux())
17+
assert.NotZero(t, srv.ReadHeaderTimeout)
18+
assert.NotZero(t, srv.ReadTimeout)
19+
assert.NotZero(t, srv.WriteTimeout)
20+
assert.NotZero(t, srv.IdleTimeout)
21+
}
22+
23+
func TestHTTPServerTimeoutValues(t *testing.T) {
24+
// Verify the HTTP server timeout values match the documented safe defaults.
25+
srv := cmdmcp.NewHTTPServer(":8811", http.NewServeMux())
26+
assert.Equal(t, 5*time.Second, srv.ReadHeaderTimeout)
27+
assert.Equal(t, 15*time.Second, srv.ReadTimeout)
28+
assert.Equal(t, 30*time.Second, srv.WriteTimeout)
29+
assert.Equal(t, 60*time.Second, srv.IdleTimeout)
30+
}
31+
32+
func TestHTTPServerAddrAndHandler(t *testing.T) {
33+
// Verify the Addr and Handler are set correctly.
34+
mux := http.NewServeMux()
35+
srv := cmdmcp.NewHTTPServer(":9999", mux)
36+
assert.Equal(t, ":9999", srv.Addr)
37+
assert.NotNil(t, srv.Handler)
38+
}

0 commit comments

Comments
 (0)