Skip to content

Wire MCP client options in mcptool.Connect: server sampling handler and list_changed notifications - #659

Open
PratikDhanave (PratikDhanave) wants to merge 4 commits into
microsoft:mainfrom
PratikDhanaveFork:wire-mcp-client-options
Open

Wire MCP client options in mcptool.Connect: server sampling handler and list_changed notifications#659
PratikDhanave (PratikDhanave) wants to merge 4 commits into
microsoft:mainfrom
PratikDhanaveFork:wire-mcp-client-options

Conversation

@PratikDhanave

Copy link
Copy Markdown
Contributor

What

mcptool.Connect constructed the MCP client with a nil *mcp.ClientOptions, so no client-side handlers were ever registered. Two go-sdk capabilities were therefore dead:

  • Sampling. ClientOptions.CreateMessageHandler auto-advertises the sampling capability when non-nil and dispatches sampling/createMessage. With nil options the client advertised no sampling and rejected every server-initiated createMessage, so an MCP server that delegates sub-reasoning to the host model could not work.
  • List changed. ClientOptions.ToolListChangedHandler / PromptListChangedHandler are dispatched for notifications/tools/list_changed and notifications/prompts/list_changed, but with nil options those notifications were dropped, so a server adding/removing tools mid-session could not refresh the caller's static tool set.

Both gaps live in the same Connect construction site. This change builds a non-nil ClientOptions from additive functional options and keeps Connect behaving exactly as before when called with no options:

  • WithSampling(client, approve, maxTokens) sets CreateMessageHandler. It denies by default when no approver is supplied, clamps the server-requested MaxTokens, translates the request messages and SystemPrompt through the existing mcpContentToAgentContent mapper, invokes the caller's chat client, and returns the reply as a CreateMessageResult{Role: assistant, StopReason: endTurn} built with the existing agentContentToMCPContent mapper.
  • WithToolListChanged(cb) / WithPromptListChanged(cb) set the notification handlers to invoke the caller callback (typically re-running mcptool.ListTools).

Why (cross-SDK parity)

Python wires ClientSession(sampling_callback=...) and denies sampling by default unless the host explicitly opts in; .NET wires a sampling IChatClient on the MCP client. The Go port previously exposed neither, so host-in-the-loop sampling and dynamic tool/prompt refresh were unreachable. The deny-by-default policy here mirrors the Python client so a server cannot silently borrow the host model.

Tests

Added to the canonical tool/mcptool/mcp_test.go, black-box via the in-memory transport already used by the suite:

  • Sampling deny-by-default: the server issues CreateMessage; with WithSampling and no approver the call returns an error and the stub chat client is never invoked.
  • Sampling approve-all: with an approving callback the stub is invoked, MaxTokens is clamped, the system prompt and user message are translated in order, and the reply is returned as an assistant/endTurn result.
  • Tool list changed: WithToolListChanged fires the callback after server.AddTool, and a subsequent ListTools returns the newly added tool.

go build ./..., go vet ./tool/mcptool/..., and go test -race ./tool/mcptool/... pass.

Open design questions

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions github-actions Bot added the public-api-change Pull Request changes public APIs label Jul 24, 2026
Connect passed a nil *mcp.ClientOptions to mcp.NewClient, so no client
handlers were registered and two go-sdk capabilities were dead: the client
advertised no sampling capability and rejected every server-initiated
sampling/createMessage, and it ignored notifications/tools/list_changed and
notifications/prompts/list_changed.

Build a non-nil ClientOptions from additive functional options. WithSampling
registers CreateMessageHandler: deny-by-default when no approver is supplied
(mirrors the Python client's sampling_callback policy), clamp the requested
MaxTokens, translate the request messages and system prompt via the existing
content mapper, invoke the caller's chat client, and return the reply as an
assistant CreateMessageResult with an endTurn stop reason. WithToolListChanged
and WithPromptListChanged forward the notifications to a caller callback so a
static tool set can be refreshed mid-session. Connect keeps its previous
behavior when called with no options.
@github-actions

This comment has been minimized.

@PratikDhanave
PratikDhanave (PratikDhanave) marked this pull request as ready for review August 4, 2026 06:06
@PratikDhanave
PratikDhanave (PratikDhanave) requested a review from a team as a code owner August 4, 2026 06:06
Copilot AI lite review requested due to automatic review settings August 4, 2026 06:06

Copilot AI 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.

Pull request overview

This PR fixes mcptool.Connect constructing the MCP client with nil options by introducing functional ConnectOptions so client-side MCP handlers (sampling and list-changed notifications) can be registered when desired, while preserving existing behavior when called with no options.

Changes:

  • Add ConnectOption plumbing and wire non-nil *mcp.ClientOptions into mcp.NewClient from mcptool.Connect.
  • Implement opt-in sampling support via WithSampling, including deny-by-default approval gating and token clamping.
  • Add tool/prompt list-changed notification callbacks (WithToolListChanged, WithPromptListChanged) and extend tests to cover sampling + tool list refresh.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
tool/mcptool/mcp.go Adds ConnectOptions, sampling handler wiring, and list-changed notification handlers to the MCP client constructed by Connect.
tool/mcptool/mcp_test.go Extends the in-memory harness to pass ConnectOptions and adds black-box tests for sampling and tools list-changed behavior.

Comment thread tool/mcptool/mcp_test.go
Comment on lines +1116 to +1120
select {
case <-changed:
case <-time.After(2 * time.Second):
t.Fatal("tool list changed callback did not fire")
}
@github-actions

This comment has been minimized.

@github-actions github-actions 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.

Generated by Go API Consistency Review Agent · sonnet46 · 41.4 AIC · ⌖ 5.8 AIC · ⊞ 5.7K

Comments that could not be inline-anchored

tool/mcptool/mcp.go:31

Parity gap — missing per-session request cap (Python sampling_max_requests)

WithSampling clamps the token budget per request but has no upper bound on how many sampling requests a server may issue per session connection. Python enforces this with sampling_max_requests (default 25), resetting the counter on reconnect. The comment in _mcp.py calls this out explicitly:

# - ``_DEFAULT_SAMPLING_MAX_REQUESTS`` caps the number of sampling requests per
#   session connection (the co…

</details>

@gdams

Copy link
Copy Markdown
Member

PratikDhanave (@PratikDhanave) needs a rebase

…le with new MCP SDK)

Upstream bumped the MCP go-sdk to protocol 2026-07-28, where SEP-2322 forbids a
server from sending sampling/createMessage while serving a request (it must
return an InputRequests map for the multi-round-trip flow). The WithSampling
option relied on the old single-shot CreateMessageHandler, which no longer works.

Drop the sampling option and its helpers/tests for now, keeping the working
ConnectOption plumbing and the tool/prompt list-changed notification handlers.
Sampling can be re-added against the SEP-2322 InputRequests flow as a follow-up.
@github-actions github-actions Bot added area:tool Changes files in the tool area size:large At most 300 changed lines across at most 10 files pending-auto-risk Automatic risk classification is in progress labels Aug 20, 2026
@github-actions

This comment has been minimized.

@github-actions github-actions 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.

Generated by Go API Consistency Review Agent · sonnet46 · 82.3 AIC · ⌖ 5.29 AIC · ⊞ 6K

Comment thread tool/mcptool/mcp.go
}, clientOptions)
return client.Connect(ctx, transport, nil)
}

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.

Parity gap: WithSampling is absent from this PR but is claimed in the description and is needed to match upstream.

The PR description states that WithSampling(client, approve, maxTokens) is included in this change, but mcp.go contains no such exported function. Upstream Python wires sampling with three guardrails that all interact:

Guardrail Python default Go (this PR)
Deny by default sampling_approval_callback=None → deny missing
Token cap sampling_max_tokens=4096 missing
Per-session request limit sampling_max_requests=25 missing

Python source: python/packages/core/agent_framework/_mcp.py – lines _DEFAULT_SAMPLING_MAX_TOKENS = 4096 and _DEFAULT_SAMPLING_MAX_REQUESTS = 25, and the deny logic at the top of the sampling callback.

The Go SamplingChatClient interface and stubSamplingChatClient test helper are added in mcp_test.go but no exported WithSampling option and no test that exercises sampling (deny-by-default or approve-all) is present in the production file. Until WithSampling is added with at minimum the deny-by-default guard, Go callers connecting to an MCP server that issues sampling/createMessage will receive an MCP-SDK-level error rather than the policy-controlled denial that Python and the PR description both promise.

Comment thread tool/mcptool/mcp_test.go
gotTexts []string
}

func (s *stubSamplingChatClient) GetResponse(_ context.Context, messages []*message.Message, maxTokens int64) (*message.Message, error) {

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.

Dead code: stubSamplingChatClient is unused in this PR.

The stubSamplingChatClient type and its GetResponse method are added here but no test in this PR exercises them — there is no WithSampling option to wire, so the stub is never called. Either the sampling tests were accidentally omitted from this PR, or this helper should be removed to keep the test file clean. If WithSampling will be added in a follow-up, the stub should be added with it rather than here.

@github-actions github-actions Bot added failed-auto-risk Automatic risk classification was inconclusive or failed and removed pending-auto-risk Automatic risk classification is in progress labels Aug 20, 2026
@github-actions github-actions Bot added pending-auto-risk Automatic risk classification is in progress and removed failed-auto-risk Automatic risk classification was inconclusive or failed labels Aug 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Parity Review: Cross-SDK Consistency

PR scope: Adds three exported public APIs to tool/mcptool:

  • ConnectOption functional option type
  • WithToolListChanged(cb func()) ConnectOption
  • WithPromptListChanged(cb func()) ConnectOption
  • Connect updated to accept ...ConnectOption (backward-compatible)

Note: The PR description mentions WithSampling but that is not included in this diff — only the list-change notification handlers and the option wiring infrastructure are added here. The stubSamplingChatClient in the test file is an unexported test helper, not a public API.

Upstream parity mapping

Concept Python .NET Go (this PR)
Tool list refresh callback internal reload on notifications/tools/list_changed not in public MCP client API WithToolListChanged
Prompt list refresh callback internal handling not found WithPromptListChanged
Sampling opt-in sampling_callback + deny-by-default + sampling_max_requests=25 IChatClient on MCP client not in this PR — left as follow-up per PR description

Assessment

The two notification-handler options (WithToolListChanged, WithPromptListChanged) are conceptually aligned with upstream behavior — both Python and .NET clients handle list_changed notifications to keep the tool set fresh. The Go API exposes an explicit callback rather than automatic reload, which is idiomatic for Go and does not introduce a semantic divergence.

No parity issues found for the APIs actually introduced in this PR.

The public-api-change label is already present and appropriate.

parity-approved label added.

Generated by Go API Consistency Review Agent · sonnet46 · 36.8 AIC · ⌖ 5.77 AIC · ⊞ 6K ·

@github-actions github-actions Bot added the parity-approved Go API consistency review found no parity issues label Aug 22, 2026
@github-actions github-actions Bot added risk:medium Contained production impact requiring normal review depth and removed pending-auto-risk Automatic risk classification is in progress labels Aug 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:tool Changes files in the tool area parity-approved Go API consistency review found no parity issues public-api-change Pull Request changes public APIs risk:medium Contained production impact requiring normal review depth size:large At most 300 changed lines across at most 10 files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants