diff --git a/design/design.md b/design/design.md index 66338b7a..5c77b7de 100644 --- a/design/design.md +++ b/design/design.md @@ -269,7 +269,7 @@ These types will be included in the `mcp` package, but will be unexported unless For user-provided data, we use `json.RawMessage` or `map[string]any`, depending on the use case. -For union types, which can't be represented in Go (specifically `Content` and `ResourceContents`), we prefer distinguished unions: struct types with fields corresponding to the union of all properties for union elements. +For union types, which can't be represented in Go, we use an interface for `Content` (implemented by types like `TextContent`). For other union types like `ResourceContents`, we use a struct with optional fields. For brevity, only a few examples are shown here: @@ -284,20 +284,16 @@ type CallToolResult struct { IsError bool `json:"isError,omitempty"` } -// Content is the wire format for content. -// -// The Type field distinguishes the type of the content. -// At most one of Text, MIMEType, Data, and Resource is non-zero. -type Content 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"` +// A Content is a [TextContent], [ImageContent], [AudioContent] or +// [EmbeddedResource]. +type Content interface { + // (unexported methods) } -// NewTextContent creates a [Content] with text. -func NewTextContent(text string) *Content +// TextContent is a textual content. +type TextContent struct { + Text string +} // etc. ``` diff --git a/examples/hello/main.go b/examples/hello/main.go index 5f343ff9..fe791e0a 100644 --- a/examples/hello/main.go +++ b/examples/hello/main.go @@ -24,8 +24,8 @@ type HiArgs struct { func SayHi(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[HiArgs]) (*mcp.CallToolResultFor[struct{}], error) { return &mcp.CallToolResultFor[struct{}]{ - Content: []*mcp.ContentBlock{ - mcp.NewTextContent("Hi " + params.Name), + Content: []mcp.Content{ + &mcp.TextContent{Text: "Hi " + params.Name}, }, }, nil } @@ -36,7 +36,7 @@ func PromptHi(ctx context.Context, ss *mcp.ServerSession, params *mcp.GetPromptP return &mcp.GetPromptResult{ Description: "Code review prompt", Messages: []*mcp.PromptMessage{ - {Role: "user", Content: mcp.NewTextContent("Say hi to " + params.Arguments["name"])}, + {Role: "user", Content: &mcp.TextContent{Text: "Say hi to " + params.Arguments["name"]}}, }, }, nil } @@ -93,6 +93,8 @@ func handleEmbeddedResource(_ context.Context, _ *mcp.ServerSession, params *mcp return nil, fmt.Errorf("no embedded resource named %q", key) } return &mcp.ReadResourceResult{ - Contents: []*mcp.ResourceContents{mcp.NewTextResourceContents(params.URI, "text/plain", text)}, + Contents: []*mcp.ResourceContents{ + {URI: params.URI, MIMEType: "text/plain", Text: text}, + }, }, nil } diff --git a/examples/sse/main.go b/examples/sse/main.go index f88605a0..5e4c851e 100644 --- a/examples/sse/main.go +++ b/examples/sse/main.go @@ -21,8 +21,8 @@ type SayHiParams struct { func SayHi(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[SayHiParams]) (*mcp.CallToolResultFor[any], error) { return &mcp.CallToolResultFor[any]{ - Content: []*mcp.ContentBlock{ - mcp.NewTextContent("Hi " + params.Name), + Content: []mcp.Content{ + &mcp.TextContent{Text: "Hi " + params.Name}, }, }, nil } diff --git a/internal/readme/client/client.go b/internal/readme/client/client.go index 684b7a23..44bc515c 100644 --- a/internal/readme/client/client.go +++ b/internal/readme/client/client.go @@ -40,7 +40,7 @@ func main() { log.Fatal("tool failed") } for _, c := range res.Content { - log.Print(c.Text) + log.Print(c.(*mcp.TextContent).Text) } } diff --git a/internal/readme/server/server.go b/internal/readme/server/server.go index 8a93ea2b..b709cd75 100644 --- a/internal/readme/server/server.go +++ b/internal/readme/server/server.go @@ -18,7 +18,7 @@ type HiParams struct { func SayHi(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[HiParams]) (*mcp.CallToolResultFor[any], error) { return &mcp.CallToolResultFor[any]{ - Content: []*mcp.ContentBlock{mcp.NewTextContent("Hi " + params.Name)}, + Content: []mcp.Content{&mcp.TextContent{Text: "Hi " + params.Name}}, }, nil } diff --git a/mcp/cmd_test.go b/mcp/cmd_test.go index 1d34050e..f66423d6 100644 --- a/mcp/cmd_test.go +++ b/mcp/cmd_test.go @@ -70,7 +70,9 @@ func TestCmdTransport(t *testing.T) { log.Fatal(err) } want := &mcp.CallToolResult{ - Content: []*mcp.ContentBlock{{Type: "text", Text: "Hi user"}}, + Content: []mcp.Content{ + &mcp.TextContent{Text: "Hi user"}, + }, } if diff := cmp.Diff(want, got); diff != "" { t.Errorf("greet returned unexpected content (-want +got):\n%s", diff) diff --git a/mcp/content.go b/mcp/content.go index 587f999d..43f7fdc0 100644 --- a/mcp/content.go +++ b/mcp/content.go @@ -10,81 +10,117 @@ import ( "fmt" ) -// A ContentBlock is one of a TextContent, ImageContent, AudioContent -// ResourceLink, or EmbeddedResource. -// Use [NewTextContent], [NewImageContent], [NewAudioContent], [NewResourceLink] -// or [NewResourceContents] to create one. +// A Content is a [TextContent], [ImageContent], [AudioContent] or +// [EmbeddedResource]. // -// The Type field must be one of "text", "image", "audio", "resource_link" or "resource". -// The constructors above populate this field appropriately. -// Although at most one of Text, Data, ResourceLink and Resource should be non-zero, -// consumers of ContentBlock use the Type field to determine which value to use; -// values in the other fields are ignored. -// TODO(jba,rfindley): rethink this type. Each kind (text, image, etc.) should have its own -// meta and annotations, otherwise they're duplicated for Resource and ResourceContents. -type ContentBlock struct { - Meta map[string]any `json:"_meta,omitempty"` - Type string `json:"type"` - Text string `json:"text,omitempty"` - MIMEType string `json:"mimeType,omitempty"` - Data []byte `json:"data,omitempty"` - ResourceLink *Resource `json:"resource_link,omitempty"` - Resource *ResourceContents `json:"resource,omitempty"` - Annotations *Annotations `json:"annotations,omitempty"` -} - -func (c *ContentBlock) UnmarshalJSON(data []byte) error { - type wireContent ContentBlock // for naive unmarshaling - var c2 wireContent - if err := json.Unmarshal(data, &c2); err != nil { - return err - } - switch c2.Type { - case "text", "image", "audio", "resource", "resource_link": - default: - return fmt.Errorf("unrecognized content type %s", c.Type) - } - *c = ContentBlock(c2) - return nil +// TODO(rfindley): add ResourceLink. +type Content interface { + MarshalJSON() ([]byte, error) + fromWire(*wireContent) } -// NewTextContent creates a [ContentBlock] with text. -func NewTextContent(text string) *ContentBlock { - return &ContentBlock{Type: "text", Text: text} +// TextContent is a textual content. +type TextContent struct { + Text string + Meta Meta + Annotations *Annotations } -// NewImageContent creates a [ContentBlock] with image data. -func NewImageContent(data []byte, mimeType string) *ContentBlock { - return &ContentBlock{Type: "image", Data: data, MIMEType: mimeType} +func (c *TextContent) MarshalJSON() ([]byte, error) { + return json.Marshal(&wireContent{ + Type: "text", + Text: c.Text, + Meta: c.Meta, + Annotations: c.Annotations, + }) } -// NewAudioContent creates a [ContentBlock] with audio data. -func NewAudioContent(data []byte, mimeType string) *ContentBlock { - return &ContentBlock{Type: "audio", Data: data, MIMEType: mimeType} +func (c *TextContent) fromWire(wire *wireContent) { + c.Text = wire.Text + c.Meta = wire.Meta + c.Annotations = wire.Annotations } -// NewResourceLink creates a [ContentBlock] with a [Resource]. -func NewResourceLink(r *Resource) *ContentBlock { - return &ContentBlock{Type: "resource_link", ResourceLink: r} +// ImageContent contains base64-encoded image data. +type ImageContent struct { + Meta Meta + Annotations *Annotations + Data []byte // base64-encoded + MIMEType string } -// NewResourceContents creates a [ContentBlock] with an embedded resource (a [ResourceContents]). -func NewResourceContents(rc *ResourceContents) *ContentBlock { - return &ContentBlock{Type: "resource", Resource: rc} +func (c *ImageContent) MarshalJSON() ([]byte, error) { + return json.Marshal(&wireContent{ + Type: "image", + MIMEType: c.MIMEType, + Data: c.Data, + Meta: c.Meta, + Annotations: c.Annotations, + }) } -// ResourceContents represents the union of the spec's {Text,Blob}ResourceContents types. -// See https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-03-26/schema.ts#L524-L551 -// for the inheritance structure. +func (c *ImageContent) fromWire(wire *wireContent) { + c.MIMEType = wire.MIMEType + c.Data = wire.Data + c.Meta = wire.Meta + c.Annotations = wire.Annotations +} + +// AudioContent contains base64-encoded audio data. +type AudioContent struct { + Data []byte + MIMEType string + Meta Meta + Annotations *Annotations +} + +func (c AudioContent) MarshalJSON() ([]byte, error) { + return json.Marshal(&wireContent{ + Type: "audio", + MIMEType: c.MIMEType, + Data: c.Data, + Meta: c.Meta, + Annotations: c.Annotations, + }) +} + +func (c *AudioContent) fromWire(wire *wireContent) { + c.MIMEType = wire.MIMEType + c.Data = wire.Data + c.Meta = wire.Meta + c.Annotations = wire.Annotations +} -// A ResourceContents is either a TextResourceContents or a BlobResourceContents. -// Use [NewTextResourceContents] or [NextBlobResourceContents] to create one. +// EmbeddedResource contains embedded resources. +type EmbeddedResource struct { + Resource *ResourceContents + Meta Meta + Annotations *Annotations +} + +func (c *EmbeddedResource) MarshalJSON() ([]byte, error) { + return json.Marshal(&wireContent{ + Type: "resource", + Resource: c.Resource, + Meta: c.Meta, + Annotations: c.Annotations, + }) +} + +func (c *EmbeddedResource) fromWire(wire *wireContent) { + c.Resource = wire.Resource + c.Meta = wire.Meta + c.Annotations = wire.Annotations +} + +// ResourceContents contains the contents of a specific resource or +// sub-resource. type ResourceContents struct { - Meta map[string]any `json:"_meta,omitempty"` - URI string `json:"uri"` // resource location; must not be empty - MIMEType string `json:"mimeType,omitempty"` - Text string `json:"text"` - Blob []byte `json:"blob,omitempty"` // if nil, then text; else blob + URI string `json:"uri"` + MIMEType string `json:"mimeType,omitempty"` + Text string `json:"text,omitempty"` + Blob []byte `json:"blob,omitempty"` + Meta Meta `json:"_meta,omitempty"` } func (r ResourceContents) MarshalJSON() ([]byte, error) { @@ -114,25 +150,55 @@ func (r ResourceContents) MarshalJSON() ([]byte, error) { return json.Marshal(br) } -// NewTextResourceContents returns a [ResourceContents] containing text. -func NewTextResourceContents(uri, mimeType, text string) *ResourceContents { - return &ResourceContents{ - URI: uri, - MIMEType: mimeType, - Text: text, - // Blob is nil, indicating this is a TextResourceContents. +// wireContent is the wire format for content. +// It represents the protocol types TextContent, ImageContent, AudioContent +// and EmbeddedResource. +// The Type field distinguishes them. In the protocol, each type has a constant +// value for the field. +// At most one of Text, Data, and Resource 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"` + Meta Meta `json:"_meta,omitempty"` + Annotations *Annotations `json:"annotations,omitempty"` +} + +func contentsFromWire(wires []*wireContent, allow map[string]bool) ([]Content, error) { + var blocks []Content + for _, wire := range wires { + block, err := contentFromWire(wire, allow) + if err != nil { + return nil, err + } + blocks = append(blocks, block) } + return blocks, nil } -// NewBlobResourceContents returns a [ResourceContents] containing a byte slice. -func NewBlobResourceContents(uri, mimeType string, blob []byte) *ResourceContents { - // The only way to distinguish text from blob is a non-nil Blob field. - if blob == nil { - blob = []byte{} +func contentFromWire(wire *wireContent, allow map[string]bool) (Content, error) { + if allow != nil && !allow[wire.Type] { + return nil, fmt.Errorf("invalid content type %q", wire.Type) } - return &ResourceContents{ - URI: uri, - MIMEType: mimeType, - Blob: blob, + switch wire.Type { + case "text": + v := new(TextContent) + v.fromWire(wire) + return v, nil + case "image": + v := new(ImageContent) + v.fromWire(wire) + return v, nil + case "audio": + v := new(AudioContent) + v.fromWire(wire) + return v, nil + case "resource": + v := new(EmbeddedResource) + v.fromWire(wire) + return v, nil } + return nil, fmt.Errorf("internal error: unrecognized content type %s", wire.Type) } diff --git a/mcp/content_test.go b/mcp/content_test.go index 08974c3c..5270f991 100644 --- a/mcp/content_test.go +++ b/mcp/content_test.go @@ -6,6 +6,8 @@ package mcp_test import ( "encoding/json" + "fmt" + "log" "testing" "github.com/google/go-cmp/cmp" @@ -14,30 +16,73 @@ import ( func TestContent(t *testing.T) { tests := []struct { - in *mcp.ContentBlock + in mcp.Content want string // json serialization }{ - {mcp.NewTextContent("hello"), `{"type":"text","text":"hello"}`}, { - mcp.NewImageContent([]byte("a1b2c3"), "image/png"), + &mcp.TextContent{Text: "hello"}, + `{"type":"text","text":"hello"}`, + }, + { + &mcp.TextContent{ + Text: "hello", + Meta: mcp.Meta{"key": "value"}, + Annotations: &mcp.Annotations{Priority: 1.0}, + }, + `{"type":"text","text":"hello","_meta":{"key":"value"},"annotations":{"priority":1}}`, + }, + { + &mcp.ImageContent{ + Data: []byte("a1b2c3"), + MIMEType: "image/png", + }, `{"type":"image","mimeType":"image/png","data":"YTFiMmMz"}`, }, { - mcp.NewAudioContent([]byte("a1b2c3"), "audio/wav"), + &mcp.ImageContent{ + Data: []byte("a1b2c3"), + MIMEType: "image/png", + Meta: mcp.Meta{"key": "value"}, + Annotations: &mcp.Annotations{Priority: 1.0}, + }, + `{"type":"image","mimeType":"image/png","data":"YTFiMmMz","_meta":{"key":"value"},"annotations":{"priority":1}}`, + }, + { + &mcp.AudioContent{ + Data: []byte("a1b2c3"), + MIMEType: "audio/wav", + }, `{"type":"audio","mimeType":"audio/wav","data":"YTFiMmMz"}`, }, { - mcp.NewResourceContents( - mcp.NewTextResourceContents("file://foo", "text", "abc"), - ), + &mcp.AudioContent{ + Data: []byte("a1b2c3"), + MIMEType: "audio/wav", + Meta: mcp.Meta{"key": "value"}, + Annotations: &mcp.Annotations{Priority: 1.0}, + }, + `{"type":"audio","mimeType":"audio/wav","data":"YTFiMmMz","_meta":{"key":"value"},"annotations":{"priority":1}}`, + }, + { + &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{URI: "file://foo", MIMEType: "text", Text: "abc"}, + }, `{"type":"resource","resource":{"uri":"file://foo","mimeType":"text","text":"abc"}}`, }, { - mcp.NewResourceContents( - mcp.NewBlobResourceContents("file://foo", "image/png", []byte("a1b2c3")), - ), + &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{URI: "file://foo", MIMEType: "image/png", Blob: []byte("a1b2c3")}, + }, `{"type":"resource","resource":{"uri":"file://foo","mimeType":"image/png","blob":"YTFiMmMz"}}`, }, + { + &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{URI: "file://foo", MIMEType: "text", Text: "abc"}, + Meta: mcp.Meta{"key": "value"}, + Annotations: &mcp.Annotations{Priority: 1.0}, + }, + `{"type":"resource","resource":{"uri":"file://foo","mimeType":"text","text":"abc"},"_meta":{"key":"value"},"annotations":{"priority":1}}`, + }, } for _, test := range tests { @@ -48,39 +93,41 @@ func TestContent(t *testing.T) { if diff := cmp.Diff(test.want, string(got)); diff != "" { t.Errorf("json.Marshal(%v) mismatch (-want +got):\n%s", test.in, diff) } - var out *mcp.ContentBlock - if err := json.Unmarshal(got, &out); err != nil { + result := fmt.Sprintf(`{"content":[%s]}`, string(got)) + log.Println(result) + var out mcp.CallToolResult + if err := json.Unmarshal([]byte(result), &out); err != nil { t.Fatal(err) } - if diff := cmp.Diff(test.in, out); diff != "" { + if diff := cmp.Diff(test.in, out.Content[0]); diff != "" { t.Errorf("json.Unmarshal(%q) mismatch (-want +got):\n%s", string(got), diff) } } } -func TestResourceContents(t *testing.T) { +func TestEmbeddedResource(t *testing.T) { for _, tt := range []struct { - rc mcp.ResourceContents + rc *mcp.ResourceContents want string // marshaled JSON }{ { - mcp.ResourceContents{URI: "u", Text: "t"}, + &mcp.ResourceContents{URI: "u", Text: "t"}, `{"uri":"u","text":"t"}`, }, { - mcp.ResourceContents{URI: "u", MIMEType: "m", Text: "t"}, - `{"uri":"u","mimeType":"m","text":"t"}`, + &mcp.ResourceContents{URI: "u", MIMEType: "m", Text: "t", Meta: mcp.Meta{"key": "value"}}, + `{"uri":"u","mimeType":"m","text":"t","_meta":{"key":"value"}}`, }, { - mcp.ResourceContents{URI: "u", Text: "", Blob: nil}, - `{"uri":"u","text":""}`, + &mcp.ResourceContents{URI: "u"}, + `{"uri":"u"}`, }, { - mcp.ResourceContents{URI: "u", Blob: []byte{}}, + &mcp.ResourceContents{URI: "u", Blob: []byte{}}, `{"uri":"u","blob":""}`, }, { - mcp.ResourceContents{URI: "u", Blob: []byte{1}}, + &mcp.ResourceContents{URI: "u", Blob: []byte{1}}, `{"uri":"u","blob":"AQ=="}`, }, } { @@ -91,10 +138,11 @@ func TestResourceContents(t *testing.T) { if got := string(data); got != tt.want { t.Errorf("%#v:\ngot %s\nwant %s", tt.rc, got, tt.want) } - var urc mcp.ResourceContents - if err := json.Unmarshal(data, &urc); err != nil { + urc := new(mcp.ResourceContents) + if err := json.Unmarshal(data, urc); err != nil { t.Fatal(err) } + // Since Blob is omitempty, the empty slice changes to nil. if diff := cmp.Diff(tt.rc, urc); diff != "" { t.Errorf("mismatch (-want, +got):\n%s", diff) } diff --git a/mcp/features_test.go b/mcp/features_test.go index 8a965a67..5ffbce8c 100644 --- a/mcp/features_test.go +++ b/mcp/features_test.go @@ -20,8 +20,8 @@ type SayHiParams struct { func SayHi(ctx context.Context, cc *ServerSession, params *CallToolParamsFor[SayHiParams]) (*CallToolResultFor[any], error) { return &CallToolResultFor[any]{ - Content: []*ContentBlock{ - NewTextContent("Hi " + params.Name), + Content: []Content{ + &TextContent{Text: "Hi " + params.Name}, }, }, nil } diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index dd8de8d2..71b99a4a 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -34,7 +34,7 @@ func sayHi(ctx context.Context, ss *ServerSession, params *CallToolParamsFor[hiP if err := ss.Ping(ctx, nil); err != nil { return nil, fmt.Errorf("ping failed: %v", err) } - return &CallToolResultFor[any]{Content: []*ContentBlock{NewTextContent("hi " + params.Arguments.Name)}}, nil + return &CallToolResultFor[any]{Content: []Content{&TextContent{Text: "hi " + params.Arguments.Name}}}, nil } func TestEndToEnd(t *testing.T) { @@ -142,7 +142,7 @@ func TestEndToEnd(t *testing.T) { wantReview := &GetPromptResult{ Description: "Code review prompt", Messages: []*PromptMessage{{ - Content: NewTextContent("Please review the following code: 1+1"), + Content: &TextContent{Text: "Please review the following code: 1+1"}, Role: "user", }}, } @@ -195,7 +195,9 @@ func TestEndToEnd(t *testing.T) { t.Fatal(err) } wantHi := &CallToolResult{ - Content: []*ContentBlock{{Type: "text", Text: "hi user"}}, + Content: []Content{ + &TextContent{Text: "hi user"}, + }, } if diff := cmp.Diff(wantHi, gotHi); diff != "" { t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff) @@ -212,7 +214,9 @@ func TestEndToEnd(t *testing.T) { } wantFail := &CallToolResult{ IsError: true, - Content: []*ContentBlock{{Type: "text", Text: errTestFailure.Error()}}, + Content: []Content{ + &TextContent{Text: errTestFailure.Error()}, + }, } if diff := cmp.Diff(wantFail, gotFail); diff != "" { t.Errorf("tools/call 'fail' mismatch (-want +got):\n%s", diff) @@ -451,7 +455,7 @@ var ( return &GetPromptResult{ Description: "Code review prompt", Messages: []*PromptMessage{ - {Role: "user", Content: NewTextContent("Please review the following code: " + params.Arguments["Code"])}, + {Role: "user", Content: &TextContent{Text: "Please review the following code: " + params.Arguments["Code"]}}, }, }, nil }, @@ -505,7 +509,9 @@ func handleEmbeddedResource(_ context.Context, _ *ServerSession, params *ReadRes return nil, fmt.Errorf("no embedded resource named %q", key) } return &ReadResourceResult{ - Contents: []*ResourceContents{NewTextResourceContents(params.URI, "text/plain", text)}, + Contents: []*ResourceContents{ + {URI: params.URI, MIMEType: "text/plain", Text: text}, + }, }, nil } diff --git a/mcp/protocol.go b/mcp/protocol.go index 7168c938..439d07a0 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -11,6 +11,8 @@ package mcp // sdiff -l <(curl $prefix/2025-03-26/schema.ts) <(curl $prefix/2025/06-18/schema.ts) import ( + "encoding/json" + "github.com/modelcontextprotocol/go-sdk/jsonschema" ) @@ -63,7 +65,7 @@ type CallToolResult struct { Meta `json:"_meta,omitempty"` // A list of content objects that represent the unstructured result of the tool // call. - Content []*ContentBlock `json:"content"` + Content []Content `json:"content"` // Whether the tool call ended in an error. // // If not set, this is assumed to be false (the call was successful). @@ -83,13 +85,32 @@ type CallToolResult struct { StructuredContent map[string]any `json:"structuredContent,omitempty"` } +// UnmarshalJSON handles the unmarshalling of content into the Content +// interface. +func (x *CallToolResult) UnmarshalJSON(data []byte) error { + type res CallToolResult // avoid recursion + var wire struct { + res + Content []*wireContent `json:"content"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + var err error + if wire.res.Content, err = contentsFromWire(wire.Content, nil); err != nil { + return err + } + *x = CallToolResult(wire.res) + return nil +} + type CallToolResultFor[Out any] struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` // A list of content objects that represent the unstructured result of the tool // call. - Content []*ContentBlock `json:"content"` + Content []Content `json:"content"` // Whether the tool call ended in an error. // // If not set, this is assumed to be false (the call was successful). @@ -177,7 +198,7 @@ type CreateMessageResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` - Content any `json:"content"` + Content Content `json:"content"` // The name of the model that generated the message. Model string `json:"model"` Role Role `json:"role"` @@ -518,8 +539,27 @@ func (x *PromptListChangedParams) SetProgressToken(t any) { setProgressToken(x, // This is similar to `SamplingMessage`, but also supports the embedding of // resources from the MCP server. type PromptMessage struct { - Content *ContentBlock `json:"content"` - Role Role `json:"role"` + Content Content `json:"content"` + Role Role `json:"role"` +} + +// UnmarshalJSON handles the unmarshalling of content into the Content +// interface. +func (m *PromptMessage) UnmarshalJSON(data []byte) error { + type msg PromptMessage // avoid recursion + var wire struct { + msg + Content *wireContent `json:"content"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + var err error + if wire.msg.Content, err = contentFromWire(wire.Content, nil); err != nil { + return err + } + *m = PromptMessage(wire.msg) + return nil } type ReadResourceParams struct { @@ -650,8 +690,27 @@ type ElicitationCapabilities struct{} // Describes a message issued to or received from an LLM API. type SamplingMessage struct { - Content any `json:"content"` - Role Role `json:"role"` + Content Content `json:"content"` + Role Role `json:"role"` +} + +// UnmarshalJSON handles the unmarshalling of content into the Content +// interface. +func (m *SamplingMessage) UnmarshalJSON(data []byte) error { + type msg SamplingMessage // avoid recursion + var wire struct { + msg + Content *wireContent `json:"content"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + var err error + if wire.msg.Content, err = contentFromWire(wire.Content, map[string]bool{"text": true, "image": true, "audio": true}); err != nil { + return err + } + *m = SamplingMessage(wire.msg) + return nil } type SetLevelParams struct { diff --git a/mcp/server.go b/mcp/server.go index f71fe304..c4aeeed9 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -412,7 +412,9 @@ func fileResourceHandler(dir string) ResourceHandler { return nil, err } // TODO(jba): figure out mime type. Omit for now: Server.readResource will fill it in. - return &ReadResourceResult{Contents: []*ResourceContents{NewBlobResourceContents(params.URI, "", data)}}, nil + return &ReadResourceResult{Contents: []*ResourceContents{ + &ResourceContents{URI: params.URI, Blob: data}, + }}, nil } } diff --git a/mcp/server_example_test.go b/mcp/server_example_test.go index b4af296c..9e982374 100644 --- a/mcp/server_example_test.go +++ b/mcp/server_example_test.go @@ -18,8 +18,8 @@ type SayHiParams struct { func SayHi(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[SayHiParams]) (*mcp.CallToolResultFor[any], error) { return &mcp.CallToolResultFor[any]{ - Content: []*mcp.ContentBlock{ - mcp.NewTextContent("Hi " + params.Arguments.Name), + Content: []mcp.Content{ + &mcp.TextContent{Text: "Hi " + params.Arguments.Name}, }, }, nil } @@ -49,7 +49,7 @@ func ExampleServer() { if err != nil { log.Fatal(err) } - fmt.Println(res.Content[0].Text) + fmt.Println(res.Content[0].(*mcp.TextContent).Text) clientSession.Close() serverSession.Wait() diff --git a/mcp/sse_example_test.go b/mcp/sse_example_test.go index d0a9f98d..70f84c3e 100644 --- a/mcp/sse_example_test.go +++ b/mcp/sse_example_test.go @@ -20,7 +20,9 @@ type AddParams struct { func Add(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[AddParams]) (*mcp.CallToolResultFor[any], error) { return &mcp.CallToolResultFor[any]{ - Content: []*mcp.ContentBlock{mcp.NewTextContent(fmt.Sprintf("%d", params.Arguments.X+params.Arguments.Y))}, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("%d", params.Arguments.X+params.Arguments.Y)}, + }, }, nil } @@ -48,7 +50,7 @@ func ExampleSSEHandler() { if err != nil { log.Fatal(err) } - fmt.Println(res.Content[0].Text) + fmt.Println(res.Content[0].(*mcp.TextContent).Text) // Output: 3 } diff --git a/mcp/sse_test.go b/mcp/sse_test.go index 8c3dfebc..23621931 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -53,7 +53,9 @@ func TestSSEServer(t *testing.T) { t.Fatal(err) } wantHi := &CallToolResult{ - Content: []*ContentBlock{{Type: "text", Text: "hi user"}}, + Content: []Content{ + &TextContent{Text: "hi user"}, + }, } if diff := cmp.Diff(wantHi, gotHi); diff != "" { t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 26aa064b..d496f83e 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -82,7 +82,9 @@ func TestStreamableTransports(t *testing.T) { // 5. Verify that the correct response is received. want := &CallToolResult{ - Content: []*ContentBlock{{Type: "text", Text: "hi streamy"}}, + Content: []Content{ + &TextContent{Text: "hi streamy"}, + }, } if diff := cmp.Diff(want, got); diff != "" { t.Errorf("CallTool() returned unexpected content (-want +got):\n%s", diff) diff --git a/mcp/testdata/conformance/server/prompts.txtar b/mcp/testdata/conformance/server/prompts.txtar index 0ef0cdc2..6168ce8e 100644 --- a/mcp/testdata/conformance/server/prompts.txtar +++ b/mcp/testdata/conformance/server/prompts.txtar @@ -24,7 +24,6 @@ code_review "jsonrpc": "2.0", "id": 1, "result": { - "_meta": {}, "capabilities": { "completions": {}, "logging": {}, @@ -49,7 +48,6 @@ code_review "jsonrpc": "2.0", "id": 2, "result": { - "_meta": {}, "tools": [] } } @@ -57,7 +55,6 @@ code_review "jsonrpc": "2.0", "id": 4, "result": { - "_meta": {}, "prompts": [ { "arguments": [ diff --git a/mcp/testdata/conformance/server/resources.txtar b/mcp/testdata/conformance/server/resources.txtar index 5d942ca3..3e7031ad 100644 --- a/mcp/testdata/conformance/server/resources.txtar +++ b/mcp/testdata/conformance/server/resources.txtar @@ -44,7 +44,6 @@ info.txt "jsonrpc": "2.0", "id": 1, "result": { - "_meta": {}, "capabilities": { "completions": {}, "logging": {}, @@ -69,7 +68,6 @@ info.txt "jsonrpc": "2.0", "id": 2, "result": { - "_meta": {}, "resources": [ { "mimeType": "text/plain", @@ -88,7 +86,6 @@ info.txt "jsonrpc": "2.0", "id": 3, "result": { - "_meta": {}, "contents": [ { "uri": "embedded:info", @@ -107,7 +104,6 @@ info.txt "jsonrpc": "2.0", "id": 3, "result": { - "_meta": {}, "contents": [ { "uri": "file:///info.txt", diff --git a/mcp/testdata/conformance/server/tools.txtar b/mcp/testdata/conformance/server/tools.txtar index 039e08d7..a43cd075 100644 --- a/mcp/testdata/conformance/server/tools.txtar +++ b/mcp/testdata/conformance/server/tools.txtar @@ -27,7 +27,6 @@ greet "jsonrpc": "2.0", "id": 1, "result": { - "_meta": {}, "capabilities": { "completions": {}, "logging": {}, @@ -52,7 +51,6 @@ greet "jsonrpc": "2.0", "id": 2, "result": { - "_meta": {}, "tools": [ { "description": "say hi", @@ -79,7 +77,6 @@ greet "jsonrpc": "2.0", "id": 3, "result": { - "_meta": {}, "resources": [] } } @@ -87,7 +84,6 @@ greet "jsonrpc": "2.0", "id": 4, "result": { - "_meta": {}, "prompts": [] } } diff --git a/mcp/testdata/conformance/server/version-latest.txtar b/mcp/testdata/conformance/server/version-latest.txtar index c81b5831..760bf8b7 100644 --- a/mcp/testdata/conformance/server/version-latest.txtar +++ b/mcp/testdata/conformance/server/version-latest.txtar @@ -17,7 +17,6 @@ response with its latest supported version. "jsonrpc": "2.0", "id": 1, "result": { - "_meta": {}, "capabilities": { "completions": {}, "logging": {}, diff --git a/mcp/testdata/conformance/server/version-older.txtar b/mcp/testdata/conformance/server/version-older.txtar index 215b22eb..97f7b79b 100644 --- a/mcp/testdata/conformance/server/version-older.txtar +++ b/mcp/testdata/conformance/server/version-older.txtar @@ -17,7 +17,6 @@ support. "jsonrpc": "2.0", "id": 1, "result": { - "_meta": {}, "capabilities": { "completions": {}, "logging": {}, diff --git a/mcp/tool.go b/mcp/tool.go index 1f6da53e..1e9c6e76 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -132,7 +132,7 @@ func newRawHandler(st *ServerTool) rawToolHandler { // rather than returned as jsonrpc2 server errors. if err != nil { return &CallToolResult{ - Content: []*ContentBlock{NewTextContent(err.Error())}, + Content: []Content{&TextContent{Text: err.Error()}}, IsError: true, }, nil }