Skip to content

Commit 90dec94

Browse files
committed
mcp,design: revert 'content' back to an interface type
After some experience with the flattened version of Content, we see that it can easily lead to incorrect usage and is a harder API to read. Therefore, this CL changes back to a design similar to what we had prior to https://go.dev/cl/672415, though opting to promote content unmarshalling to the protocol types that use it, which leads to an overall simpler api.
1 parent d6ea827 commit 90dec94

File tree

21 files changed

+240
-174
lines changed

21 files changed

+240
-174
lines changed

examples/hello/main.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ type HiArgs struct {
2424

2525
func SayHi(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[HiArgs]) (*mcp.CallToolResultFor[struct{}], error) {
2626
return &mcp.CallToolResultFor[struct{}]{
27-
Content: []*mcp.ContentBlock{
28-
mcp.NewTextContent("Hi " + params.Name),
27+
Content: []mcp.Content{
28+
&mcp.TextContent{Text: "Hi " + params.Name},
2929
},
3030
}, nil
3131
}
@@ -36,7 +36,7 @@ func PromptHi(ctx context.Context, ss *mcp.ServerSession, params *mcp.GetPromptP
3636
return &mcp.GetPromptResult{
3737
Description: "Code review prompt",
3838
Messages: []*mcp.PromptMessage{
39-
{Role: "user", Content: mcp.NewTextContent("Say hi to " + params.Arguments["name"])},
39+
{Role: "user", Content: &mcp.TextContent{Text: "Say hi to " + params.Arguments["name"]}},
4040
},
4141
}, nil
4242
}
@@ -93,6 +93,8 @@ func handleEmbeddedResource(_ context.Context, _ *mcp.ServerSession, params *mcp
9393
return nil, fmt.Errorf("no embedded resource named %q", key)
9494
}
9595
return &mcp.ReadResourceResult{
96-
Contents: []*mcp.ResourceContents{mcp.NewTextResourceContents(params.URI, "text/plain", text)},
96+
Contents: []*mcp.ResourceContents{
97+
{URI: params.URI, MIMEType: "text/plain", Text: text},
98+
},
9799
}, nil
98100
}

examples/sse/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ type SayHiParams struct {
2121

2222
func SayHi(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[SayHiParams]) (*mcp.CallToolResultFor[any], error) {
2323
return &mcp.CallToolResultFor[any]{
24-
Content: []*mcp.ContentBlock{
25-
mcp.NewTextContent("Hi " + params.Name),
24+
Content: []mcp.Content{
25+
&mcp.TextContent{Text: "Hi " + params.Name},
2626
},
2727
}, nil
2828
}

internal/readme/client/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func main() {
4040
log.Fatal("tool failed")
4141
}
4242
for _, c := range res.Content {
43-
log.Print(c.Text)
43+
log.Print(c.(*mcp.TextContent).Text)
4444
}
4545
}
4646

internal/readme/server/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type HiParams struct {
1818

1919
func SayHi(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[HiParams]) (*mcp.CallToolResultFor[any], error) {
2020
return &mcp.CallToolResultFor[any]{
21-
Content: []*mcp.ContentBlock{mcp.NewTextContent("Hi " + params.Name)},
21+
Content: []mcp.Content{&mcp.TextContent{Text: "Hi " + params.Name}},
2222
}, nil
2323
}
2424

mcp/cmd_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ func TestCmdTransport(t *testing.T) {
7070
log.Fatal(err)
7171
}
7272
want := &mcp.CallToolResult{
73-
Content: []*mcp.ContentBlock{{Type: "text", Text: "Hi user"}},
73+
Content: []mcp.Content{
74+
&mcp.TextContent{Text: "Hi user"},
75+
},
7476
}
7577
if diff := cmp.Diff(want, got); diff != "" {
7678
t.Errorf("greet returned unexpected content (-want +got):\n%s", diff)

mcp/content.go

Lines changed: 99 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -6,133 +6,129 @@ package mcp
66

77
import (
88
"encoding/json"
9-
"errors"
109
"fmt"
1110
)
1211

13-
// A ContentBlock is one of a TextContent, ImageContent, AudioContent
14-
// ResourceLink, or EmbeddedResource.
15-
// Use [NewTextContent], [NewImageContent], [NewAudioContent], [NewResourceLink]
16-
// or [NewResourceContents] to create one.
17-
//
18-
// The Type field must be one of "text", "image", "audio", "resource_link" or "resource".
19-
// The constructors above populate this field appropriately.
20-
// Although at most one of Text, Data, ResourceLink and Resource should be non-zero,
21-
// consumers of ContentBlock use the Type field to determine which value to use;
22-
// values in the other fields are ignored.
23-
// TODO(jba,rfindley): rethink this type. Each kind (text, image, etc.) should have its own
24-
// meta and annotations, otherwise they're duplicated for Resource and ResourceContents.
25-
type ContentBlock struct {
26-
Meta map[string]any `json:"_meta,omitempty"`
27-
Type string `json:"type"`
28-
Text string `json:"text,omitempty"`
29-
MIMEType string `json:"mimeType,omitempty"`
30-
Data []byte `json:"data,omitempty"`
31-
ResourceLink *Resource `json:"resource_link,omitempty"`
32-
Resource *ResourceContents `json:"resource,omitempty"`
33-
Annotations *Annotations `json:"annotations,omitempty"`
34-
}
35-
36-
func (c *ContentBlock) UnmarshalJSON(data []byte) error {
37-
type wireContent ContentBlock // for naive unmarshaling
38-
var c2 wireContent
39-
if err := json.Unmarshal(data, &c2); err != nil {
40-
return err
41-
}
42-
switch c2.Type {
43-
case "text", "image", "audio", "resource", "resource_link":
44-
default:
45-
return fmt.Errorf("unrecognized content type %s", c.Type)
46-
}
47-
*c = ContentBlock(c2)
48-
return nil
12+
// A Content is a [TextContent], [ImageContent], [AudioContent] or
13+
// [EmbeddedResource].
14+
type Content interface {
15+
MarshalJSON() ([]byte, error)
16+
fromWire(*wireContent)
17+
}
18+
19+
// TextContent is a textual content.
20+
type TextContent struct {
21+
Text string
22+
}
23+
24+
func (c *TextContent) MarshalJSON() ([]byte, error) {
25+
return json.Marshal(&wireContent{Type: "text", Text: c.Text})
4926
}
5027

51-
// NewTextContent creates a [ContentBlock] with text.
52-
func NewTextContent(text string) *ContentBlock {
53-
return &ContentBlock{Type: "text", Text: text}
28+
func (c *TextContent) fromWire(wire *wireContent) {
29+
c.Text = wire.Text
5430
}
5531

56-
// NewImageContent creates a [ContentBlock] with image data.
57-
func NewImageContent(data []byte, mimeType string) *ContentBlock {
58-
return &ContentBlock{Type: "image", Data: data, MIMEType: mimeType}
32+
// ImageContent contains base64-encoded image data.
33+
type ImageContent struct {
34+
Data []byte // base64-encoded
35+
MIMEType string
5936
}
6037

61-
// NewAudioContent creates a [ContentBlock] with audio data.
62-
func NewAudioContent(data []byte, mimeType string) *ContentBlock {
63-
return &ContentBlock{Type: "audio", Data: data, MIMEType: mimeType}
38+
func (c *ImageContent) MarshalJSON() ([]byte, error) {
39+
return json.Marshal(&wireContent{Type: "image", MIMEType: c.MIMEType, Data: c.Data})
6440
}
6541

66-
// NewResourceLink creates a [ContentBlock] with a [Resource].
67-
func NewResourceLink(r *Resource) *ContentBlock {
68-
return &ContentBlock{Type: "resource_link", ResourceLink: r}
42+
func (c *ImageContent) fromWire(wire *wireContent) {
43+
c.MIMEType = wire.MIMEType
44+
c.Data = wire.Data
6945
}
7046

71-
// NewResourceContents creates a [ContentBlock] with an embedded resource (a [ResourceContents]).
72-
func NewResourceContents(rc *ResourceContents) *ContentBlock {
73-
return &ContentBlock{Type: "resource", Resource: rc}
47+
// AudioContent contains base64-encoded audio data.
48+
type AudioContent struct {
49+
Data []byte
50+
MIMEType string
7451
}
7552

76-
// ResourceContents represents the union of the spec's {Text,Blob}ResourceContents types.
77-
// See https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-03-26/schema.ts#L524-L551
78-
// for the inheritance structure.
53+
func (c AudioContent) MarshalJSON() ([]byte, error) {
54+
return json.Marshal(&wireContent{Type: "audio", MIMEType: c.MIMEType, Data: c.Data})
55+
}
7956

80-
// A ResourceContents is either a TextResourceContents or a BlobResourceContents.
81-
// Use [NewTextResourceContents] or [NextBlobResourceContents] to create one.
57+
func (c *AudioContent) fromWire(wire *wireContent) {
58+
c.MIMEType = wire.MIMEType
59+
c.Data = wire.Data
60+
}
61+
62+
// EmbeddedResource contains embedded resources.
63+
type EmbeddedResource struct {
64+
Resource *ResourceContents
65+
}
66+
67+
func (r *EmbeddedResource) MarshalJSON() ([]byte, error) {
68+
return json.Marshal(&wireContent{Type: "resource", Resource: r.Resource})
69+
}
70+
71+
func (c *EmbeddedResource) fromWire(wire *wireContent) {
72+
c.Resource = wire.Resource
73+
}
74+
75+
// ResourceContents contains the contents of a specific resource or
76+
// sub-resource.
8277
type ResourceContents struct {
83-
Meta map[string]any `json:"_meta,omitempty"`
84-
URI string `json:"uri"` // resource location; must not be empty
85-
MIMEType string `json:"mimeType,omitempty"`
86-
Text string `json:"text"`
87-
Blob []byte `json:"blob,omitempty"` // if nil, then text; else blob
78+
URI string `json:"uri,"`
79+
MIMEType string `json:"mimeType,omitempty"`
80+
Text string `json:"text,omitempty"`
81+
Blob []byte `json:"blob,omitempty"`
8882
}
8983

90-
func (r ResourceContents) MarshalJSON() ([]byte, error) {
91-
// If we could assume Go 1.24, we could use omitzero for Blob and avoid this method.
92-
if r.URI == "" {
93-
return nil, errors.New("ResourceContents missing URI")
94-
}
95-
if r.Blob == nil {
96-
// Text. Marshal normally.
97-
type wireResourceContents ResourceContents // (lacks MarshalJSON method)
98-
return json.Marshal((wireResourceContents)(r))
99-
}
100-
// Blob.
101-
if r.Text != "" {
102-
return nil, errors.New("ResourceContents has non-zero Text and Blob fields")
103-
}
104-
// r.Blob may be the empty slice, so marshal with an alternative definition.
105-
br := struct {
106-
URI string `json:"uri,omitempty"`
107-
MIMEType string `json:"mimeType,omitempty"`
108-
Blob []byte `json:"blob"`
109-
}{
110-
URI: r.URI,
111-
MIMEType: r.MIMEType,
112-
Blob: r.Blob,
113-
}
114-
return json.Marshal(br)
84+
// wireContent is the wire format for content.
85+
// It represents the protocol types TextContent, ImageContent, AudioContent
86+
// and EmbeddedResource.
87+
// The Type field distinguishes them. In the protocol, each type has a constant
88+
// value for the field.
89+
// At most one of Text, Data, and Resource is non-zero.
90+
type wireContent struct {
91+
Type string `json:"type"`
92+
Text string `json:"text,omitempty"`
93+
MIMEType string `json:"mimeType,omitempty"`
94+
Data []byte `json:"data,omitempty"`
95+
Resource *ResourceContents `json:"resource,omitempty"`
96+
Annotations *Annotations `json:"annotations,omitempty"`
11597
}
11698

117-
// NewTextResourceContents returns a [ResourceContents] containing text.
118-
func NewTextResourceContents(uri, mimeType, text string) *ResourceContents {
119-
return &ResourceContents{
120-
URI: uri,
121-
MIMEType: mimeType,
122-
Text: text,
123-
// Blob is nil, indicating this is a TextResourceContents.
99+
func contentsFromWire(wires []*wireContent, allow map[string]bool) ([]Content, error) {
100+
var blocks []Content
101+
for _, wire := range wires {
102+
block, err := contentFromWire(wire, allow)
103+
if err != nil {
104+
return nil, err
105+
}
106+
blocks = append(blocks, block)
124107
}
108+
return blocks, nil
125109
}
126110

127-
// NewBlobResourceContents returns a [ResourceContents] containing a byte slice.
128-
func NewBlobResourceContents(uri, mimeType string, blob []byte) *ResourceContents {
129-
// The only way to distinguish text from blob is a non-nil Blob field.
130-
if blob == nil {
131-
blob = []byte{}
111+
func contentFromWire(wire *wireContent, allow map[string]bool) (Content, error) {
112+
if allow != nil && !allow[wire.Type] {
113+
return nil, fmt.Errorf("invalid content type %q", wire.Type)
132114
}
133-
return &ResourceContents{
134-
URI: uri,
135-
MIMEType: mimeType,
136-
Blob: blob,
115+
switch wire.Type {
116+
case "text":
117+
v := new(TextContent)
118+
v.fromWire(wire)
119+
return v, nil
120+
case "image":
121+
v := new(ImageContent)
122+
v.fromWire(wire)
123+
return v, nil
124+
case "audio":
125+
v := new(AudioContent)
126+
v.fromWire(wire)
127+
return v, nil
128+
case "resource":
129+
v := new(EmbeddedResource)
130+
v.fromWire(wire)
131+
return v, nil
137132
}
133+
return nil, fmt.Errorf("internal error: unrecognized content type %s", wire.Type)
138134
}

0 commit comments

Comments
 (0)