Skip to content

discuss: how to surface ClientOptions on convenience constructors - #891

Open
QuentinBisson wants to merge 1 commit into
mark3labs:mainfrom
QuentinBisson:feat/client-constructor-options
Open

discuss: how to surface ClientOptions on convenience constructors#891
QuentinBisson wants to merge 1 commit into
mark3labs:mainfrom
QuentinBisson:feat/client-constructor-options

Conversation

@QuentinBisson

@QuentinBisson QuentinBisson commented May 18, 2026

Copy link
Copy Markdown
Contributor

Problem

The convenience client constructors reserve their variadic slot for transport-level options:

func NewStdioMCPClient(command string, env []string, args ...string) (*Client, error)
func NewSSEMCPClient(baseURL string, options ...transport.ClientOption) (*Client, error)
func NewStreamableHttpClient(baseURL string, options ...transport.StreamableHTTPCOption) (*Client, error)

Client-level options — WithTracer, WithPropagator, WithSession, WithSamplingHandler, WithRootsHandler, WithElicitationHandler, WithClientCapabilities — have no entry point on these constructors. Today users have to drop to the lower-level NewClient(transport, opts...) and reproduce the transport-construction code (notably the stdio.Start(ctx) step the convenience helpers paper over).

This PR is open for design discussion before code lands. Below are the alternatives I see; the current commits show Option C as a worked example but I'd rather the maintainer pick.

Option A — modify the existing signatures (breaking)

func NewStdioMCPClient(command string, env []string, args []string, opts ...ClientOption) (*Client, error)
func NewSSEMCPClient(baseURL string, transportOpts []transport.ClientOption, opts ...ClientOption) (*Client, error)
func NewStreamableHttpClient(baseURL string, transportOpts []transport.StreamableHTTPCOption, opts ...ClientOption) (*Client, error)
  • Pros: single canonical constructor per transport. No duplicated API surface.
  • Cons: breaks every existing call site. For stdio, args ...string becomes args []string so callers wrap their args in a slice literal. For SSE / streamable-http, transport options move to a leading slice parameter.
  • Pre-1.0 library, so this is on the table.

Option B — method on *Client for post-construction options

func (c *Client) With(opts ...ClientOption) *Client

Call sites:

c, err := client.NewStdioMCPClient(cmd, env, args...)
if err != nil { ... }
c.With(client.WithTracer(t), client.WithPropagator(p))
  • Pros: non-breaking. One method covers all three transports. Reads idiomatically (Go fluent-builder pattern).
  • Cons: option application is a separate statement from construction; can't fold into a single expression because of err return.

Option C — add new constructors alongside the existing ones

func NewStdioClient(command string, env []string, args []string, opts ...ClientOption) (*Client, error)
func NewSSEClient(baseURL string, transportOpts []transport.ClientOption, opts ...ClientOption) (*Client, error)
func NewStreamableHTTPClient(baseURL string, transportOpts []transport.StreamableHTTPCOption, opts ...ClientOption) (*Client, error)
  • Pros: non-breaking.
  • Cons: doubles the constructor surface. Two near-identical entry points per transport, the docs have to explain the difference.

The current commits on this branch implement Option C. Tests pass; NewStreamableHTTPClient preserves the auto-WithSession behaviour from the existing constructor when the transport reports an active session ID.

Recommendation

I'd lean Option A — pre-1.0 is the right time, the migration is mechanical, and Option C's duplicate surface bothers me. But I want your call before pushing code in one direction.

Happy to reshape the PR into whichever option you prefer.

Summary by CodeRabbit

  • New Features

    • Added new constructor functions for creating MCP clients with consistent option handling across stdio, SSE, and HTTP-based transports.
    • Enhanced client construction to support variadic client options uniformly across all transport types.
  • Tests

    • Added comprehensive test coverage validating constructor behavior and client option application.

Review Change Stack

@mark-iii-labs-huly

Copy link
Copy Markdown

Connected to Huly®: MCP_G-456

@coderabbitai

coderabbitai Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This PR adds three new convenience constructors (NewStdioClient, NewSSEClient, NewStreamableHTTPClient) that standardize MCP client creation with consistent parameter patterns: transport options as slices, client options as variadic arguments. Each constructor handles transport creation, error wrapping, and optional auto-session behavior. Comprehensive tests validate that client options propagate correctly and conditional session application works as designed.

Changes

Client Convenience Constructors

Layer / File(s) Summary
Convenience constructors for stdio, SSE, and HTTP
client/stdio.go, client/sse.go, client/http.go
NewStdioClient creates and starts a stdio transport, wrapping start errors. NewSSEClient and NewStreamableHTTPClient build SSE and HTTP transports from option slices, with NewStreamableHTTPClient conditionally auto-applying WithSession() when a session ID is available.
Constructor option validation tests
client/constructor_options_test.go
Four tests verify that WithSession() client options are properly applied by each constructor, that transport and client options remain separated, and that NewStreamableHTTPClient preserves auto-session behavior only when the transport reports an active session.

🎯 2 (Simple) | ⏱️ ~10 minutes


