Skip to content

Commit d7e56a4

Browse files
committed
mcp: allow configurable terminate duration for CommandTransport
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 f37e549 commit d7e56a4

File tree

2 files changed

+86
-5
lines changed

2 files changed

+86
-5
lines changed

mcp/cmd.go

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

16+
const (
17+
defaultTerminateDuration = 5 * time.Second
18+
minTerminateDuration = 1 * time.Second
19+
)
20+
1621
// A CommandTransport is a [Transport] that runs a command and communicates
1722
// with it over stdin/stdout, using newline-delimited JSON.
1823
type CommandTransport struct {
1924
Command *exec.Cmd
25+
// TerminateDuration controls how long Close waits after closing stdin
26+
// for the process to exit before sending SIGTERM.
27+
// If less than 1 second (including zero or negative), the default of 5s is used.
28+
TerminateDuration time.Duration
2029
}
2130

2231
// NewCommandTransport returns a [CommandTransport] that runs the given command
@@ -46,15 +55,20 @@ func (t *CommandTransport) Connect(ctx context.Context) (Connection, error) {
4655
if err := t.Command.Start(); err != nil {
4756
return nil, err
4857
}
49-
return newIOConn(&pipeRWC{t.Command, stdout, stdin}), nil
58+
terminateDuration := t.TerminateDuration
59+
if terminateDuration < minTerminateDuration {
60+
terminateDuration = defaultTerminateDuration
61+
}
62+
return newIOConn(&pipeRWC{t.Command, stdout, stdin, terminateDuration}), nil
5063
}
5164

5265
// A pipeRWC is an io.ReadWriteCloser that communicates with a subprocess over
5366
// stdin/stdout pipes.
5467
type pipeRWC struct {
55-
cmd *exec.Cmd
56-
stdout io.ReadCloser
57-
stdin io.WriteCloser
68+
cmd *exec.Cmd
69+
stdout io.ReadCloser
70+
stdin io.WriteCloser
71+
terminateDuration time.Duration
5872
}
5973

6074
func (s *pipeRWC) Read(p []byte) (n int, err error) {
@@ -85,7 +99,7 @@ func (s *pipeRWC) Close() error {
8599
select {
86100
case err := <-resChan:
87101
return err, true
88-
case <-time.After(5 * time.Second):
102+
case <-time.After(s.terminateDuration):
89103
}
90104
return nil, false
91105
}

mcp/cmd_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,73 @@ func createServerCommand(t *testing.T, serverName string) *exec.Cmd {
239239
return cmd
240240
}
241241

242+
func TestCommandTransportTerminateDuration(t *testing.T) {
243+
if runtime.GOOS == "windows" {
244+
t.Skip("requires POSIX signals")
245+
}
246+
requireExec(t)
247+
248+
tests := []struct {
249+
name string
250+
duration time.Duration
251+
wantMaxDuration time.Duration
252+
}{
253+
{
254+
name: "default duration (zero)",
255+
duration: 0,
256+
wantMaxDuration: 6 * time.Second, // default 5s + buffer
257+
},
258+
{
259+
name: "below minimum duration",
260+
duration: 500 * time.Millisecond,
261+
wantMaxDuration: 6 * time.Second, // should use default 5s + buffer
262+
},
263+
{
264+
name: "custom valid duration",
265+
duration: 2 * time.Second,
266+
wantMaxDuration: 3 * time.Second, // custom 2s + buffer
267+
},
268+
}
269+
270+
for _, tt := range tests {
271+
t.Run(tt.name, func(t *testing.T) {
272+
ctx, cancel := context.WithCancel(context.Background())
273+
defer cancel()
274+
275+
// Use a command that won't exit when stdin is closed
276+
cmd := exec.Command("sleep", "3600")
277+
transport := &mcp.CommandTransport{
278+
Command: cmd,
279+
TerminateDuration: tt.duration,
280+
}
281+
282+
conn, err := transport.Connect(ctx)
283+
if err != nil {
284+
t.Fatal(err)
285+
}
286+
287+
start := time.Now()
288+
err = conn.Close()
289+
elapsed := time.Since(start)
290+
291+
// Close() may return "signal: terminated" when the subprocess is killed,
292+
// which is expected behavior for our test with a non-responsive subprocess
293+
if err != nil && err.Error() != "signal: terminated" {
294+
t.Fatalf("Close() failed with unexpected error: %v", err)
295+
}
296+
297+
if elapsed > tt.wantMaxDuration {
298+
t.Errorf("Close() took %v, expected at most %v", elapsed, tt.wantMaxDuration)
299+
}
300+
301+
// Ensure the process was actually terminated
302+
if cmd.Process != nil {
303+
cmd.Process.Kill()
304+
}
305+
})
306+
}
307+
}
308+
242309
func requireExec(t *testing.T) {
243310
t.Helper()
244311

0 commit comments

Comments
 (0)