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 }