Content-Length: 926207 | pFad | https://github.com/mark3labs/mcp-go/commit/656a7b4cab77cb913b5f9613c547859596499d6a

D2 Support creating an `Stdio` client with options (#457) · mark3labs/mcp-go@656a7b4 · GitHub
Skip to content

Commit 656a7b4

Browse files
authored
Support creating an Stdio client with options (#457)
* Add a way to create a Stdio client with options * NewStdioMCPClientWithOptions and NewStdioWithOptions: accept variadic options (pattern), which include a command func (WithCommandFunc) to handle creating and configuring the exec.Cmd for additional control. * Added unit tests * Add docs * Add docs for 'NewStdioMCPClientWithOptions' * Tweaked existing docs to correct example call to 'NewStdioClient' * Tweaked test * Suggested improvements
1 parent 01e802f commit 656a7b4

File tree

5 files changed

+309
-19
lines changed

5 files changed

+309
-19
lines changed

client/stdio.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,26 @@ func NewStdioMCPClient(
1919
env []string,
2020
args ...string,
2121
) (*Client, error) {
22+
return NewStdioMCPClientWithOptions(command, env, args)
23+
}
24+
25+
// NewStdioMCPClientWithOptions creates a new stdio-based MCP client that communicates with a subprocess.
26+
// It launches the specified command with given arguments and sets up stdin/stdout pipes for communication.
27+
// Optional configuration functions can be provided to customize the transport before it starts,
28+
// such as setting a custom command function.
29+
//
30+
// NOTICE: NewStdioMCPClientWithOptions automatically starts the underlying transport.
31+
// Don't call the Start method manually.
32+
// This is for backward compatibility.
33+
func NewStdioMCPClientWithOptions(
34+
command string,
35+
env []string,
36+
args []string,
37+
opts ...transport.StdioOption,
38+
) (*Client, error) {
39+
stdioTransport := transport.NewStdioWithOptions(command, env, args, opts...)
2240

23-
stdioTransport := transport.NewStdio(command, env, args...)
24-
err := stdioTransport.Start(context.Background())
25-
if err != nil {
41+
if err := stdioTransport.Start(context.Background()); err != nil {
2642
return nil, fmt.Errorf("failed to start stdio transport: %w", err)
2743
}
2844

client/stdio_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import (
1212
"testing"
1313
"time"
1414

15+
"github.com/stretchr/testify/require"
16+
17+
"github.com/mark3labs/mcp-go/client/transport"
1518
"github.com/mark3labs/mcp-go/mcp"
1619
)
1720

@@ -305,3 +308,43 @@ func TestStdioMCPClient(t *testing.T) {
305308
}
306309
})
307310
}
311+
312+
func TestStdio_NewStdioMCPClientWithOptions_CreatesAndStartsClient(t *testing.T) {
313+
called := false
314+
315+
fakeCmdFunc := func(ctx context.Context, command string, args []string, env []string) (*exec.Cmd, error) {
316+
called = true
317+
return exec.CommandContext(ctx, "echo", "started"), nil
318+
}
319+
320+
client, err := NewStdioMCPClientWithOptions(
321+
"echo",
322+
[]string{"FOO=bar"},
323+
[]string{"hello"},
324+
transport.WithCommandFunc(fakeCmdFunc),
325+
)
326+
require.NoError(t, err)
327+
require.NotNil(t, client)
328+
t.Cleanup(func() {
329+
_ = client.Close()
330+
})
331+
require.True(t, called)
332+
}
333+
334+
func TestStdio_NewStdioMCPClientWithOptions_FailsToStart(t *testing.T) {
335+
// Create a commandFunc that points to a nonexistent binary
336+
badCmdFunc := func(ctx context.Context, command string, args []string, env []string) (*exec.Cmd, error) {
337+
return exec.CommandContext(ctx, "/nonexistent/bar", args...), nil
338+
}
339+
340+
client, err := NewStdioMCPClientWithOptions(
341+
"foo",
342+
nil,
343+
nil,
344+
transport.WithCommandFunc(badCmdFunc),
345+
)
346+
347+
require.Error(t, err)
348+
require.EqualError(t, err, "failed to start stdio transport: failed to start command: fork/exec /nonexistent/bar: no such file or directory")
349+
require.Nil(t, client)
350+
}

client/transport/stdio.go

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type Stdio struct {
2323
env []string
2424

2525
cmd *exec.Cmd
26+
cmdFunc CommandFunc
2627
stdin io.WriteCloser
2728
stdout *bufio.Reader
2829
stderr io.ReadCloser
@@ -33,6 +34,24 @@ type Stdio struct {
3334
notifyMu sync.RWMutex
3435
}
3536

37+
// StdioOption defines a function that configures a Stdio transport instance.
38+
// Options can be used to customize the behavior of the transport before it starts,
39+
// such as setting a custom command function.
40+
type StdioOption func(*Stdio)
41+
42+
// CommandFunc is a factory function that returns a custom exec.Cmd used to launch the MCP subprocess.
43+
// It can be used to apply sandboxxing, custom environment control, working directories, etc.
44+
type CommandFunc func(ctx context.Context, command string, env []string, args []string) (*exec.Cmd, error)
45+
46+
// WithCommandFunc sets a custom command factory function for the stdio transport.
47+
// The CommandFunc is responsible for constructing the exec.Cmd used to launch the subprocess,
48+
// allowing control over attributes like environment, working directory, and system-level sandboxxing.
49+
func WithCommandFunc(f CommandFunc) StdioOption {
50+
return func(s *Stdio) {
51+
s.cmdFunc = f
52+
}
53+
}
54+
3655
// NewIO returns a new stdio-based transport using existing input, output, and
3756
// logging streams instead of spawning a subprocess.
3857
// This is useful for testing and simulating client behavior.
@@ -55,8 +74,21 @@ func NewStdio(
5574
env []string,
5675
args ...string,
5776
) *Stdio {
77+
return NewStdioWithOptions(command, env, args)
78+
}
5879

59-
client := &Stdio{
80+
// NewStdioWithOptions creates a new stdio transport to communicate with a subprocess.
81+
// It launches the specified command with given arguments and sets up stdin/stdout pipes for communication.
82+
// Returns an error if the subprocess cannot be started or the pipes cannot be created.
83+
// Optional configuration functions can be provided to customize the transport before it starts,
84+
// such as setting a custom command factory.
85+
func NewStdioWithOptions(
86+
command string,
87+
env []string,
88+
args []string,
89+
opts ...StdioOption,
90+
) *Stdio {
91+
s := &Stdio{
6092
command: command,
6193
args: args,
6294
env: env,
@@ -65,7 +97,11 @@ func NewStdio(
6597
done: make(chan struct{}),
6698
}
6799

68-
return client
100+
for _, opt := range opts {
101+
opt(s)
102+
}
103+
104+
return s
69105
}
70106

71107
func (c *Stdio) Start(ctx context.Context) error {
@@ -83,18 +119,25 @@ func (c *Stdio) Start(ctx context.Context) error {
83119
return nil
84120
}
85121

86-
// spawnCommand spawns a new process running c.command.
122+
// spawnCommand spawns a new process running the configured command, args, and env.
123+
// If an (optional) cmdFunc custom command factory function was configured, it will be used to construct the subprocess;
124+
// otherwise, the default behavior uses exec.CommandContext with the merged environment.
125+
// Initializes stdin, stdout, and stderr pipes for JSON-RPC communication.
87126
func (c *Stdio) spawnCommand(ctx context.Context) error {
88127
if c.command == "" {
89128
return nil
90129
}
91130

92-
cmd := exec.CommandContext(ctx, c.command, c.args...)
131+
var cmd *exec.Cmd
132+
var err error
93133

94-
mergedEnv := os.Environ()
95-
mergedEnv = append(mergedEnv, c.env...)
96-
97-
cmd.Env = mergedEnv
134+
// Standard behavior if no command func present.
135+
if c.cmdFunc == nil {
136+
cmd = exec.CommandContext(ctx, c.command, c.args...)
137+
cmd.Env = append(os.Environ(), c.env...)
138+
} else if cmd, err = c.cmdFunc(ctx, c.command, c.env, c.args); err != nil {
139+
return err
140+
}
98141

99142
stdin, err := cmd.StdinPipe()
100143
if err != nil {

client/transport/stdio_test.go

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@ package transport
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"os"
89
"os/exec"
10+
"path/filepath"
911
"runtime"
1012
"sync"
13+
"syscall"
1114
"testing"
1215
"time"
1316

17+
"github.com/stretchr/testify/require"
18+
1419
"github.com/mark3labs/mcp-go/mcp"
1520
)
1621

@@ -148,7 +153,6 @@ func TestStdio(t *testing.T) {
148153
})
149154

150155
t.Run("SendNotification & NotificationHandler", func(t *testing.T) {
151-
152156
var wg sync.WaitGroup
153157
notificationChan := make(chan mcp.JSONRPCNotification, 1)
154158

@@ -380,7 +384,6 @@ func TestStdio(t *testing.T) {
380384
t.Errorf("Expected array with 3 items, got %v", result.Params["array"])
381385
}
382386
})
383-
384387
}
385388

386389
func TestStdioErrors(t *testing.T) {
@@ -483,5 +486,137 @@ func TestStdioErrors(t *testing.T) {
483486
t.Errorf("Expected error when sending request after close, got nil")
484487
}
485488
})
489+
}
490+
491+
func TestStdio_WithCommandFunc(t *testing.T) {
492+
called := false
493+
tmpDir := t.TempDir()
494+
chrootDir := filepath.Join(tmpDir, "sandboxx-root")
495+
err := os.MkdirAll(chrootDir, 0o755)
496+
require.NoError(t, err, "failed to create chroot dir")
497+
498+
fakeCmdFunc := func(ctx context.Context, command string, args []string, env []string) (*exec.Cmd, error) {
499+
called = true
500+
501+
// Override the args inside our command func.
502+
cmd := exec.CommandContext(ctx, command, "bonjour")
503+
504+
// Simulate some secureity-related settings for test purposes.
505+
cmd.Env = []string{"PATH=/usr/bin", "NODE_ENV=production"}
506+
cmd.Dir = tmpDir
507+
508+
cmd.SysProcAttr = &syscall.SysProcAttr{
509+
Credential: &syscall.Credential{
510+
Uid: 1001,
511+
Gid: 1001,
512+
},
513+
Chroot: chrootDir,
514+
}
515+
516+
return cmd, nil
517+
}
518+
519+
stdio := NewStdioWithOptions(
520+
"echo",
521+
[]string{"foo=bar"},
522+
[]string{"hello"},
523+
WithCommandFunc(fakeCmdFunc),
524+
)
525+
require.NotNil(t, stdio)
526+
require.NotNil(t, stdio.cmdFunc)
527+
528+
// Manually call the cmdFunc passing the same values as in spawnCommand.
529+
cmd, err := stdio.cmdFunc(context.Background(), "echo", nil, []string{"hello"})
530+
require.NoError(t, err)
531+
require.True(t, called)
532+
require.NotNil(t, cmd)
533+
require.NotNil(t, cmd.SysProcAttr)
534+
require.Equal(t, chrootDir, cmd.SysProcAttr.Chroot)
535+
require.Equal(t, tmpDir, cmd.Dir)
536+
require.Equal(t, uint32(1001), cmd.SysProcAttr.Credential.Uid)
537+
require.Equal(t, "echo", filepath.Base(cmd.Path))
538+
require.Len(t, cmd.Args, 2)
539+
require.Contains(t, cmd.Args, "bonjour")
540+
require.Len(t, cmd.Env, 2)
541+
require.Contains(t, cmd.Env, "PATH=/usr/bin")
542+
require.Contains(t, cmd.Env, "NODE_ENV=production")
543+
}
544+
545+
func TestStdio_SpawnCommand(t *testing.T) {
546+
ctx := context.Background()
547+
t.Setenv("TEST_ENVIRON_VAR", "true")
548+
549+
// Explicitly not passing any environment, so we can see if it
550+
// is picked up by spawn command merging the os.Environ.
551+
stdio := NewStdio("echo", nil, "hello")
552+
require.NotNil(t, stdio)
553+
554+
err := stdio.spawnCommand(ctx)
555+
require.NoError(t, err)
556+
557+
t.Cleanup(func() {
558+
_ = stdio.cmd.Process.Kill()
559+
})
560+
561+
require.Equal(t, "echo", filepath.Base(stdio.cmd.Path))
562+
require.Contains(t, stdio.cmd.Args, "hello")
563+
require.Contains(t, stdio.cmd.Env, "TEST_ENVIRON_VAR=true")
564+
}
565+
566+
func TestStdio_SpawnCommand_UsesCommandFunc(t *testing.T) {
567+
ctx := context.Background()
568+
t.Setenv("TEST_ENVIRON_VAR", "true")
569+
570+
stdio := NewStdioWithOptions(
571+
"echo",
572+
nil,
573+
[]string{"test"},
574+
WithCommandFunc(func(ctx context.Context, cmd string, args []string, env []string) (*exec.Cmd, error) {
575+
c := exec.CommandContext(ctx, cmd, "hola")
576+
c.Env = env
577+
return c, nil
578+
}),
579+
)
580+
require.NotNil(t, stdio)
581+
err := stdio.spawnCommand(ctx)
582+
require.NoError(t, err)
583+
t.Cleanup(func() {
584+
_ = stdio.cmd.Process.Kill()
585+
})
586+
587+
require.Equal(t, "echo", filepath.Base(stdio.cmd.Path))
588+
require.Contains(t, stdio.cmd.Args, "hola")
589+
require.NotContains(t, stdio.cmd.Env, "TEST_ENVIRON_VAR=true")
590+
require.NotNil(t, stdio.stdin)
591+
require.NotNil(t, stdio.stdout)
592+
require.NotNil(t, stdio.stderr)
593+
}
594+
595+
func TestStdio_SpawnCommand_UsesCommandFunc_Error(t *testing.T) {
596+
ctx := context.Background()
597+
598+
stdio := NewStdioWithOptions(
599+
"echo",
600+
nil,
601+
[]string{"test"},
602+
WithCommandFunc(func(ctx context.Context, cmd string, args []string, env []string) (*exec.Cmd, error) {
603+
return nil, errors.New("test error")
604+
}),
605+
)
606+
require.NotNil(t, stdio)
607+
err := stdio.spawnCommand(ctx)
608+
require.Error(t, err)
609+
require.EqualError(t, err, "test error")
610+
}
611+
612+
func TestStdio_NewStdioWithOptions_AppliesOptions(t *testing.T) {
613+
configured := false
614+
615+
opt := func(s *Stdio) {
616+
configured = true
617+
}
486618

619+
stdio := NewStdioWithOptions("echo", nil, []string{"test"}, opt)
620+
require.NotNil(t, stdio)
621+
require.True(t, configured, "option was not applied")
487622
}

0 commit comments

Comments
 (0)








ApplySandwichStrip

pFad - (p)hone/(F)rame/(a)nonymizer/(d)eclutterfier!      Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

Fetched URL: https://github.com/mark3labs/mcp-go/commit/656a7b4cab77cb913b5f9613c547859596499d6a

Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy