From 5841ef298ebc8e2308073e824e086e41368be37d Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Mon, 18 May 2026 13:35:13 +0200 Subject: [PATCH] feat(client): add NewStdioClient/NewSSEClient/NewStreamableHTTPClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing convenience constructors (NewStdioMCPClient, NewSSEMCPClient, NewStreamableHttpClient) reserve their variadic slot for transport-level options. Client-level options (WithTracer, WithPropagator, WithSession, WithSamplingHandler, …) have no entry point on those constructors and can only be reached by dropping to NewClient + manual transport construction. Add new constructors that take ClientOption as the final variadic parameter; transport options remain available via a leading slice where applicable. Existing constructors are kept verbatim, so this is strictly additive. - NewStdioClient(cmd, env, args, ...ClientOption) - NewSSEClient(url, []transport.ClientOption, ...ClientOption) - NewStreamableHTTPClient(url, []transport.StreamableHTTPCOption, ...ClientOption) NewStreamableHTTPClient preserves the auto-WithSession behaviour from NewStreamableHttpClient when the transport reports an active session ID, so callers swapping over keep the same semantics. --- client/constructor_options_test.go | 72 ++++++++++++++++++++++++++++++ client/http.go | 26 +++++++++++ client/sse.go | 25 +++++++++++ client/stdio.go | 24 ++++++++++ 4 files changed, 147 insertions(+) create mode 100644 client/constructor_options_test.go diff --git a/client/constructor_options_test.go b/client/constructor_options_test.go new file mode 100644 index 000000000..419cd7eb0 --- /dev/null +++ b/client/constructor_options_test.go @@ -0,0 +1,72 @@ +package client + +import ( + "testing" + + "github.com/mark3labs/mcp-go/client/transport" +) + +// TestNewStdioClient_AppliesClientOptions exercises the new constructor's +// promise: every ClientOption passed in the variadic slot is applied to the +// returned client. WithSession is convenient because it sets a single bool +// observable from the test. +func TestNewStdioClient_AppliesClientOptions(t *testing.T) { + // Use a no-op command for the underlying subprocess; the test only + // cares that NewStdioClient applies the options before returning. + c, err := NewStdioClient("true", nil, nil, WithSession()) + if err != nil { + t.Fatalf("NewStdioClient: %v", err) + } + t.Cleanup(func() { _ = c.Close() }) + if !c.initialized { + t.Fatalf("WithSession not applied; initialized=false") + } +} + +// TestNewSSEClient_AppliesClientOptions verifies that NewSSEClient routes +// the variadic ClientOption arguments through to the constructed client +// while keeping transportOpts separate. +func TestNewSSEClient_AppliesClientOptions(t *testing.T) { + c, err := NewSSEClient( + "http://example.invalid/sse", + []transport.ClientOption{transport.WithHeaders(map[string]string{"x-test": "1"})}, + WithSession(), + ) + if err != nil { + t.Fatalf("NewSSEClient: %v", err) + } + if !c.initialized { + t.Fatalf("WithSession not applied; initialized=false") + } +} + +// TestNewStreamableHTTPClient_AppliesClientOptions verifies the same routing +// for the streamable-http convenience constructor. +func TestNewStreamableHTTPClient_AppliesClientOptions(t *testing.T) { + c, err := NewStreamableHTTPClient( + "http://example.invalid/mcp", + nil, + WithSession(), + ) + if err != nil { + t.Fatalf("NewStreamableHTTPClient: %v", err) + } + if !c.initialized { + t.Fatalf("WithSession not applied; initialized=false") + } +} + +// TestNewStreamableHTTPClient_PreservesAutoSession asserts that the +// existing "transport reports an active session ID → set WithSession" +// behaviour from NewStreamableHttpClient is preserved on the new +// constructor. The fixture transport here has no session, so initialized +// reflects only the caller-supplied opts. +func TestNewStreamableHTTPClient_PreservesAutoSession(t *testing.T) { + c, err := NewStreamableHTTPClient("http://example.invalid/mcp", nil) + if err != nil { + t.Fatalf("NewStreamableHTTPClient: %v", err) + } + if c.initialized { + t.Fatalf("initialized=true without an active session; auto-session must not fire") + } +} diff --git a/client/http.go b/client/http.go index d001a1e63..fa08be4a9 100644 --- a/client/http.go +++ b/client/http.go @@ -20,3 +20,29 @@ func NewStreamableHttpClient(baseURL string, options ...transport.StreamableHTTP } return NewClient(trans, clientOptions...), nil } + +// NewStreamableHTTPClient creates a new streamable-http-based MCP client +// with the given base URL, applying the provided transport-level options +// when constructing the transport and the provided client-level options +// to the returned client. +// +// Pass transport options (e.g. transport.WithContinuousListening) as a +// slice in transportOpts, and client options (e.g. WithTracer, +// WithPropagator) as the variadic opts. When the transport reports an +// active session ID at construction time, WithSession is appended +// automatically so the returned client skips re-initialisation. +func NewStreamableHTTPClient( + baseURL string, + transportOpts []transport.StreamableHTTPCOption, + opts ...ClientOption, +) (*Client, error) { + trans, err := transport.NewStreamableHTTP(baseURL, transportOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create streamable-http transport: %w", err) + } + clientOpts := opts + if trans.GetSessionId() != "" { + clientOpts = append(clientOpts, WithSession()) + } + return NewClient(trans, clientOpts...), nil +} diff --git a/client/sse.go b/client/sse.go index 390d90922..db12393b0 100644 --- a/client/sse.go +++ b/client/sse.go @@ -51,6 +51,31 @@ func NewSSEMCPClient(baseURL string, options ...transport.ClientOption) (*Client return NewClient(sseTransport), nil } +// NewSSEClient creates a new SSE-based MCP client with the given base URL, +// applying the provided transport-level options when constructing the +// transport and the provided client-level options to the returned client. +// +// Pass transport options (e.g. WithHeaders, WithHTTPClient) as a slice in +// transportOpts, and client options (e.g. WithTracer, WithPropagator) as +// the variadic opts: +// +// c, err := client.NewSSEClient( +// url, +// []transport.ClientOption{client.WithHeaders(h)}, +// client.WithTracer(t), +// ) +func NewSSEClient( + baseURL string, + transportOpts []transport.ClientOption, + opts ...ClientOption, +) (*Client, error) { + sseTransport, err := transport.NewSSE(baseURL, transportOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create SSE transport: %w", err) + } + return NewClient(sseTransport, opts...), nil +} + // GetEndpoint returns the current endpoint URL for the SSE connection. // // Note: This method only works with SSE transport, or it will panic. diff --git a/client/stdio.go b/client/stdio.go index 6a2d89bcf..55dbe0f5a 100644 --- a/client/stdio.go +++ b/client/stdio.go @@ -44,6 +44,30 @@ func NewStdioMCPClientWithOptions( return NewClient(stdioTransport), nil } +// NewStdioClient creates a new stdio-based MCP client that communicates with +// a subprocess. It launches the specified command with the given environment +// variables and arguments, starts the underlying transport, and applies the +// supplied client-level options (e.g. WithTracer, WithPropagator) to the +// returned client. +// +// Callers that need transport-level options (e.g. a custom command function) +// should fall back to NewStdioMCPClientWithOptions or construct the transport +// directly with transport.NewStdioWithOptions and call NewClient. +func NewStdioClient( + command string, + env []string, + args []string, + opts ...ClientOption, +) (*Client, error) { + stdioTransport := transport.NewStdioWithOptions(command, env, args) + + if err := stdioTransport.Start(context.Background()); err != nil { + return nil, fmt.Errorf("failed to start stdio transport: %w", err) + } + + return NewClient(stdioTransport, opts...), nil +} + // GetStderr returns a reader for the stderr output of the subprocess. // This can be used to capture error messages or logs from the subprocess. //