Suggested reviewers

  • ezynda3
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title uses a vague term 'discuss' and poses a question format rather than summarizing the actual implementation change. Revise the title to clearly describe the implemented feature, such as 'Add new convenience constructors that accept ClientOption parameters' or 'Add NewStdioClient, NewSSEClient, and NewStreamableHTTPClient constructors'.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description is comprehensive, explaining the problem, proposing three design options, and documenting the current implementation choice with relevant context and trade-offs.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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.
@QuentinBisson
QuentinBisson force-pushed the feat/client-constructor-options branch from 34ecaff to 5841ef2 Compare May 18, 2026 11:35
@QuentinBisson QuentinBisson changed the title feat(client): add ApplyClientOptions for post-construction option setup feat(client): add NewStdioClient/NewSSEClient/NewStreamableHTTPClient May 18, 2026
@QuentinBisson QuentinBisson changed the title feat(client): add NewStdioClient/NewSSEClient/NewStreamableHTTPClient discuss: how to surface ClientOptions on convenience constructors May 18, 2026
@QuentinBisson
QuentinBisson marked this pull request as ready for review May 18, 2026 13:40

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@client/constructor_options_test.go`:
- Around line 13-72: Convert the four separate tests into a single table-driven
test that iterates over cases for NewStdioClient, NewSSEClient,
NewStreamableHTTPClient and the auto-session case (referencing the constructors
NewStdioClient, NewSSEClient, NewStreamableHTTPClient), and replace all t.Fatalf
checks with testify/require and testify/assert calls (use require.NoError for
constructor errors, require.NotNil for returned client where appropriate, and
assert.True/assert.False for the initialized checks). Keep cleanup by calling
t.Cleanup(func(){ _ = c.Close() }) per case; for SSE and HTTP constructors
include the transport.ClientOption and transport header case as a test case
input. Ensure the table has fields for name, constructor args, and
expectedInitialized, and run each case with t.Run(name, func(t *testing.T){ ...
}) using require/assert.

In `@client/http.go`:
- Around line 43-46: The code aliases the caller-provided slice opts into
clientOpts and then appends WithSession(), which can mutate the caller's backing
array; change clientOpts to be a copy of opts before appending when
trans.GetSessionId() != "" (e.g., allocate a new slice and copy opts into it,
then append WithSession()) so appending the session option does not modify the
original opts slice used by the caller; update the block around clientOpts,
opts, trans.GetSessionId() and WithSession() accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 00120425-29b8-4730-99e2-200bd2f788f8

📥 Commits

Reviewing files that changed from the base of the PR and between ca26738 and 5841ef2.

📒 Files selected for processing (4)
  • client/constructor_options_test.go
  • client/http.go
  • client/sse.go
  • client/stdio.go

Comment on lines +13 to +72
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")
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Align these tests with repository testing conventions.

These tests use t.Fatalf and duplicated per-case functions; the repo guideline requires testify/assert + testify/require and table-driven tests.

As per coding guidelines, "Use testify/assert and testify/require for testing; implement table-driven tests with tests := []struct{ name, ... }."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/constructor_options_test.go` around lines 13 - 72, Convert the four
separate tests into a single table-driven test that iterates over cases for
NewStdioClient, NewSSEClient, NewStreamableHTTPClient and the auto-session case
(referencing the constructors NewStdioClient, NewSSEClient,
NewStreamableHTTPClient), and replace all t.Fatalf checks with testify/require
and testify/assert calls (use require.NoError for constructor errors,
require.NotNil for returned client where appropriate, and
assert.True/assert.False for the initialized checks). Keep cleanup by calling
t.Cleanup(func(){ _ = c.Close() }) per case; for SSE and HTTP constructors
include the transport.ClientOption and transport header case as a test case
input. Ensure the table has fields for name, constructor args, and
expectedInitialized, and run each case with t.Run(name, func(t *testing.T){ ...
}) using require/assert.

Comment thread client/http.go
Comment on lines +43 to +46
clientOpts := opts
if trans.GetSessionId() != "" {
clientOpts = append(clientOpts, WithSession())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid mutating caller-provided option slices.

Line 43 aliases opts, and Line 45 may append into the same backing array. If the caller passed someOpts..., this can unexpectedly mutate shared state. Copy before appending.

Suggested fix
-	clientOpts := opts
+	clientOpts := append([]ClientOption(nil), opts...)
 	if trans.GetSessionId() != "" {
 		clientOpts = append(clientOpts, WithSession())
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
clientOpts := opts
if trans.GetSessionId() != "" {
clientOpts = append(clientOpts, WithSession())
}
clientOpts := append([]ClientOption(nil), opts...)
if trans.GetSessionId() != "" {
clientOpts = append(clientOpts, WithSession())
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/http.go` around lines 43 - 46, The code aliases the caller-provided
slice opts into clientOpts and then appends WithSession(), which can mutate the
caller's backing array; change clientOpts to be a copy of opts before appending
when trans.GetSessionId() != "" (e.g., allocate a new slice and copy opts into
it, then append WithSession()) so appending the session option does not modify
the original opts slice used by the caller; update the block around clientOpts,
opts, trans.GetSessionId() and WithSession() accordingly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant