mcp: implement sampling with tools (#699)

Summary

Add support for tool use within sampling requests, as described in the
MCP 2025-11-25 spec's sampling.tools capability.
  New types

  Content types:
- ToolUseContent — represents a tool invocation request from the
assistant
- ToolResultContent — represents the result of a tool invocation, with
nested []Content for the result blocks

  Capability types:
  - SamplingCapabilities gains Tools and Context sub-fields
  - SamplingToolsCapabilities, SamplingContextCapabilities

  Tool-enabled sampling (parallel tool calls):
- CreateMessageWithToolsParams with SamplingMessageV2 (supports array
content)
  - CreateMessageWithToolsResult (supports array content)
  - ToolChoice — controls tool invocation mode (auto, required, none)
  - ServerSession.CreateMessageWithTools
  - ClientOptions.CreateMessageWithToolsHandler

  Design

Following the TypeScript SDK's pattern, tool-enabled sampling uses
separate types from basic sampling to avoid breaking the existing API.
The basic CreateMessage/CreateMessageResult path
   is unchanged.

Both paths share the same wire method (sampling/createMessage) and go
through the method info table. The table uses the broader
CreateMessageWithToolsResult type internally;
CreateMessage downconverts (erroring if multiple content blocks are
returned). This is documented as a rough edge to unify in v2.

Setting CreateMessageWithToolsHandler automatically infers the
sampling.tools capability. It is a panic to set both
CreateMessageHandler and CreateMessageWithToolsHandler.

  References

-
https://modelcontextprotocol.io/specification/2025-11-25/client/sampling
- TypeScript SDK: CreateMessageResultWithToolsSchema /
CreateMessageRequestParamsWithTools

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Robert Findley 2026-02-17 06:07:08 -05:00 • committed by GitHub
commit 3cf9f9908d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1641 additions and 38 deletions

View file

@ -45,3 +45,17 @@ v2.
**Workaround**: to advertise no capabilities, set
`ServerOptions.Capabilities` or `ClientOptions.Capabilities` to an empty
`&ServerCapabilities{}` or `&ClientCapabilities{}` respectively.
- `CreateMessageResult.Content` is singular `Content`, but the 2025-11-25 spec
allows `content` to be a single block or an array (for parallel tool calls).
We added `CreateMessageResultWithTools` (with `Content []Content`) as a
workaround, matching the TypeScript SDK's approach. In v2,
`CreateMessageResult` should use `[]Content` directly. Similarly,
`SamplingMessage.Content` should become `[]Content` to support sending
multiple tool_result blocks in a single user message.
- We didn't actually need CallToolParams and CallToolParamsRaw, since even when
we're unmarshalling into a custom Go type (for the mcp.AddTool convenience
wrapper) we need to first unmarshal into a `map[string]any` in order to do
server-side validation of required fields. CallToolParams could have just had
a map[string]any.

View file

@ -44,3 +44,17 @@ v2.
**Workaround**: to advertise no capabilities, set
`ServerOptions.Capabilities` or `ClientOptions.Capabilities` to an empty
`&ServerCapabilities{}` or `&ClientCapabilities{}` respectively.
- `CreateMessageResult.Content` is singular `Content`, but the 2025-11-25 spec
allows `content` to be a single block or an array (for parallel tool calls).
We added `CreateMessageResultWithTools` (with `Content []Content`) as a
workaround, matching the TypeScript SDK's approach. In v2,
`CreateMessageResult` should use `[]Content` directly. Similarly,
`SamplingMessage.Content` should become `[]Content` to support sending
multiple tool_result blocks in a single user message.
- We didn't actually need CallToolParams and CallToolParamsRaw, since even when
we're unmarshalling into a custom Go type (for the mcp.AddTool convenience
wrapper) we need to first unmarshal into a `map[string]any` in order to do
server-side validation of required fields. CallToolParams could have just had
a map[string]any.

View file

@ -52,6 +52,9 @@ func NewClient(impl *Implementation, options *ClientOptions) *Client {
}
options = nil // prevent reuse
if opts.CreateMessageHandler != nil && opts.CreateMessageWithToolsHandler != nil {
panic("cannot set both CreateMessageHandler and CreateMessageWithToolsHandler; use CreateMessageWithToolsHandler for tool support, or CreateMessageHandler for basic sampling")
}
if opts.Logger == nil { // ensure we have a logger
opts.Logger = ensureLogger(nil)
}
@ -77,6 +80,19 @@ type ClientOptions struct {
// non nil value for [ClientCapabilities.Sampling], that value overrides the
// inferred capability.
CreateMessageHandler func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error)
// CreateMessageWithToolsHandler handles incoming sampling/createMessage
// requests that may involve tool use. It returns
// [CreateMessageWithToolsResult], which supports array content for parallel
// tool calls.
//
// Setting this handler causes the client to advertise the sampling
// capability with tools support (sampling.tools). As with
// [CreateMessageHandler], [ClientOptions.Capabilities].Sampling overrides
// the inferred capability.
//
// It is a panic to set both CreateMessageHandler and
// CreateMessageWithToolsHandler.
CreateMessageWithToolsHandler func(context.Context, *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error)
// ElicitationHandler handles incoming requests for elicitation/create.
//
// Setting ElicitationHandler to a non-nil value automatically causes the
@ -109,7 +125,16 @@ type ClientOptions struct {
// are set in the Capabilities field, their values override the inferred
// value.
//
// For example, to to configure elicitation modes:
// For example, to advertise sampling with tools and context support:
//
// Capabilities: &ClientCapabilities{
// Sampling: &SamplingCapabilities{
// Tools: &SamplingToolsCapabilities{},
// Context: &SamplingContextCapabilities{},
// },
// }
//
// Or to configure elicitation modes:
//
// Capabilities: &ClientCapabilities{
// Elicitation: &ElicitationCapabilities{
@ -119,8 +144,7 @@ type ClientOptions struct {
// }
//
// Conversely, if Capabilities does not set a field (for example, if the
// Elicitation field is nil), the inferred elicitation capability will be
// used.
// Elicitation field is nil), the inferred capability will be used.
Capabilities *ClientCapabilities
// ElicitationCompleteHandler handles incoming notifications for notifications/elicitation/complete.
ElicitationCompleteHandler func(context.Context, *ElicitationCompleteNotificationRequest)
@ -198,10 +222,13 @@ func (c *Client) capabilities(protocolVersion string) *ClientCapabilities {
caps.Roots = *caps.RootsV2
}
// Augment with sampling capability if handler is set.
if c.opts.CreateMessageHandler != nil {
// Augment with sampling capability if a handler is set.
if c.opts.CreateMessageHandler != nil || c.opts.CreateMessageWithToolsHandler != nil {
if caps.Sampling == nil {
caps.Sampling = &SamplingCapabilities{}
if c.opts.CreateMessageWithToolsHandler != nil {
caps.Sampling.Tools = &SamplingToolsCapabilities{}
}
}
}
@ -453,12 +480,27 @@ func (c *Client) listRoots(_ context.Context, req *ListRootsRequest) (*ListRoots
}, nil
}
func (c *Client) createMessage(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) {
if c.opts.CreateMessageHandler == nil {
// TODO: wrap or annotate this error? Pick a standard code?
return nil, &jsonrpc.Error{Code: codeUnsupportedMethod, Message: "client does not support CreateMessage"}
func (c *Client) createMessage(ctx context.Context, req *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) {
if c.opts.CreateMessageWithToolsHandler != nil {
return c.opts.CreateMessageWithToolsHandler(ctx, req)
}
return c.opts.CreateMessageHandler(ctx, req)
if c.opts.CreateMessageHandler != nil {
// Downconvert the request for the basic handler.
baseParams, err := req.Params.toBase()
if err != nil {
return nil, err
}
baseReq := &CreateMessageRequest{
Session: req.Session,
Params: baseParams,
}
res, err := c.opts.CreateMessageHandler(ctx, baseReq)
if err != nil {
return nil, err
}
return res.toWithTools(), nil
}
return nil, &jsonrpc.Error{Code: codeUnsupportedMethod, Message: "client does not support CreateMessage"}
}
// urlElicitationMiddleware returns middleware that automatically handles URL elicitation

View file

@ -13,7 +13,10 @@ import (
)
// A Content is a [TextContent], [ImageContent], [AudioContent],
// [ResourceLink], or [EmbeddedResource].
// [ResourceLink], [EmbeddedResource], [ToolUseContent], or [ToolResultContent].
//
// Note: [ToolUseContent] and [ToolResultContent] are only valid in sampling
// message contexts (CreateMessageParams/CreateMessageResult).
type Content interface {
MarshalJSON() ([]byte, error)
fromWire(*wireContent)
@ -182,6 +185,104 @@ func (c *EmbeddedResource) fromWire(wire *wireContent) {
c.Annotations = wire.Annotations
}
// ToolUseContent represents a request from the assistant to invoke a tool.
// This content type is only valid in sampling messages.
type ToolUseContent struct {
// ID is a unique identifier for this tool use, used to match with ToolResultContent.
ID string
// Name is the name of the tool to invoke.
Name string
// Input contains the tool arguments as a JSON object.
Input map[string]any
Meta Meta
}
func (c *ToolUseContent) MarshalJSON() ([]byte, error) {
input := c.Input
if input == nil {
input = map[string]any{}
}
wire := struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Input map[string]any `json:"input"`
Meta Meta `json:"_meta,omitempty"`
}{
Type: "tool_use",
ID: c.ID,
Name: c.Name,
Input: input,
Meta: c.Meta,
}
return json.Marshal(wire)
}
func (c *ToolUseContent) fromWire(wire *wireContent) {
c.ID = wire.ID
c.Name = wire.Name
c.Input = wire.Input
c.Meta = wire.Meta
}
// ToolResultContent represents the result of a tool invocation.
// This content type is only valid in sampling messages with role "user".
type ToolResultContent struct {
// ToolUseID references the ID from the corresponding ToolUseContent.
ToolUseID string
// Content holds the unstructured result of the tool call.
Content []Content
// StructuredContent holds an optional structured result as a JSON object.
StructuredContent any
// IsError indicates whether the tool call ended in an error.
IsError bool
Meta Meta
}
func (c *ToolResultContent) MarshalJSON() ([]byte, error) {
// Marshal nested content
var contentWire []*wireContent
for _, content := range c.Content {
data, err := content.MarshalJSON()
if err != nil {
return nil, err
}
var w wireContent
if err := json.Unmarshal(data, &w); err != nil {
return nil, err
}
contentWire = append(contentWire, &w)
}
if contentWire == nil {
contentWire = []*wireContent{} // avoid JSON null
}
wire := struct {
Type string `json:"type"`
ToolUseID string `json:"toolUseId"`
Content []*wireContent `json:"content"`
StructuredContent any `json:"structuredContent,omitempty"`
IsError bool `json:"isError,omitempty"`
Meta Meta `json:"_meta,omitempty"`
}{
Type: "tool_result",
ToolUseID: c.ToolUseID,
Content: contentWire,
StructuredContent: c.StructuredContent,
IsError: c.IsError,
Meta: c.Meta,
}
return json.Marshal(wire)
}
func (c *ToolResultContent) fromWire(wire *wireContent) {
c.ToolUseID = wire.ToolUseID
c.StructuredContent = wire.StructuredContent
c.IsError = wire.IsError
c.Meta = wire.Meta
// Content is handled separately in contentFromWire due to nested content
}
// ResourceContents contains the contents of a specific resource or
// sub-resource.
type ResourceContents struct {
@ -194,28 +295,55 @@ type ResourceContents struct {
// wireContent is the wire format for content.
// It represents the protocol types TextContent, ImageContent, AudioContent,
// ResourceLink, and EmbeddedResource.
// ResourceLink, EmbeddedResource, ToolUseContent, and ToolResultContent.
// The Type field distinguishes them. In the protocol, each type has a constant
// value for the field.
// At most one of Text, Data, Resource, and URI is non-zero.
type wireContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
MIMEType string `json:"mimeType,omitempty"`
Data []byte `json:"data,omitempty"`
Resource *ResourceContents `json:"resource,omitempty"`
URI string `json:"uri,omitempty"`
Name string `json:"name,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Size *int64 `json:"size,omitempty"`
Meta Meta `json:"_meta,omitempty"`
Annotations *Annotations `json:"annotations,omitempty"`
Icons []Icon `json:"icons,omitempty"`
Type string `json:"type"`
Text string `json:"text,omitempty"` // TextContent
MIMEType string `json:"mimeType,omitempty"` // ImageContent, AudioContent, ResourceLink
Data []byte `json:"data,omitempty"` // ImageContent, AudioContent
Resource *ResourceContents `json:"resource,omitempty"` // EmbeddedResource
URI string `json:"uri,omitempty"` // ResourceLink
Name string `json:"name,omitempty"` // ResourceLink, ToolUseContent
Title string `json:"title,omitempty"` // ResourceLink
Description string `json:"description,omitempty"` // ResourceLink
Size *int64 `json:"size,omitempty"` // ResourceLink
Meta Meta `json:"_meta,omitempty"` // all types
Annotations *Annotations `json:"annotations,omitempty"` // all types except ToolUseContent, ToolResultContent
Icons []Icon `json:"icons,omitempty"` // ResourceLink
ID string `json:"id,omitempty"` // ToolUseContent
Input map[string]any `json:"input,omitempty"` // ToolUseContent
ToolUseID string `json:"toolUseId,omitempty"` // ToolResultContent
NestedContent []*wireContent `json:"content,omitempty"` // ToolResultContent
StructuredContent any `json:"structuredContent,omitempty"` // ToolResultContent
IsError bool `json:"isError,omitempty"` // ToolResultContent
}
// unmarshalContent unmarshals JSON that is either a single content object or
// an array of content objects. A single object is wrapped in a one-element slice.
func unmarshalContent(raw json.RawMessage, allow map[string]bool) ([]Content, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, fmt.Errorf("nil content")
}
// Try array first, then fall back to single object.
var wires []*wireContent
if err := json.Unmarshal(raw, &wires); err == nil {
return contentsFromWire(wires, allow)
}
var wire wireContent
if err := json.Unmarshal(raw, &wire); err != nil {
return nil, err
}
c, err := contentFromWire(&wire, allow)
if err != nil {
return nil, err
}
return []Content{c}, nil
}
func contentsFromWire(wires []*wireContent, allow map[string]bool) ([]Content, error) {
var blocks []Content
blocks := make([]Content, 0, len(wires))
for _, wire := range wires {
block, err := contentFromWire(wire, allow)
if err != nil {
@ -254,6 +382,27 @@ func contentFromWire(wire *wireContent, allow map[string]bool) (Content, error)
v := new(EmbeddedResource)
v.fromWire(wire)
return v, nil
case "tool_use":
v := new(ToolUseContent)
v.fromWire(wire)
return v, nil
case "tool_result":
v := new(ToolResultContent)
v.fromWire(wire)
// Handle nested content - tool_result content can contain text, image, audio,
// resource_link, and resource (same as CallToolResult.content)
if wire.NestedContent != nil {
toolResultContentAllow := map[string]bool{
"text": true, "image": true, "audio": true,
"resource_link": true, "resource": true,
}
nestedContent, err := contentsFromWire(wire.NestedContent, toolResultContentAllow)
if err != nil {
return nil, fmt.Errorf("tool_result nested content: %w", err)
}
v.Content = nestedContent
}
return v, nil
}
return nil, fmt.Errorf("internal error: unrecognized content type %s", wire.Type)
return nil, fmt.Errorf("unrecognized content type %q", wire.Type)
}

View file

@ -188,7 +188,6 @@ type RootCapabilities struct {
// this schema, but this is not a closed set: any client can define its own,
// additional capabilities.
type ClientCapabilities struct {
// NOTE: any addition to ClientCapabilities must also be reflected in
// [ClientCapabilities.clone].
@ -216,7 +215,12 @@ type ClientCapabilities struct {
func (c *ClientCapabilities) clone() *ClientCapabilities {
cp := *c
cp.RootsV2 = shallowClone(c.RootsV2)
cp.Sampling = shallowClone(c.Sampling)
if c.Sampling != nil {
x := *c.Sampling
x.Tools = shallowClone(c.Sampling.Tools)
x.Context = shallowClone(c.Sampling.Context)
cp.Sampling = &x
}
if c.Elicitation != nil {
x := *c.Elicitation
x.Form = shallowClone(c.Elicitation.Form)
@ -357,6 +361,11 @@ type CreateMessageParams struct {
Meta `json:"_meta,omitempty"`
// A request to include context from one or more MCP servers (including the
// caller), to be attached to the prompt. The client may ignore this request.
//
// The default is "none". Values "thisServer" and
// "allServers" are soft-deprecated. Servers SHOULD only use these values if
// the client declares ClientCapabilities.sampling.context. These values may
// be removed in future spec releases.
IncludeContext string `json:"includeContext,omitempty"`
// The maximum number of tokens to sample, as requested by the server. The
// client may choose to sample fewer tokens than requested.
@ -379,6 +388,106 @@ func (x *CreateMessageParams) isParams() {}
func (x *CreateMessageParams) GetProgressToken() any { return getProgressToken(x) }
func (x *CreateMessageParams) SetProgressToken(t any) { setProgressToken(x, t) }
// CreateMessageWithToolsParams is a sampling request that includes tools.
// It extends the basic [CreateMessageParams] fields with tools, tool choice,
// and messages that support array content (for parallel tool calls).
//
// Use with [ServerSession.CreateMessageWithTools].
type CreateMessageWithToolsParams struct {
Meta `json:"_meta,omitempty"`
IncludeContext string `json:"includeContext,omitempty"`
MaxTokens int64 `json:"maxTokens"`
// Messages supports array content for tool_use and tool_result blocks.
Messages []*SamplingMessageV2 `json:"messages"`
Metadata any `json:"metadata,omitempty"`
ModelPreferences *ModelPreferences `json:"modelPreferences,omitempty"`
StopSequences []string `json:"stopSequences,omitempty"`
SystemPrompt string `json:"systemPrompt,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
// Tools is the list of tools available for the model to use.
Tools []*Tool `json:"tools,omitempty"`
// ToolChoice controls how the model should use tools.
ToolChoice *ToolChoice `json:"toolChoice,omitempty"`
}
func (x *CreateMessageWithToolsParams) isParams() {}
func (x *CreateMessageWithToolsParams) GetProgressToken() any { return getProgressToken(x) }
func (x *CreateMessageWithToolsParams) SetProgressToken(t any) { setProgressToken(x, t) }
// toBase converts to CreateMessageParams by taking the content block from each
// message. Tools and ToolChoice are dropped. Returns an error if any message
// has multiple content blocks, since SamplingMessage only supports one.
func (p *CreateMessageWithToolsParams) toBase() (*CreateMessageParams, error) {
var msgs []*SamplingMessage
for _, m := range p.Messages {
if len(m.Content) > 1 {
return nil, fmt.Errorf("message has %d content blocks; use CreateMessageWithToolsHandler to support multiple content", len(m.Content))
}
var content Content
if len(m.Content) > 0 {
content = m.Content[0]
}
msgs = append(msgs, &SamplingMessage{Content: content, Role: m.Role})
}
return &CreateMessageParams{
Meta: p.Meta,
IncludeContext: p.IncludeContext,
MaxTokens: p.MaxTokens,
Messages: msgs,
Metadata: p.Metadata,
ModelPreferences: p.ModelPreferences,
StopSequences: p.StopSequences,
SystemPrompt: p.SystemPrompt,
Temperature: p.Temperature,
}, nil
}
// SamplingMessageV2 describes a message issued to or received from an
// LLM API, supporting array content for parallel tool calls. The "V2" refers
// to the 2025-11-25 spec, which changed content from a single block to
// single-or-array. In v2 of the SDK, this will replace [SamplingMessage].
//
// When marshaling, a single-element Content slice is marshaled as a single
// object for compatibility with pre-2025-11-25 implementations. When
// unmarshaling, a single JSON content object is accepted and wrapped in a
// one-element slice.
type SamplingMessageV2 struct {
Content []Content `json:"content"`
Role Role `json:"role"`
}
var samplingWithToolsAllow = map[string]bool{
"text": true, "image": true, "audio": true,
"tool_use": true, "tool_result": true,
}
// MarshalJSON marshals the message. A single-element Content slice is marshaled
// as a single object for backward compatibility.
func (m *SamplingMessageV2) MarshalJSON() ([]byte, error) {
if len(m.Content) == 1 {
return json.Marshal(&SamplingMessage{Content: m.Content[0], Role: m.Role})
}
type msg SamplingMessageV2 // avoid recursion
return json.Marshal((*msg)(m))
}
func (m *SamplingMessageV2) UnmarshalJSON(data []byte) error {
type msg SamplingMessageV2 // avoid recursion
var wire struct {
msg
Content json.RawMessage `json:"content"`
}
if err := json.Unmarshal(data, &wire); err != nil {
return err
}
var err error
if wire.msg.Content, err = unmarshalContent(wire.Content, samplingWithToolsAllow); err != nil {
return err
}
*m = SamplingMessageV2(wire.msg)
return nil
}
// The client's response to a sampling/create_message request from the server.
// The client should inform the user before returning the sampled message, to
// allow them to inspect the response (human in the loop) and decide whether to
@ -392,6 +501,12 @@ type CreateMessageResult struct {
Model string `json:"model"`
Role Role `json:"role"`
// The reason why sampling stopped, if known.
//
// Standard values:
// - "endTurn": natural end of the assistant's turn
// - "stopSequence": a stop sequence was encountered
// - "maxTokens": reached the maximum token limit
// - "toolUse": the model wants to use one or more tools
StopReason string `json:"stopReason,omitempty"`
}
@ -413,6 +528,84 @@ func (r *CreateMessageResult) UnmarshalJSON(data []byte) error {
return nil
}
// CreateMessageWithToolsResult is the client's response to a
// sampling/create_message request that included tools. Content is a slice to
// support parallel tool calls (multiple tool_use blocks in one response).
//
// Use [ServerSession.CreateMessageWithTools] to send a sampling request with
// tools and receive this result type.
//
// When unmarshaling, a single JSON content object is accepted and wrapped in a
// one-element slice, for compatibility with clients that return a single block.
type CreateMessageWithToolsResult struct {
Meta `json:"_meta,omitempty"`
Content []Content `json:"content"`
Model string `json:"model"`
Role Role `json:"role"`
// The reason why sampling stopped.
//
// Standard values: "endTurn", "stopSequence", "maxTokens", "toolUse".
StopReason string `json:"stopReason,omitempty"`
}
// createMessageWithToolsResultAllow lists content types valid in assistant responses.
// tool_result is excluded: it only appears in user messages.
var createMessageWithToolsResultAllow = map[string]bool{
"text": true, "image": true, "audio": true,
"tool_use": true,
}
func (*CreateMessageWithToolsResult) isResult() {}
// MarshalJSON marshals the result. When Content has a single element, it is
// marshaled as a single object for compatibility with pre-2025-11-25
// implementations that expect a single content block.
func (r *CreateMessageWithToolsResult) MarshalJSON() ([]byte, error) {
if len(r.Content) == 1 {
return json.Marshal(&CreateMessageResult{
Meta: r.Meta,
Content: r.Content[0],
Model: r.Model,
Role: r.Role,
StopReason: r.StopReason,
})
}
type result CreateMessageWithToolsResult // avoid recursion
return json.Marshal((*result)(r))
}
func (r *CreateMessageWithToolsResult) UnmarshalJSON(data []byte) error {
type result CreateMessageWithToolsResult // avoid recursion
var wire struct {
result
Content json.RawMessage `json:"content"`
}
if err := json.Unmarshal(data, &wire); err != nil {
return err
}
var err error
if wire.result.Content, err = unmarshalContent(wire.Content, createMessageWithToolsResultAllow); err != nil {
return err
}
*r = CreateMessageWithToolsResult(wire.result)
return nil
}
// toWithTools converts a CreateMessageResult to CreateMessageWithToolsResult.
func (r *CreateMessageResult) toWithTools() *CreateMessageWithToolsResult {
var content []Content
if r.Content != nil {
content = []Content{r.Content}
}
return &CreateMessageWithToolsResult{
Meta: r.Meta,
Content: content,
Model: r.Model,
Role: r.Role,
StopReason: r.StopReason,
}
}
type GetPromptParams struct {
// This property is reserved by the protocol to allow clients and servers to
// attach additional metadata to their responses.
@ -982,7 +1175,27 @@ func (x *RootsListChangedParams) SetProgressToken(t any) { setProgressToken(x, t
// below directly above ClientCapabilities.
// SamplingCapabilities describes the client's support for sampling.
type SamplingCapabilities struct{}
type SamplingCapabilities struct {
// Context indicates the client supports includeContext values other than "none".
Context *SamplingContextCapabilities `json:"context,omitempty"`
// Tools indicates the client supports tools and toolChoice in sampling requests.
Tools *SamplingToolsCapabilities `json:"tools,omitempty"`
}
// SamplingContextCapabilities indicates the client supports context inclusion.
type SamplingContextCapabilities struct{}
// SamplingToolsCapabilities indicates the client supports tool use in sampling.
type SamplingToolsCapabilities struct{}
// ToolChoice controls how the model uses tools during sampling.
type ToolChoice struct {
// Mode controls tool invocation behavior:
// - "auto": Model decides whether to use tools (default)
// - "required": Model must use at least one tool
// - "none": Model must not use any tools
Mode string `json:"mode,omitempty"`
}
// ElicitationCapabilities describes the capabilities for elicitation.
//
@ -993,14 +1206,15 @@ type ElicitationCapabilities struct {
}
// FormElicitationCapabilities describes capabilities for form elicitation.
type FormElicitationCapabilities struct {
}
type FormElicitationCapabilities struct{}
// URLElicitationCapabilities describes capabilities for url elicitation.
type URLElicitationCapabilities struct {
}
type URLElicitationCapabilities struct{}
// Describes a message issued to or received from an LLM API.
//
// For assistant messages, Content may be text, image, audio, or tool_use.
// For user messages, Content may be text, image, audio, or tool_result.
type SamplingMessage struct {
Content Content `json:"content"`
Role Role `json:"role"`
@ -1017,8 +1231,9 @@ func (m *SamplingMessage) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(data, &wire); err != nil {
return err
}
// Allow text, image, audio, tool_use, and tool_result in sampling messages
var err error
if wire.msg.Content, err = contentFromWire(wire.Content, map[string]bool{"text": true, "image": true, "audio": true}); err != nil {
if wire.msg.Content, err = contentFromWire(wire.Content, map[string]bool{"text": true, "image": true, "audio": true, "tool_use": true, "tool_result": true}); err != nil {
return err
}
*m = SamplingMessage(wire.msg)
@ -1297,7 +1512,6 @@ type ToolCapabilities struct {
// ServerCapabilities describes capabilities that a server supports.
type ServerCapabilities struct {
// NOTE: any addition to ServerCapabilities must also be reflected in
// [ServerCapabilities.clone].

View file

@ -7,6 +7,7 @@ package mcp
import (
"encoding/json"
"maps"
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
@ -488,6 +489,587 @@ func TestCompleteResult(t *testing.T) {
}
}
func TestToolUseContent_MarshalJSON(t *testing.T) {
tests := []struct {
name string
content *ToolUseContent
want *ToolUseContent
}{
{
name: "basic tool use",
content: &ToolUseContent{
ID: "tool_123",
Name: "calculator",
Input: map[string]any{
"operation": "add",
"x": 1.0,
"y": 2.0,
},
},
want: &ToolUseContent{
ID: "tool_123",
Name: "calculator",
Input: map[string]any{
"operation": "add",
"x": 1.0,
"y": 2.0,
},
},
},
{
name: "nil input marshals as empty object",
content: &ToolUseContent{
ID: "tool_456",
Name: "no_args_tool",
Input: nil,
},
want: &ToolUseContent{
ID: "tool_456",
Name: "no_args_tool",
Input: map[string]any{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := tt.content.MarshalJSON()
if err != nil {
t.Fatalf("MarshalJSON() error = %v", err)
}
wire := &wireContent{}
if err := json.Unmarshal(data, wire); err != nil {
t.Fatalf("Unmarshal wire error = %v", err)
}
got, err := contentFromWire(wire, map[string]bool{"tool_use": true})
if err != nil {
t.Fatalf("contentFromWire() error = %v", err)
}
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestToolUseContent_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
json string
want *ToolUseContent
}{
{
name: "basic tool use",
json: `{"type":"tool_use","id":"tool_123","name":"calculator","input":{"x":1,"y":2}}`,
want: &ToolUseContent{
ID: "tool_123",
Name: "calculator",
Input: map[string]any{
"x": 1.0,
"y": 2.0,
},
},
},
{
name: "with meta",
json: `{"type":"tool_use","id":"t1","name":"calc","input":{"x":1},"_meta":{"requestId":"req-123"}}`,
want: &ToolUseContent{
ID: "t1",
Name: "calc",
Input: map[string]any{"x": 1.0},
Meta: Meta{"requestId": "req-123"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
wire := &wireContent{}
if err := json.Unmarshal([]byte(tt.json), wire); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
got, err := contentFromWire(wire, map[string]bool{"tool_use": true})
if err != nil {
t.Fatalf("contentFromWire() error = %v", err)
}
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestToolResultContent_MarshalJSON(t *testing.T) {
tests := []struct {
name string
content *ToolResultContent
want string
}{
{
name: "basic tool result",
content: &ToolResultContent{
ToolUseID: "tool_123",
Content: []Content{&TextContent{Text: "42"}},
},
want: `{"type":"tool_result","toolUseId":"tool_123","content":[{"type":"text","text":"42"}]}`,
},
{
name: "tool result with error",
content: &ToolResultContent{
ToolUseID: "tool_456",
Content: []Content{&TextContent{Text: "division by zero"}},
IsError: true,
},
want: `{"type":"tool_result","toolUseId":"tool_456","content":[{"type":"text","text":"division by zero"}],"isError":true}`,
},
{
name: "tool result with structured content",
content: &ToolResultContent{
ToolUseID: "tool_789",
Content: []Content{&TextContent{Text: `{"result": 42}`}},
StructuredContent: map[string]any{"result": 42.0},
},
want: `{"type":"tool_result","toolUseId":"tool_789","content":[{"type":"text","text":"{\"result\": 42}"}],"structuredContent":{"result":42}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := tt.content.MarshalJSON()
if err != nil {
t.Fatalf("MarshalJSON() error = %v", err)
}
if diff := cmp.Diff(tt.want, string(data)); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestToolResultContent_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
json string
wantID string
wantErr bool
checkFn func(t *testing.T, got *ToolResultContent)
}{
{
name: "basic tool result",
json: `{"type":"tool_result","toolUseId":"tool_123","content":[{"type":"text","text":"42"}],"isError":false}`,
wantID: "tool_123",
checkFn: func(t *testing.T, got *ToolResultContent) {
if len(got.Content) != 1 {
t.Fatalf("len(Content) = %d, want 1", len(got.Content))
}
tc, ok := got.Content[0].(*TextContent)
if !ok {
t.Fatalf("Content[0] type = %T, want *TextContent", got.Content[0])
}
if tc.Text != "42" {
t.Errorf("Text = %v, want 42", tc.Text)
}
},
},
{
name: "image nested content",
json: `{"type":"tool_result","toolUseId":"t1","content":[{"type":"image","mimeType":"image/png","data":"YWJj"}]}`,
wantID: "t1",
checkFn: func(t *testing.T, got *ToolResultContent) {
if len(got.Content) != 1 {
t.Fatalf("len(Content) = %d, want 1", len(got.Content))
}
img, ok := got.Content[0].(*ImageContent)
if !ok {
t.Fatalf("Content[0] type = %T, want *ImageContent", got.Content[0])
}
if img.MIMEType != "image/png" {
t.Errorf("MIMEType = %v, want image/png", img.MIMEType)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
wire := &wireContent{}
if err := json.Unmarshal([]byte(tt.json), wire); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
got, err := contentFromWire(wire, map[string]bool{"tool_result": true})
if (err != nil) != tt.wantErr {
t.Fatalf("contentFromWire() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
result, ok := got.(*ToolResultContent)
if !ok {
t.Fatalf("type = %T, want *ToolResultContent", got)
}
if result.ToolUseID != tt.wantID {
t.Errorf("ToolUseID = %v, want %v", result.ToolUseID, tt.wantID)
}
if tt.checkFn != nil {
tt.checkFn(t, result)
}
})
}
}
func TestSamplingMessage_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
json string
wantRole Role
wantType string // expected Content type name
}{
{
name: "tool_use content",
json: `{"content":{"type":"tool_use","id":"tool_1","name":"calc","input":{}},"role":"assistant"}`,
wantRole: "assistant",
wantType: "*mcp.ToolUseContent",
},
{
name: "tool_result content",
json: `{"content":{"type":"tool_result","toolUseId":"tool_1","content":[{"type":"text","text":"42"}]},"role":"user"}`,
wantRole: "user",
wantType: "*mcp.ToolResultContent",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var msg SamplingMessage
if err := json.Unmarshal([]byte(tt.json), &msg); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if msg.Role != tt.wantRole {
t.Errorf("Role = %v, want %v", msg.Role, tt.wantRole)
}
gotType := reflect.TypeOf(msg.Content).String()
if gotType != tt.wantType {
t.Errorf("Content type = %v, want %v", gotType, tt.wantType)
}
})
}
}
func TestSamplingCapabilities_MarshalJSON(t *testing.T) {
caps := &SamplingCapabilities{
Tools: &SamplingToolsCapabilities{},
Context: &SamplingContextCapabilities{},
}
data, err := json.Marshal(caps)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var got SamplingCapabilities
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if got.Tools == nil {
t.Error("Tools capability should not be nil")
}
if got.Context == nil {
t.Error("Context capability should not be nil")
}
}
func TestSamplingCapabilities_UnmarshalJSON(t *testing.T) {
// Empty struct should marshal/unmarshal correctly (backward compatibility).
caps := &SamplingCapabilities{}
data, err := json.Marshal(caps)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var got SamplingCapabilities
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if got.Tools != nil {
t.Error("Tools capability should be nil for empty capabilities")
}
if got.Context != nil {
t.Error("Context capability should be nil for empty capabilities")
}
}
func TestCreateMessageWithToolsParams_MarshalUnmarshalJSON(t *testing.T) {
params := &CreateMessageWithToolsParams{
MaxTokens: 1000,
Messages: []*SamplingMessageV2{
{
Role: "user",
Content: []Content{&TextContent{Text: "Calculate 1+1"}},
},
},
Tools: []*Tool{
{
Name: "calculator",
Description: "A calculator tool",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"x": map[string]any{"type": "number"},
"y": map[string]any{"type": "number"},
},
},
},
},
ToolChoice: &ToolChoice{Mode: "auto"},
}
data, err := json.Marshal(params)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var got CreateMessageWithToolsParams
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(got.Tools) != 1 {
t.Fatalf("len(Tools) = %v, want 1", len(got.Tools))
}
if got.Tools[0].Name != "calculator" {
t.Errorf("Tools[0].Name = %v, want calculator", got.Tools[0].Name)
}
if got.ToolChoice == nil || got.ToolChoice.Mode != "auto" {
t.Errorf("ToolChoice = %v, want {Mode: auto}", got.ToolChoice)
}
}
func TestToolChoice_MarshalUnmarshalJSON(t *testing.T) {
choices := []*ToolChoice{
{Mode: "auto"},
{Mode: "required"},
{Mode: "none"},
}
for _, tc := range choices {
data, err := json.Marshal(tc)
if err != nil {
t.Fatalf("Marshal(%v) error = %v", tc.Mode, err)
}
var got ToolChoice
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if diff := cmp.Diff(*tc, got); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
}
}
func TestCreateMessageWithToolsResult_MarshalJSON(t *testing.T) {
// Single-element Content marshals as object (not array) for backward compat.
result := &CreateMessageWithToolsResult{
Model: "test",
Role: "assistant",
Content: []Content{&TextContent{Text: "hello"}},
}
data, err := json.Marshal(result)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("Unmarshal raw error = %v", err)
}
content := raw["content"]
for _, b := range content {
if b == ' ' || b == '\t' || b == '\n' || b == '\r' {
continue
}
if b == '[' {
t.Errorf("single-element Content marshaled as array, want object")
}
break
}
}
func TestCreateMessageWithToolsResult_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
json string
wantLen int
wantErr bool
}{
{
name: "single tool_use content",
json: `{"content":{"type":"tool_use","id":"tool_1","name":"calculator","input":{"x":1}},"model":"test-model","role":"assistant","stopReason":"toolUse"}`,
wantLen: 1,
},
{
name: "array of tool_use content",
json: `{"content":[{"type":"tool_use","id":"t1","name":"calc","input":{"x":1}},{"type":"tool_use","id":"t2","name":"search","input":{"q":"hi"}}],"model":"test","role":"assistant","stopReason":"toolUse"}`,
wantLen: 2,
},
{
name: "empty array",
json: `{"content":[],"model":"m","role":"assistant"}`,
wantLen: 0,
},
{
name: "null content",
json: `{"content":null,"model":"m","role":"assistant"}`,
wantErr: true,
},
{
name: "rejects tool_result",
json: `{"content":{"type":"tool_result","toolUseId":"t1","content":[]},"model":"m","role":"assistant"}`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got CreateMessageWithToolsResult
err := json.Unmarshal([]byte(tt.json), &got)
if (err != nil) != tt.wantErr {
t.Fatalf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if len(got.Content) != tt.wantLen {
t.Errorf("len(Content) = %d, want %d", len(got.Content), tt.wantLen)
}
})
}
}
func TestSamplingMessageV2_MarshalJSON(t *testing.T) {
msg := &SamplingMessageV2{
Role: "user",
Content: []Content{},
}
data, err := json.Marshal(msg)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var got SamplingMessageV2
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(got.Content) != 0 {
t.Errorf("len(Content) = %d, want 0", len(got.Content))
}
}
func TestSamplingMessageV2_UnmarshalJSON(t *testing.T) {
// Text + tool_use in the same message (valid per spec for assistant).
msg := &SamplingMessageV2{
Role: "assistant",
Content: []Content{
&TextContent{Text: "Let me check the weather."},
&ToolUseContent{ID: "c1", Name: "weather", Input: map[string]any{"city": "SF"}},
},
}
data, err := json.Marshal(msg)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var got SamplingMessageV2
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(got.Content) != 2 {
t.Fatalf("len(Content) = %d, want 2", len(got.Content))
}
if _, ok := got.Content[0].(*TextContent); !ok {
t.Errorf("Content[0] type = %T, want *TextContent", got.Content[0])
}
if _, ok := got.Content[1].(*ToolUseContent); !ok {
t.Errorf("Content[1] type = %T, want *ToolUseContent", got.Content[1])
}
}
func TestToBase_Conversion(t *testing.T) {
// Single-content messages convert successfully.
params := &CreateMessageWithToolsParams{
MaxTokens: 1000,
Messages: []*SamplingMessageV2{
{Role: "user", Content: []Content{&TextContent{Text: "hello"}}},
},
Tools: []*Tool{{Name: "calc"}},
ToolChoice: &ToolChoice{Mode: "auto"},
}
base, err := params.toBase()
if err != nil {
t.Fatalf("toBase() error = %v", err)
}
if base.MaxTokens != 1000 {
t.Errorf("MaxTokens = %d, want 1000", base.MaxTokens)
}
if tc, ok := base.Messages[0].Content.(*TextContent); !ok || tc.Text != "hello" {
t.Errorf("Messages[0].Content = %v, want TextContent{hello}", base.Messages[0].Content)
}
// Multi-content messages return an error.
params2 := &CreateMessageWithToolsParams{
MaxTokens: 1000,
Messages: []*SamplingMessageV2{
{Role: "assistant", Content: []Content{
&ToolUseContent{ID: "c1", Name: "calc", Input: map[string]any{}},
&ToolUseContent{ID: "c2", Name: "search", Input: map[string]any{}},
}},
},
}
if _, err := params2.toBase(); err == nil {
t.Error("toBase() should return error for multi-content message")
}
}
func TestToWithTools_Conversion(t *testing.T) {
tests := []struct {
name string
result *CreateMessageResult
wantLen int
}{
{
name: "with content",
result: &CreateMessageResult{
Model: "test",
Role: "assistant",
Content: &TextContent{Text: "hello"},
StopReason: "endTurn",
},
wantLen: 1,
},
{
name: "nil content",
result: &CreateMessageResult{
Model: "test",
Role: "assistant",
},
wantLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
wt := tt.result.toWithTools()
if wt.Model != tt.result.Model {
t.Errorf("Model = %v, want %v", wt.Model, tt.result.Model)
}
if len(wt.Content) != tt.wantLen {
t.Fatalf("len(Content) = %d, want %d", len(wt.Content), tt.wantLen)
}
if tt.wantLen > 0 {
if tc, ok := wt.Content[0].(*TextContent); !ok || tc.Text != "hello" {
t.Errorf("Content[0] = %v, want TextContent{hello}", wt.Content[0])
}
}
})
}
}
func TestContentUnmarshal(t *testing.T) {
// Verify that types with a Content field round-trip properly.
roundtrip := func(in, out any) {

View file

@ -24,6 +24,7 @@ type (
type (
CreateMessageRequest = ClientRequest[*CreateMessageParams]
CreateMessageWithToolsRequest = ClientRequest[*CreateMessageWithToolsParams]
ElicitRequest = ClientRequest[*ElicitParams]
initializedClientRequest = ClientRequest[*InitializedParams]
InitializeRequest = ClientRequest[*InitializeParams]

546
mcp/sampling_test.go Normal file
View file

@ -0,0 +1,546 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package mcp
// TODO: move other sampling-related tests to this file.
import (
"context"
"strings"
"testing"
)
func TestSamplingWithTools_Integration(t *testing.T) {
ctx := context.Background()
ct, st := NewInMemoryTransports()
// Track what the client received
var receivedParams *CreateMessageWithToolsParams
// Client with tools capability, using CreateMessageWithToolsHandler
client := NewClient(testImpl, &ClientOptions{
CreateMessageWithToolsHandler: func(_ context.Context, req *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) {
receivedParams = req.Params
// Return a tool use response
return &CreateMessageWithToolsResult{
Model: "test-model",
Role: "assistant",
Content: []Content{&ToolUseContent{
ID: "tool_call_1",
Name: "calculator",
Input: map[string]any{"x": 1.0, "y": 2.0},
}},
StopReason: "toolUse",
}, nil
},
Capabilities: &ClientCapabilities{
Sampling: &SamplingCapabilities{Tools: &SamplingToolsCapabilities{}},
},
})
server := NewServer(testImpl, nil)
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
defer ss.Close()
cs, err := client.Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
defer cs.Close()
// Server sends CreateMessageWithTools
result, err := ss.CreateMessageWithTools(ctx, &CreateMessageWithToolsParams{
MaxTokens: 1000,
Messages: []*SamplingMessageV2{
{Role: "user", Content: []Content{&TextContent{Text: "Calculate 1+2"}}},
},
Tools: []*Tool{
{
Name: "calculator",
Description: "A calculator",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"x": map[string]any{"type": "number"},
"y": map[string]any{"type": "number"},
},
},
},
},
ToolChoice: &ToolChoice{Mode: "auto"},
})
if err != nil {
t.Fatalf("CreateMessageWithTools() error = %v", err)
}
// Verify client received the tools
if receivedParams == nil {
t.Fatal("client did not receive params")
}
if len(receivedParams.Tools) != 1 {
t.Errorf("client received %d tools, want 1", len(receivedParams.Tools))
}
if receivedParams.Tools[0].Name != "calculator" {
t.Errorf("tool name = %v, want calculator", receivedParams.Tools[0].Name)
}
if receivedParams.ToolChoice == nil || receivedParams.ToolChoice.Mode != "auto" {
t.Errorf("tool choice mode = %v, want auto", receivedParams.ToolChoice)
}
// Verify server received the tool use response
if result.StopReason != "toolUse" {
t.Errorf("StopReason = %v, want toolUse", result.StopReason)
}
if len(result.Content) != 1 {
t.Fatalf("len(Content) = %d, want 1", len(result.Content))
}
toolUse, ok := result.Content[0].(*ToolUseContent)
if !ok {
t.Fatalf("Content[0] type = %T, want *ToolUseContent", result.Content[0])
}
if toolUse.ID != "tool_call_1" {
t.Errorf("ToolUse.ID = %v, want tool_call_1", toolUse.ID)
}
if toolUse.Name != "calculator" {
t.Errorf("ToolUse.Name = %v, want calculator", toolUse.Name)
}
}
func TestSamplingWithToolResult_Integration(t *testing.T) {
ctx := context.Background()
ct, st := NewInMemoryTransports()
// Track messages received by client
var receivedMessages []*SamplingMessage
client := NewClient(testImpl, &ClientOptions{
CreateMessageHandler: func(_ context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) {
receivedMessages = req.Params.Messages
return &CreateMessageResult{
Model: "test-model",
Role: "assistant",
Content: &TextContent{Text: "The result is 3"},
}, nil
},
Capabilities: &ClientCapabilities{
Sampling: &SamplingCapabilities{Tools: &SamplingToolsCapabilities{}},
},
})
server := NewServer(testImpl, nil)
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
defer ss.Close()
cs, err := client.Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
defer cs.Close()
// Server sends CreateMessage with tool result in messages
_, err = ss.CreateMessage(ctx, &CreateMessageParams{
MaxTokens: 1000,
Messages: []*SamplingMessage{
{Role: "user", Content: &TextContent{Text: "Calculate 1+2"}},
{Role: "assistant", Content: &ToolUseContent{
ID: "tool_1",
Name: "calculator",
Input: map[string]any{"x": 1.0, "y": 2.0},
}},
{Role: "user", Content: &ToolResultContent{
ToolUseID: "tool_1",
Content: []Content{&TextContent{Text: "3"}},
}},
},
})
if err != nil {
t.Fatalf("CreateMessage() error = %v", err)
}
// Verify client received all messages including tool content
if len(receivedMessages) != 3 {
t.Fatalf("received %d messages, want 3", len(receivedMessages))
}
// Check first message is text
if _, ok := receivedMessages[0].Content.(*TextContent); !ok {
t.Errorf("message[0] content type = %T, want *TextContent", receivedMessages[0].Content)
}
// Check second message is tool use
toolUse, ok := receivedMessages[1].Content.(*ToolUseContent)
if !ok {
t.Fatalf("message[1] content type = %T, want *ToolUseContent", receivedMessages[1].Content)
}
if toolUse.ID != "tool_1" {
t.Errorf("toolUse.ID = %v, want tool_1", toolUse.ID)
}
// Check third message is tool result
toolResult, ok := receivedMessages[2].Content.(*ToolResultContent)
if !ok {
t.Fatalf("message[2] content type = %T, want *ToolResultContent", receivedMessages[2].Content)
}
if toolResult.ToolUseID != "tool_1" {
t.Errorf("toolResult.ToolUseID = %v, want tool_1", toolResult.ToolUseID)
}
if len(toolResult.Content) != 1 {
t.Fatalf("toolResult.Content len = %d, want 1", len(toolResult.Content))
}
if tc, ok := toolResult.Content[0].(*TextContent); !ok || tc.Text != "3" {
t.Errorf("toolResult.Content[0] = %v, want TextContent with '3'", toolResult.Content[0])
}
}
func TestSamplingToolsCapabilities(t *testing.T) {
ctx := context.Background()
t.Run("client with explicit tools capability", func(t *testing.T) {
ct, st := NewInMemoryTransports()
client := NewClient(testImpl, &ClientOptions{
CreateMessageHandler: func(_ context.Context, _ *CreateMessageRequest) (*CreateMessageResult, error) {
return &CreateMessageResult{Model: "m", Content: &TextContent{}}, nil
},
Capabilities: &ClientCapabilities{
Sampling: &SamplingCapabilities{
Tools: &SamplingToolsCapabilities{},
Context: &SamplingContextCapabilities{},
},
},
})
server := NewServer(testImpl, nil)
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
defer ss.Close()
cs, err := client.Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
defer cs.Close()
// Check server sees client capabilities
caps := ss.InitializeParams().Capabilities
if caps.Sampling == nil {
t.Fatal("client should advertise sampling capability")
}
if caps.Sampling.Tools == nil {
t.Error("client should advertise sampling.tools capability")
}
if caps.Sampling.Context == nil {
t.Error("client should advertise sampling.context capability")
}
})
t.Run("client without tools capability", func(t *testing.T) {
ct, st := NewInMemoryTransports()
client := NewClient(testImpl, &ClientOptions{
CreateMessageHandler: func(_ context.Context, _ *CreateMessageRequest) (*CreateMessageResult, error) {
return &CreateMessageResult{Model: "m", Content: &TextContent{}}, nil
},
// No Capabilities.Sampling.Tools set
})
server := NewServer(testImpl, nil)
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
defer ss.Close()
cs, err := client.Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
defer cs.Close()
// Check server sees client capabilities
caps := ss.InitializeParams().Capabilities
if caps.Sampling == nil {
t.Fatal("client should advertise sampling capability")
}
if caps.Sampling.Tools != nil {
t.Error("client should NOT advertise sampling.tools capability")
}
if caps.Sampling.Context != nil {
t.Error("client should NOT advertise sampling.context capability")
}
})
t.Run("client with inferred tools capability", func(t *testing.T) {
ct, st := NewInMemoryTransports()
client := NewClient(testImpl, &ClientOptions{
CreateMessageWithToolsHandler: func(_ context.Context, _ *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) {
return &CreateMessageWithToolsResult{Model: "m", Content: []Content{&TextContent{}}}, nil
},
// No explicit Capabilities set — tools should be inferred.
})
server := NewServer(testImpl, nil)
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
defer ss.Close()
cs, err := client.Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
defer cs.Close()
caps := ss.InitializeParams().Capabilities
if caps.Sampling == nil {
t.Fatal("client should advertise sampling capability")
}
if caps.Sampling.Tools == nil {
t.Error("client should infer sampling.tools capability from CreateMessageWithToolsHandler")
}
})
}
func TestSamplingToolResultWithError_Integration(t *testing.T) {
ctx := context.Background()
ct, st := NewInMemoryTransports()
var receivedMessages []*SamplingMessage
client := NewClient(testImpl, &ClientOptions{
CreateMessageHandler: func(_ context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) {
receivedMessages = req.Params.Messages
return &CreateMessageResult{
Model: "test-model",
Role: "assistant",
Content: &TextContent{Text: "I see the tool failed"},
}, nil
},
Capabilities: &ClientCapabilities{
Sampling: &SamplingCapabilities{Tools: &SamplingToolsCapabilities{}},
},
})
server := NewServer(testImpl, nil)
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
defer ss.Close()
cs, err := client.Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
defer cs.Close()
// Server sends CreateMessage with error tool result, preceded by
// the original request and tool use for a more realistic scenario.
_, err = ss.CreateMessage(ctx, &CreateMessageParams{
MaxTokens: 1000,
Messages: []*SamplingMessage{
{Role: "user", Content: &TextContent{Text: "Divide 1 by 0"}},
{Role: "assistant", Content: &ToolUseContent{
ID: "tool_1",
Name: "calculator",
Input: map[string]any{"op": "div", "x": 1.0, "y": 0.0},
}},
{Role: "user", Content: &ToolResultContent{
ToolUseID: "tool_1",
Content: []Content{&TextContent{Text: "division by zero"}},
IsError: true,
}},
},
})
if err != nil {
t.Fatalf("CreateMessage() error = %v", err)
}
if len(receivedMessages) != 3 {
t.Fatalf("received %d messages, want 3", len(receivedMessages))
}
toolResult, ok := receivedMessages[2].Content.(*ToolResultContent)
if !ok {
t.Fatalf("content type = %T, want *ToolResultContent", receivedMessages[2].Content)
}
if !toolResult.IsError {
t.Error("IsError should be true")
}
if toolResult.ToolUseID != "tool_1" {
t.Errorf("ToolUseID = %v, want tool_1", toolResult.ToolUseID)
}
}
func TestParallelToolCalls_Integration(t *testing.T) {
ctx := context.Background()
ct, st := NewInMemoryTransports()
// Client returns parallel tool use results
client := NewClient(testImpl, &ClientOptions{
CreateMessageWithToolsHandler: func(_ context.Context, req *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) {
return &CreateMessageWithToolsResult{
Model: "test-model",
Role: "assistant",
Content: []Content{
&ToolUseContent{ID: "call_1", Name: "weather", Input: map[string]any{"city": "SF"}},
&ToolUseContent{ID: "call_2", Name: "weather", Input: map[string]any{"city": "NY"}},
},
StopReason: "toolUse",
}, nil
},
Capabilities: &ClientCapabilities{
Sampling: &SamplingCapabilities{Tools: &SamplingToolsCapabilities{}},
},
})
server := NewServer(testImpl, nil)
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
defer ss.Close()
cs, err := client.Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
defer cs.Close()
result, err := ss.CreateMessageWithTools(ctx, &CreateMessageWithToolsParams{
MaxTokens: 1000,
Messages: []*SamplingMessageV2{
{Role: "user", Content: []Content{&TextContent{Text: "Weather in SF and NY"}}},
},
Tools: []*Tool{
{Name: "weather", InputSchema: map[string]any{"type": "object"}},
},
})
if err != nil {
t.Fatalf("CreateMessageWithTools() error = %v", err)
}
if len(result.Content) != 2 {
t.Fatalf("len(Content) = %d, want 2", len(result.Content))
}
for i, c := range result.Content {
tu, ok := c.(*ToolUseContent)
if !ok {
t.Fatalf("Content[%d] type = %T, want *ToolUseContent", i, c)
}
if tu.Name != "weather" {
t.Errorf("Content[%d].Name = %v, want weather", i, tu.Name)
}
}
if result.Content[0].(*ToolUseContent).ID != "call_1" {
t.Errorf("Content[0].ID = %v, want call_1", result.Content[0].(*ToolUseContent).ID)
}
if result.Content[1].(*ToolUseContent).ID != "call_2" {
t.Errorf("Content[1].ID = %v, want call_2", result.Content[1].(*ToolUseContent).ID)
}
}
func TestNewClient_BothHandlersPanics(t *testing.T) {
defer func() {
r := recover()
if r == nil {
t.Fatal("expected panic when both handlers set")
}
msg, ok := r.(string)
if !ok || !strings.Contains(msg, "CreateMessageHandler") {
t.Errorf("unexpected panic: %v", r)
}
}()
NewClient(testImpl, &ClientOptions{
CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) {
return nil, nil
},
CreateMessageWithToolsHandler: func(context.Context, *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) {
return nil, nil
},
})
}
func TestCreateMessage_MultipleContentError(t *testing.T) {
ctx := context.Background()
ct, st := NewInMemoryTransports()
// Client returns multiple content blocks via CreateMessageWithToolsHandler
client := NewClient(testImpl, &ClientOptions{
CreateMessageWithToolsHandler: func(_ context.Context, _ *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) {
return &CreateMessageWithToolsResult{
Model: "test",
Role: "assistant",
Content: []Content{
&TextContent{Text: "a"},
&TextContent{Text: "b"},
},
}, nil
},
})
server := NewServer(testImpl, nil)
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
defer ss.Close()
cs, err := client.Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
defer cs.Close()
// Server calls CreateMessage (singular), should get error
_, err = ss.CreateMessage(ctx, &CreateMessageParams{
MaxTokens: 100,
Messages: []*SamplingMessage{{Role: "user", Content: &TextContent{Text: "hi"}}},
})
if err == nil {
t.Fatal("expected error for multiple content blocks")
}
if !strings.Contains(err.Error(), "CreateMessageWithTools") {
t.Errorf("error should mention CreateMessageWithTools, got: %v", err)
}
}
func TestClientCapabilities_CloneSampling(t *testing.T) {
caps := &ClientCapabilities{
Sampling: &SamplingCapabilities{
Tools: &SamplingToolsCapabilities{},
Context: &SamplingContextCapabilities{},
},
}
cloned := caps.clone()
// Verify deep copy — Sampling pointer should differ.
// (Tools and Context are empty structs, so Go may reuse the same address;
// we just check they're non-nil and that mutating Sampling doesn't alias.)
if cloned.Sampling == caps.Sampling {
t.Error("Sampling pointer should differ after clone")
}
if cloned.Sampling.Tools == nil {
t.Error("cloned Sampling.Tools should not be nil")
}
if cloned.Sampling.Context == nil {
t.Error("cloned Sampling.Context should not be nil")
}
// Verify mutation doesn't affect original.
cloned.Sampling.Tools = nil
if caps.Sampling.Tools == nil {
t.Error("modifying cloned Sampling.Tools should not affect original")
}
}

View file

@ -1163,6 +1163,10 @@ func (ss *ServerSession) ListRoots(ctx context.Context, params *ListRootsParams)
}
// CreateMessage sends a sampling request to the client.
//
// If the client returns multiple content blocks (e.g. parallel tool calls),
// CreateMessage returns an error. Use [ServerSession.CreateMessageWithTools]
// for tool-enabled sampling.
func (ss *ServerSession) CreateMessage(ctx context.Context, params *CreateMessageParams) (*CreateMessageResult, error) {
if err := ss.checkInitialized(methodCreateMessage); err != nil {
return nil, err
@ -1175,7 +1179,44 @@ func (ss *ServerSession) CreateMessage(ctx context.Context, params *CreateMessag
p2.Messages = []*SamplingMessage{} // avoid JSON "null"
params = &p2
}
return handleSend[*CreateMessageResult](ctx, methodCreateMessage, newServerRequest(ss, orZero[Params](params)))
res, err := handleSend[*CreateMessageWithToolsResult](ctx, methodCreateMessage, newServerRequest(ss, orZero[Params](params)))
if err != nil {
return nil, err
}
// Downconvert to singular content.
if len(res.Content) > 1 {
return nil, fmt.Errorf("CreateMessage result has %d content blocks; use CreateMessageWithTools for multiple content", len(res.Content))
}
var content Content
if len(res.Content) > 0 {
content = res.Content[0]
}
return &CreateMessageResult{
Meta: res.Meta,
Content: content,
Model: res.Model,
Role: res.Role,
StopReason: res.StopReason,
}, nil
}
// CreateMessageWithTools sends a sampling request with tools to the client,
// returning a [CreateMessageWithToolsResult] that supports array content
// (for parallel tool calls). Use this instead of [ServerSession.CreateMessage]
// when the request includes tools.
func (ss *ServerSession) CreateMessageWithTools(ctx context.Context, params *CreateMessageWithToolsParams) (*CreateMessageWithToolsResult, error) {
if err := ss.checkInitialized(methodCreateMessage); err != nil {
return nil, err
}
if params == nil {
params = &CreateMessageWithToolsParams{Messages: []*SamplingMessageV2{}}
}
if params.Messages == nil {
p2 := *params
p2.Messages = []*SamplingMessageV2{} // avoid JSON "null"
params = &p2
}
return handleSend[*CreateMessageWithToolsResult](ctx, methodCreateMessage, newServerRequest(ss, orZero[Params](params)))
}
// Elicit sends an elicitation request to the client asking for user input.