github.com/anthropics/anthropic-sdk-go@v1.70.1/mcp/mcp.go (about)

     1  // Package mcp provides helpers for using Model Context Protocol tools and
     2  // resources with the Anthropic SDK.
     3  //
     4  // This package is imported separately from the core SDK so that consumers who
     5  // don't use MCP don't pull in the MCP SDK and its transitive dependencies.
     6  package mcp
     7  
     8  import (
     9  	"bytes"
    10  	"context"
    11  	"encoding/base64"
    12  	"encoding/json"
    13  	"errors"
    14  	"fmt"
    15  	"io"
    16  	"net/url"
    17  	"strings"
    18  
    19  	anthropic "github.com/anthropics/anthropic-sdk-go"
    20  	mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
    21  )
    22  
    23  // UnsupportedValueError is returned when an MCP value cannot be converted to
    24  // a format supported by the Claude API.
    25  type UnsupportedValueError struct {
    26  	Message string
    27  }
    28  
    29  func (e *UnsupportedValueError) Error() string { return e.Message }
    30  
    31  // -----------------------------------------------------------------------------
    32  // Tool conversion
    33  // -----------------------------------------------------------------------------
    34  
    35  // NewBetaTool creates an [anthropic.BetaTool] from an MCP tool and session
    36  // for use with [anthropic.BetaMessageService.NewToolRunner].
    37  //
    38  // The session must outlive any [anthropic.BetaToolRunner] that uses the
    39  // returned tool, since execution dispatches to the session.
    40  func NewBetaTool(tool *mcpsdk.Tool, session *mcpsdk.ClientSession) (anthropic.BetaTool, error) {
    41  	var inputSchema anthropic.BetaToolInputSchemaParam
    42  	if tool.InputSchema != nil {
    43  		// Round-trip via JSON: the MCP and Anthropic schema types use different
    44  		// representations internally, but their wire formats are compatible.
    45  		b, err := json.Marshal(tool.InputSchema)
    46  		if err == nil {
    47  			err = json.Unmarshal(b, &inputSchema)
    48  		}
    49  		if err != nil {
    50  			return nil, err
    51  		}
    52  	}
    53  	return &betaTool{tool: tool, session: session, schema: inputSchema}, nil
    54  }
    55  
    56  // NewBetaTools converts a slice of MCP tools into [anthropic.BetaTool] values
    57  // for the tool runner. The session must outlive any runner that uses these
    58  // tools.
    59  //
    60  //	result, _ := session.ListTools(ctx, nil)
    61  //	tools, err := mcp.NewBetaTools(result.Tools, session)
    62  //	runner := client.Beta.Messages.NewToolRunner(tools, params)
    63  func NewBetaTools(tools []*mcpsdk.Tool, session *mcpsdk.ClientSession) ([]anthropic.BetaTool, error) {
    64  	out := make([]anthropic.BetaTool, 0, len(tools))
    65  	for _, t := range tools {
    66  		item, err := NewBetaTool(t, session)
    67  		if err != nil {
    68  			return nil, err
    69  		}
    70  		out = append(out, item)
    71  	}
    72  	return out, nil
    73  }
    74  
    75  type betaTool struct {
    76  	tool    *mcpsdk.Tool
    77  	session *mcpsdk.ClientSession
    78  	schema  anthropic.BetaToolInputSchemaParam
    79  }
    80  
    81  func (t *betaTool) Name() string                                    { return t.tool.Name }
    82  func (t *betaTool) Description() string                             { return t.tool.Description }
    83  func (t *betaTool) InputSchema() anthropic.BetaToolInputSchemaParam { return t.schema }
    84  
    85  func (t *betaTool) Execute(ctx context.Context, input json.RawMessage) ([]anthropic.BetaToolResultBlockParamContentUnion, error) {
    86  	var args map[string]any
    87  	if err := json.Unmarshal(input, &args); err != nil {
    88  		return nil, fmt.Errorf("mcp tool %s: failed to unmarshal input: %w", t.tool.Name, err)
    89  	}
    90  
    91  	result, err := t.session.CallTool(ctx, &mcpsdk.CallToolParams{
    92  		Name:      t.tool.Name,
    93  		Arguments: args,
    94  	})
    95  	if err != nil {
    96  		return nil, fmt.Errorf("mcp tool %s: %w", t.tool.Name, err)
    97  	}
    98  
    99  	if result.IsError {
   100  		var parts []string
   101  		for _, c := range result.Content {
   102  			if tc, ok := c.(*mcpsdk.TextContent); ok && tc.Text != "" {
   103  				parts = append(parts, tc.Text)
   104  			}
   105  		}
   106  		msg := strings.Join(parts, "\n")
   107  		if msg == "" {
   108  			msg = "tool returned an error"
   109  		}
   110  		return nil, errors.New(msg)
   111  	}
   112  
   113  	// Per the MCP spec, when both Content and StructuredContent are present,
   114  	// Content is a text mirror of StructuredContent and either is sufficient.
   115  	// When Content is empty but StructuredContent is set, encode the structured
   116  	// data as a text block — matching the TS and Python implementations.
   117  	if len(result.Content) == 0 {
   118  		if result.StructuredContent != nil {
   119  			b, marshalErr := json.Marshal(result.StructuredContent)
   120  			if marshalErr != nil {
   121  				return nil, fmt.Errorf("mcp tool %s: failed to marshal structured content: %w", t.tool.Name, marshalErr)
   122  			}
   123  			return []anthropic.BetaToolResultBlockParamContentUnion{
   124  				{OfText: &anthropic.BetaTextBlockParam{Text: string(b)}},
   125  			}, nil
   126  		}
   127  		return nil, nil
   128  	}
   129  
   130  	blocks := make([]anthropic.BetaToolResultBlockParamContentUnion, 0, len(result.Content))
   131  	for _, c := range result.Content {
   132  		block, convErr := ToBlock(c)
   133  		if convErr != nil {
   134  			return nil, convErr
   135  		}
   136  		blocks = append(blocks, block)
   137  	}
   138  	return blocks, nil
   139  }
   140  
   141  // -----------------------------------------------------------------------------
   142  // Content conversion
   143  // -----------------------------------------------------------------------------
   144  
   145  // ToBlock converts a single MCP content item into an Anthropic content block.
   146  // Supported: TextContent, ImageContent (jpeg/png/gif/webp), EmbeddedResource.
   147  // Returns [*UnsupportedValueError] for AudioContent and ResourceLink.
   148  func ToBlock(content mcpsdk.Content) (anthropic.BetaToolResultBlockParamContentUnion, error) {
   149  	switch v := content.(type) {
   150  	case *mcpsdk.TextContent:
   151  		return anthropic.BetaToolResultBlockParamContentUnion{OfText: &anthropic.BetaTextBlockParam{Text: v.Text}}, nil
   152  
   153  	case *mcpsdk.ImageContent:
   154  		if !isSupportedImageMimeType(v.MIMEType) {
   155  			return anthropic.BetaToolResultBlockParamContentUnion{}, &UnsupportedValueError{
   156  				fmt.Sprintf("unsupported image MIME type: %s", v.MIMEType),
   157  			}
   158  		}
   159  		return anthropic.BetaToolResultBlockParamContentUnion{OfImage: &anthropic.BetaImageBlockParam{
   160  			Source: anthropic.BetaImageBlockParamSourceUnion{
   161  				OfBase64: &anthropic.BetaBase64ImageSourceParam{
   162  					Data:      base64.StdEncoding.EncodeToString(v.Data),
   163  					MediaType: anthropic.BetaBase64ImageSourceMediaType(v.MIMEType),
   164  				},
   165  			},
   166  		}}, nil
   167  
   168  	case *mcpsdk.EmbeddedResource:
   169  		if v.Resource == nil {
   170  			return anthropic.BetaToolResultBlockParamContentUnion{}, &UnsupportedValueError{"embedded resource has nil contents"}
   171  		}
   172  		return convertResourceContents(v.Resource)
   173  
   174  	case *mcpsdk.AudioContent, *mcpsdk.ResourceLink:
   175  		return anthropic.BetaToolResultBlockParamContentUnion{}, &UnsupportedValueError{
   176  			fmt.Sprintf("unsupported MCP content type: %T", content),
   177  		}
   178  
   179  	default:
   180  		return anthropic.BetaToolResultBlockParamContentUnion{}, &UnsupportedValueError{
   181  			fmt.Sprintf("unknown MCP content type: %T", content),
   182  		}
   183  	}
   184  }
   185  
   186  // ToMessage converts an MCP prompt message into a [anthropic.BetaMessageParam].
   187  func ToMessage(msg *mcpsdk.PromptMessage) (anthropic.BetaMessageParam, error) {
   188  	block, err := ToBlock(msg.Content)
   189  	if err != nil {
   190  		return anthropic.BetaMessageParam{}, err
   191  	}
   192  	return anthropic.BetaMessageParam{
   193  		Role: anthropic.BetaMessageParamRole(msg.Role),
   194  		Content: []anthropic.BetaContentBlockParamUnion{{
   195  			OfText:     block.OfText,
   196  			OfImage:    block.OfImage,
   197  			OfDocument: block.OfDocument,
   198  		}},
   199  	}, nil
   200  }
   201  
   202  // -----------------------------------------------------------------------------
   203  // Resource conversion
   204  // -----------------------------------------------------------------------------
   205  
   206  // ResourceToBlock converts MCP resource read results into an Anthropic content
   207  // block. Returns the first item in Contents with a MIME type supported by the
   208  // Claude API.
   209  func ResourceToBlock(result *mcpsdk.ReadResourceResult) (anthropic.BetaToolResultBlockParamContentUnion, error) {
   210  	if len(result.Contents) == 0 {
   211  		return anthropic.BetaToolResultBlockParamContentUnion{}, &UnsupportedValueError{
   212  			"resource contents array must contain at least one item",
   213  		}
   214  	}
   215  	for _, c := range result.Contents {
   216  		if isSupportedResourceMimeType(c.MIMEType) {
   217  			return convertResourceContents(c)
   218  		}
   219  	}
   220  	var mimeTypes []string
   221  	for _, c := range result.Contents {
   222  		if c.MIMEType != "" {
   223  			mimeTypes = append(mimeTypes, c.MIMEType)
   224  		}
   225  	}
   226  	return anthropic.BetaToolResultBlockParamContentUnion{}, &UnsupportedValueError{
   227  		fmt.Sprintf("no supported MIME type found in resource contents. Available: %s", strings.Join(mimeTypes, ", ")),
   228  	}
   229  }
   230  
   231  // ResourceToFile converts MCP resource contents into an [io.Reader] suitable
   232  // for [anthropic.BetaFileUploadParams].File. Uses the first item in Contents
   233  // regardless of MIME type — any resource can be uploaded as a file.
   234  func ResourceToFile(result *mcpsdk.ReadResourceResult) (io.Reader, error) {
   235  	if len(result.Contents) == 0 {
   236  		return nil, &UnsupportedValueError{"resource contents array must contain at least one item"}
   237  	}
   238  	res := result.Contents[0]
   239  	var data []byte
   240  	if len(res.Blob) > 0 {
   241  		data = res.Blob
   242  	} else {
   243  		data = []byte(res.Text)
   244  	}
   245  	return anthropic.File(bytes.NewReader(data), extractFilename(res.URI), res.MIMEType), nil
   246  }
   247  
   248  // -----------------------------------------------------------------------------
   249  // Internal helpers
   250  // -----------------------------------------------------------------------------
   251  
   252  func convertResourceContents(res *mcpsdk.ResourceContents) (anthropic.BetaToolResultBlockParamContentUnion, error) {
   253  	mimeType := res.MIMEType
   254  
   255  	if isSupportedImageMimeType(mimeType) {
   256  		if len(res.Blob) == 0 {
   257  			return anthropic.BetaToolResultBlockParamContentUnion{}, &UnsupportedValueError{
   258  				fmt.Sprintf("image resource must have blob data, not text. URI: %s", res.URI),
   259  			}
   260  		}
   261  		return anthropic.BetaToolResultBlockParamContentUnion{OfImage: &anthropic.BetaImageBlockParam{
   262  			Source: anthropic.BetaImageBlockParamSourceUnion{
   263  				OfBase64: &anthropic.BetaBase64ImageSourceParam{
   264  					Data:      base64.StdEncoding.EncodeToString(res.Blob),
   265  					MediaType: anthropic.BetaBase64ImageSourceMediaType(mimeType),
   266  				},
   267  			},
   268  		}}, nil
   269  	}
   270  
   271  	if mimeType == "application/pdf" {
   272  		if len(res.Blob) == 0 {
   273  			return anthropic.BetaToolResultBlockParamContentUnion{}, &UnsupportedValueError{
   274  				fmt.Sprintf("PDF resource must have blob data, not text. URI: %s", res.URI),
   275  			}
   276  		}
   277  		return anthropic.BetaToolResultBlockParamContentUnion{OfDocument: &anthropic.BetaRequestDocumentBlockParam{
   278  			Source: anthropic.BetaRequestDocumentBlockSourceUnionParam{
   279  				OfBase64: &anthropic.BetaBase64PDFSourceParam{Data: base64.StdEncoding.EncodeToString(res.Blob)},
   280  			},
   281  		}}, nil
   282  	}
   283  
   284  	if mimeType == "" || strings.HasPrefix(mimeType, "text/") {
   285  		var text string
   286  		if len(res.Blob) > 0 {
   287  			text = string(res.Blob)
   288  		} else {
   289  			text = res.Text
   290  		}
   291  		return anthropic.BetaToolResultBlockParamContentUnion{OfDocument: &anthropic.BetaRequestDocumentBlockParam{
   292  			Source: anthropic.BetaRequestDocumentBlockSourceUnionParam{
   293  				OfText: &anthropic.BetaPlainTextSourceParam{Data: text},
   294  			},
   295  		}}, nil
   296  	}
   297  
   298  	return anthropic.BetaToolResultBlockParamContentUnion{}, &UnsupportedValueError{
   299  		fmt.Sprintf("unsupported MIME type %q for resource: %s", mimeType, res.URI),
   300  	}
   301  }
   302  
   303  var supportedImageMimeTypes = map[string]bool{
   304  	"image/jpeg": true,
   305  	"image/png":  true,
   306  	"image/gif":  true,
   307  	"image/webp": true,
   308  }
   309  
   310  func isSupportedImageMimeType(mimeType string) bool {
   311  	return supportedImageMimeTypes[mimeType]
   312  }
   313  
   314  func isSupportedResourceMimeType(mimeType string) bool {
   315  	return mimeType == "" ||
   316  		strings.HasPrefix(mimeType, "text/") ||
   317  		mimeType == "application/pdf" ||
   318  		isSupportedImageMimeType(mimeType)
   319  }
   320  
   321  func extractFilename(uri string) string {
   322  	if uri == "" {
   323  		return "file"
   324  	}
   325  	u, err := url.Parse(uri)
   326  	if err == nil && u.Path != "" {
   327  		if idx := strings.LastIndex(u.Path, "/"); idx >= 0 {
   328  			if name := u.Path[idx+1:]; name != "" {
   329  				return name
   330  			}
   331  		}
   332  	}
   333  	return "file"
   334  }