Skip to content

Commit 6543b59

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 6543b59

File tree

22 files changed

+333
-159
lines changed

22 files changed

+333
-159
lines changed

design/design.md

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ These types will be included in the `mcp` package, but will be unexported unless
269269
270270
For user-provided data, we use `json.RawMessage` or `map[string]any`, depending on the use case.
271271
272-
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.
272+
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.
273273
274274
For brevity, only a few examples are shown here:
275275
@@ -284,20 +284,16 @@ type CallToolResult struct {
284284
IsError bool `json:"isError,omitempty"`
285285
}
286286

287-
// Content is the wire format for content.
288-
//
289-
// The Type field distinguishes the type of the content.
290-
// At most one of Text, MIMEType, Data, and Resource is non-zero.
291-
type Content struct {
292-
Type string `json:"type"`
293-
Text string `json:"text,omitempty"`
294-
MIMEType string `json:"mimeType,omitempty"`
295-
Data []byte `json:"data,omitempty"`
296-
Resource *ResourceContents `json:"resource,omitempty"`
287+
// A Content is a [TextContent], [ImageContent], [AudioContent] or
288+
// [EmbeddedResource].
289+
type Content interface {
290+
// (unexported methods)
297291
}
298292

299-
// NewTextContent creates a [Content] with text.
300-
func NewTextContent(text string) *Content
293+
// TextContent is a textual content.
294+
type TextContent struct {
295+
Text string
296+
}
301297
// etc.
302298
```
303299

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: 142 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -10,81 +10,117 @@ import (
1010
"fmt"
1111
)
1212

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.
13+
// A Content is a [TextContent], [ImageContent], [AudioContent] or
14+
// [EmbeddedResource].
1715
//
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
16+
// TODO(rfindley): add ResourceLink.
17+
type Content interface {
18+
MarshalJSON() ([]byte, error)
19+
fromWire(*wireContent)
4920
}
5021

51-
// NewTextContent creates a [ContentBlock] with text.
52-
func NewTextContent(text string) *ContentBlock {
53-
return &ContentBlock{Type: "text", Text: text}
22+
// TextContent is a textual content.
23+
type TextContent struct {
24+
Text string
25+
Meta Meta
26+
Annotations *Annotations
5427
}
5528

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}
29+
func (c *TextContent) MarshalJSON() ([]byte, error) {
30+
return json.Marshal(&wireContent{
31+
Type: "text",
32+
Text: c.Text,
33+
Meta: c.Meta,
34+
Annotations: c.Annotations,
35+
})
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 *TextContent) fromWire(wire *wireContent) {
39+
c.Text = wire.Text
40+
c.Meta = wire.Meta
41+
c.Annotations = wire.Annotations
6442
}
6543

66-
// NewResourceLink creates a [ContentBlock] with a [Resource].
67-
func NewResourceLink(r *Resource) *ContentBlock {
68-
return &ContentBlock{Type: "resource_link", ResourceLink: r}
44+
// ImageContent contains base64-encoded image data.
45+
type ImageContent struct {
46+
Meta Meta
47+
Annotations *Annotations
48+
Data []byte // base64-encoded
49+
MIMEType string
6950
}
7051

71-
// NewResourceContents creates a [ContentBlock] with an embedded resource (a [ResourceContents]).
72-
func NewResourceContents(rc *ResourceContents) *ContentBlock {
73-
return &ContentBlock{Type: "resource", Resource: rc}
52+
func (c *ImageContent) MarshalJSON() ([]byte, error) {
53+
return json.Marshal(&wireContent{
54+
Type: "image",
55+
MIMEType: c.MIMEType,
56+
Data: c.Data,
57+
Meta: c.Meta,
58+
Annotations: c.Annotations,
59+
})
7460
}
7561

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.
62+
func (c *ImageContent) fromWire(wire *wireContent) {
63+
c.MIMEType = wire.MIMEType
64+
c.Data = wire.Data
65+
c.Meta = wire.Meta
66+
c.Annotations = wire.Annotations
67+
}
68+
69+
// AudioContent contains base64-encoded audio data.
70+
type AudioContent struct {
71+
Data []byte
72+
MIMEType string
73+
Meta Meta
74+
Annotations *Annotations
75+
}
76+
77+
func (c AudioContent) MarshalJSON() ([]byte, error) {
78+
return json.Marshal(&wireContent{
79+
Type: "audio",
80+
MIMEType: c.MIMEType,
81+
Data: c.Data,
82+
Meta: c.Meta,
83+
Annotations: c.Annotations,
84+
})
85+
}
86+
87+
func (c *AudioContent) fromWire(wire *wireContent) {
88+
c.MIMEType = wire.MIMEType
89+
c.Data = wire.Data
90+
c.Meta = wire.Meta
91+
c.Annotations = wire.Annotations
92+
}
7993

80-
// A ResourceContents is either a TextResourceContents or a BlobResourceContents.
81-
// Use [NewTextResourceContents] or [NextBlobResourceContents] to create one.
94+
// EmbeddedResource contains embedded resources.
95+
type EmbeddedResource struct {
96+
Resource *ResourceContents
97+
Meta Meta
98+
Annotations *Annotations
99+
}
100+
101+
func (c *EmbeddedResource) MarshalJSON() ([]byte, error) {
102+
return json.Marshal(&wireContent{
103+
Type: "resource",
104+
Resource: c.Resource,
105+
Meta: c.Meta,
106+
Annotations: c.Annotations,
107+
})
108+
}
109+
110+
func (c *EmbeddedResource) fromWire(wire *wireContent) {
111+
c.Resource = wire.Resource
112+
c.Meta = wire.Meta
113+
c.Annotations = wire.Annotations
114+
}
115+
116+
// ResourceContents contains the contents of a specific resource or
117+
// sub-resource.
82118
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
119+
URI string `json:"uri"`
120+
MIMEType string `json:"mimeType,omitempty"`
121+
Text string `json:"text,omitempty"`
122+
Blob []byte `json:"blob,omitempty"`
123+
Meta Meta `json:"_meta,omitempty"`
88124
}
89125

90126
func (r ResourceContents) MarshalJSON() ([]byte, error) {
@@ -114,25 +150,55 @@ func (r ResourceContents) MarshalJSON() ([]byte, error) {
114150
return json.Marshal(br)
115151
}
116152

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.
153+
// wireContent is the wire format for content.
154+
// It represents the protocol types TextContent, ImageContent, AudioContent
155+
// and EmbeddedResource.
156+
// The Type field distinguishes them. In the protocol, each type has a constant
157+
// value for the field.
158+
// At most one of Text, Data, and Resource is non-zero.
159+
type wireContent struct {
160+
Type string `json:"type"`
161+
Text string `json:"text,omitempty"`
162+
MIMEType string `json:"mimeType,omitempty"`
163+
Data []byte `json:"data,omitempty"`
164+
Resource *ResourceContents `json:"resource,omitempty"`
165+
Meta Meta `json:"_meta,omitempty"`
166+
Annotations *Annotations `json:"annotations,omitempty"`
167+
}
168+
169+
func contentsFromWire(wires []*wireContent, allow map[string]bool) ([]Content, error) {
170+
var blocks []Content
171+
for _, wire := range wires {
172+
block, err := contentFromWire(wire, allow)
173+
if err != nil {
174+
return nil, err
175+
}
176+
blocks = append(blocks, block)
124177
}
178+
return blocks, nil
125179
}
126180

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{}
181+
func contentFromWire(wire *wireContent, allow map[string]bool) (Content, error) {
182+
if allow != nil && !allow[wire.Type] {
183+
return nil, fmt.Errorf("invalid content type %q", wire.Type)
132184
}
133-
return &ResourceContents{
134-
URI: uri,
135-
MIMEType: mimeType,
136-
Blob: blob,
185+
switch wire.Type {
186+
case "text":
187+
v := new(TextContent)
188+
v.fromWire(wire)
189+
return v, nil
190+
case "image":
191+
v := new(ImageContent)
192+
v.fromWire(wire)
193+
return v, nil
194+
case "audio":
195+
v := new(AudioContent)
196+
v.fromWire(wire)
197+
return v, nil
198+
case "resource":
199+
v := new(EmbeddedResource)
200+
v.fromWire(wire)
201+
return v, nil
137202
}
203+
return nil, fmt.Errorf("internal error: unrecognized content type %s", wire.Type)
138204
}

0 commit comments

Comments
 (0)