Skip to content

Commit bae3558

Browse files
authored
mcp: allow configurable terminate duration for CommandTransport (#363)
Make the process termination timeout configurable instead of using a hardcoded 5-second delay. This allows applications to customize the termination behavior based on their specific needs. Fixes #322.
1 parent e06cc69 commit bae3558

File tree

2 files changed

+86
-5
lines changed

2 files changed

+86
-5
lines changed

mcp/cmd.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,18 @@ import (
1313
"time"
1414
)
1515

16+
const (
17+
defaultTerminateDuration = 5 * time.Second
18+
)
19+
1620
// A CommandTransport is a [Transport] that runs a command and communicates
1721
// with it over stdin/stdout, using newline-delimited JSON.
1822
type CommandTransport struct {
1923
Command *exec.Cmd
24+
// TerminateDuration controls how long Close waits after closing stdin
25+
// for the process to exit before sending SIGTERM.
26+
// If zero or negative, the default of 5s is used.
27+
TerminateDuration time.Duration
2028
}
2129

2230
// NewCommandTransport returns a [CommandTransport] that runs the given command
@@ -46,15 +54,20 @@ func (t *CommandTransport) Connect(ctx context.Context) (Connection, error) {
4654
if err := t.Command.Start(); err != nil {
4755
return nil, err
4856
}
49-
return newIOConn(&pipeRWC{t.Command, stdout, stdin}), nil
57+
td := t.TerminateDuration
58+
if td <= 0 {
59+
td = defaultTerminateDuration
60+
}
61+
return newIOConn(&pipeRWC{t.Command, stdout, stdin, td}), nil
5062
}
5163

5264
// A pipeRWC is an io.ReadWriteCloser that communicates with a subprocess over
5365
// stdin/stdout pipes.
5466
type pipeRWC struct {
55-
cmd *exec.Cmd
56-
stdout io.ReadCloser
57-
stdin io.WriteCloser
67+
cmd *exec.Cmd
68+
stdout io.ReadCloser
69+
stdin io.WriteCloser
70+
terminateDuration time.Duration
5871
}
5972

6073
func (s *pipeRWC) Read(p []byte) (n int, err error) {
@@ -85,7 +98,7 @@ func (s *pipeRWC) Close() error {
8598
select {
8699
case err := <-resChan:
87100
return err, true
88-
case <-time.After(5 * time.Second):
101+
case <-time.After(s.terminateDuration):
89102
}
90103
return nil, false
91104
}

mcp/cmd_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,74 @@ func createServerCommand(t *testing.T, serverName string) *exec.Cmd {
251251
return cmd
252252
}
253253

254+
func TestCommandTransportTerminateDuration(t *testing.T) {
255+
if runtime.GOOS == "windows" {
256+
t.Skip("requires POSIX signals")
257+
}
258+
requireExec(t)
259+
260+
tests := []struct {
261+
name string
262+
duration time.Duration
263+
wantMaxDuration time.Duration
264+
}{
265+
{
266+
name: "default duration (zero)",
267+
duration: 0,
268+
wantMaxDuration: 6 * time.Second, // default 5s + buffer
269+
},
270+
{
271+
name: "below minimum duration",
272+
duration: 500 * time.Millisecond,
273+
wantMaxDuration: 6 * time.Second, // should use default 5s + buffer
274+
},
275+
{
276+
name: "custom valid duration",
277+
duration: 2 * time.Second,
278+
wantMaxDuration: 3 * time.Second, // custom 2s + buffer
279+
},
280+
}
281+
282+
for _, tt := range tests {
283+
t.Run(tt.name, func(t *testing.T) {
284+
ctx, cancel := context.WithCancel(context.Background())
285+
defer cancel()
286+
287+
// Use a command that won't exit when stdin is closed
288+
cmd := exec.Command("sleep", "20")
289+
transport := &mcp.CommandTransport{
290+
Command: cmd,
291+
TerminateDuration: tt.duration,
292+
}
293+
294+
conn, err := transport.Connect(ctx)
295+
if err != nil {
296+
t.Fatal(err)
297+
}
298+
299+
start := time.Now()
300+
err = conn.Close()
301+
elapsed := time.Since(start)
302+
303+
if err != nil {
304+
var exitErr *exec.ExitError
305+
if !errors.As(err, &exitErr) {
306+
t.Fatalf("Close() failed with unexpected error: %v", err)
307+
}
308+
}
309+
310+
if elapsed > tt.wantMaxDuration {
311+
t.Errorf("Close() took %v, expected at most %v", elapsed, tt.wantMaxDuration)
312+
}
313+
314+
// Ensure the process was actually terminated
315+
if cmd.Process != nil {
316+
cmd.Process.Kill()
317+
}
318+
})
319+
}
320+
}
321+
254322
func requireExec(t *testing.T) {
255323
t.Helper()
256324

0 commit comments

Comments
 (0)