internal/mcp: simplify content

Now that the protocol package is merged into the mcp package, clean up
the higher level 'Content' union types, in favor of types constructors.

Change-Id: I37bedb0ff0a296eef799ba65c37ca7f94ee1dbbe
Reviewed-on: https://go-review.googlesource.com/c/tools/+/672415
Reviewed-by: Jonathan Amsterdam <jba@google.com>
TryBot-Bypass: Jonathan Amsterdam <jba@google.com>
This commit is contained in:
Rob Findley 2025-05-13 21:51:26 +00:00 • committed by Jonathan Amsterdam
commit dac5be2c50
15 changed files with 107 additions and 207 deletions

View file

@ -123,6 +123,7 @@ type Stream interface {
Close() error
}
```
Methods accept a Go `Context` and return an `error`,
as is idiomatic for APIs that do I/O.
@ -350,17 +351,6 @@ type Content struct {
Data []byte `json:"data,omitempty"`
Resource *Resource `json:"resource,omitempty"`
}
// Resource is the wire format for embedded resources.
//
// The URI field describes the resource location. At most one of Text and Blob
// is non-zero.
type Resource struct {
URI string `json:"uri,"`
MIMEType string `json:"mimeType,omitempty"`
Text string `json:"text"`
Blob []byte `json:"blob"`
}
```
**Differences from mcp-go**: these types are largely similar, but our type
@ -445,6 +435,7 @@ content, err := session.CallTool(ctx, &CallToolParams{
...
return session.Close()
```
A server that can handle that client call would look like this:
```go
@ -465,8 +456,6 @@ session until the client disconnects:
func (*Server) Run(context.Context, Transport)
```
**Differences from mcp-go**: the Server APIs are very similar to mcp-go,
though the association between servers and transports is different. In
mcp-go, a single server is bound to what we would call an `SSEHTTPHandler`,
@ -493,9 +482,11 @@ documentation.
As we saw above, the `ClientSession` method for the specification's
`CallTool` RPC takes a context and a params pointer as arguments, and returns a
result pointer and error:
```go
func (*ClientSession) CallTool(context.Context, *CallToolParams) (*CallToolResult, error)
```
Our SDK has a method for every RPC in the spec, and their signatures all share
this form. To avoid boilerplate, we don't repeat this signature for RPCs
defined in the spec; readers may assume it when we mention a "spec method."
@ -867,12 +858,14 @@ handler to a Go function using reflection to derive its arguments. We provide
To add a resource or resource template to a server, users call the `AddResource` and
`AddResourceTemplate` methods, passing the resource or template and a function for reading it:
```go
type ReadResourceHandler func(context.Context, *ServerSession, *Resource, *ReadResourceParams) (*ReadResourceResult, error)
func (*Server) AddResource(*Resource, ReadResourceHandler)
func (*Server) AddResourceTemplate(*ResourceTemplate, ReadResourceHandler)
```
The `Resource` is passed to the reader function even though it is redundant (the function could have closed over it)
so a single handler can support multiple resources.
If the incoming resource matches a template, a `Resource` argument is constructed
@ -880,15 +873,18 @@ from the fields in the `ResourceTemplate`.
The `ServerSession` argument is there so the reader can observe the client's roots.
To read files from the local filesystem, we recommend using `FileReadResourceHandler` to construct a handler:
```go
// FileReadResourceHandler returns a ReadResourceHandler that reads paths using dir as a root directory.
// It protects against path traversal attacks.
// It will not read any file that is not in the root set of the client requesting the resource.
func (*Server) FileReadResourceHandler(dir string) ReadResourceHandler
```
It guards against [path traversal attacks](https://go.dev/blog/osroot)
and observes the client's roots.
Here is an example:
```go
// Safely read "/public/puppies.txt".
s.AddResource(
@ -897,14 +893,17 @@ s.AddResource(
```
There are also server methods to remove resources and resource templates.
```go
func (*Server) RemoveResources(uris ...string)
func (*Server) RemoveResourceTemplates(names ...string)
```
Resource templates don't have unique identifiers, so removing a name will remove all
resource templates with that name.
Servers support all of the resource-related spec methods:
- `ListResources` and `ListResourceTemplates` for listings.
- `ReadResource` to get the contents of a resource.
- `Subscribe` and `Unsubscribe` to manage subscriptions on resources.
@ -916,6 +915,7 @@ then returns the result of calling the associated reader function.
#### Subscriptions
ClientSessions can manage change notifications on particular resources:
```go
func (*ClientSession) Subscribe(context.Context, *SubscribeParams) error
func (*ClientSession) Unsubscribe(context.Context, *UnsubscribeParams) error
@ -929,6 +929,7 @@ user doesn't have to.
If a server author wants to support resource subscriptions, they must provide handlers
to be called when clients subscribe and unsubscribe. It is an error to provide only
one of these handlers.
```go
type ServerOptions struct {
...
@ -940,9 +941,11 @@ type ServerOptions struct {
```
User code should call `ResourceUpdated` when a subscribed resource changes.
```go
func (*Server) ResourceUpdated(context.Context, *ResourceUpdatedNotification) error
```
The server routes these notifications to the server sessions that subscribed to the resource.
### ListChanged notifications
@ -1007,6 +1010,7 @@ follows:
level, a handler would call `session.Log(ctx, mcp.LevelNotice, "message")`.
A client that wishes to receive log messages must provide a handler:
```go
type ClientOptions struct {
...

View file

@ -22,7 +22,7 @@ type HiParams struct {
func SayHi(ctx context.Context, cc *mcp.ServerConnection, params *HiParams) ([]mcp.Content, error) {
return []mcp.Content{
mcp.TextContent{Text: "Hi " + params.Name},
mcp.NewTextContent("Hi " + params.Name),
}, nil
}
@ -30,7 +30,7 @@ func PromptHi(ctx context.Context, cc *mcp.ServerConnection, params *HiParams) (
return &mcp.GetPromptResult{
Description: "Code review prompt",
Messages: []mcp.PromptMessage{
{Role: "user", Content: mcp.TextContent{Text: "Say hi to " + params.Name}.ToWire()},
{Role: "user", Content: mcp.NewTextContent("Say hi to " + params.Name)},
},
}, nil
}

View file

@ -21,7 +21,7 @@ type SayHiParams struct {
func SayHi(ctx context.Context, cc *mcp.ServerConnection, params *SayHiParams) ([]mcp.Content, error) {
return []mcp.Content{
mcp.TextContent{Text: "Hi " + params.Name},
mcp.NewTextContent("Hi " + params.Name),
}, nil
}

View file

@ -10,15 +10,6 @@ import (
"slices"
)
// Apply returns a new slice resulting from applying f to each element of x.
func Apply[S ~[]E, E, F any](x S, f func(E) F) []F {
y := make([]F, len(x))
for i, e := range x {
y[i] = f(e)
}
return y
}
// Helpers below are copied from gopls' moremaps package.
// sorted returns an iterator over the entries of m in key order.

View file

@ -57,7 +57,7 @@ func TestCmdTransport(t *testing.T) {
log.Fatal(err)
}
want := &mcp.CallToolResult{
Content: []mcp.WireContent{{Type: "text", Text: "Hi user"}},
Content: []mcp.Content{{Type: "text", Text: "Hi user"}},
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("greet returned unexpected content (-want +got):\n%s", diff)

View file

@ -11,37 +11,25 @@ import (
// The []byte fields below are marked omitzero, not omitempty:
// we want to marshal an empty byte slice.
// TODO(jba): figure out how to fix this for 1.23.
// WireContent is the wire format for content.
// Content 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,omitzero"`
Resource *WireResource `json:"resource,omitempty"`
Annotations *Annotations `json:"annotations,omitempty"`
type Content struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
MIMEType string `json:"mimeType,omitempty"`
Data []byte `json:"data,omitzero"`
Resource *ResourceContents `json:"resource,omitempty"`
Annotations *Annotations `json:"annotations,omitempty"`
}
// A WireResource is either a TextResourceContents or a BlobResourceContents.
// See https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-03-26/schema.ts#L524-L551
// for the inheritance structure.
// If Blob is nil, this is a TextResourceContents; otherwise it's a BlobResourceContents.
//
// The URI field describes the resource location.
type WireResource struct {
URI string `json:"uri,"`
MIMEType string `json:"mimeType,omitempty"`
Text string `json:"text"`
Blob []byte `json:"blob,omitzero"`
}
func (c *WireContent) UnmarshalJSON(data []byte) error {
type wireContent WireContent // for naive unmarshaling
func (c *Content) UnmarshalJSON(data []byte) error {
type wireContent Content // for naive unmarshaling
var c2 wireContent
if err := json.Unmarshal(data, &c2); err != nil {
return err
@ -51,77 +39,44 @@ func (c *WireContent) UnmarshalJSON(data []byte) error {
default:
return fmt.Errorf("unrecognized content type %s", c.Type)
}
*c = WireContent(c2)
*c = Content(c2)
return nil
}
// Content is the union of supported content types: [TextContent],
// [ImageContent], [AudioContent], and [ResourceContent].
func NewTextContent(text string) Content {
return Content{Type: "text", Text: text}
}
func NewImageContent(data []byte, mimeType string) Content {
return Content{Type: "image", Data: data, MIMEType: mimeType}
}
func NewAudioContent(data []byte, mimeType string) Content {
return Content{Type: "audio", Data: data, MIMEType: mimeType}
}
func NewResourceContent(resource ResourceContents) Content {
return Content{Type: "resource", Resource: &resource}
}
// A ResourceContents is either a TextResourceContents or a BlobResourceContents.
// See https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-03-26/schema.ts#L524-L551
// for the inheritance structure.
// If Blob is nil, this is a TextResourceContents; otherwise it's a BlobResourceContents.
//
// ToWire converts content to its jsonrpc2 wire format.
type Content interface {
// TODO: unexport this, and move the tests that use it to this package.
ToWire() WireContent
// The URI field describes the resource location.
type ResourceContents struct {
URI string `json:"uri,"`
MIMEType string `json:"mimeType,omitempty"`
Text string `json:"text,omitempty"`
Blob []byte `json:"blob,omitzero"`
}
// TextContent is a textual content.
type TextContent struct {
Text string
}
func (c TextContent) ToWire() WireContent {
return WireContent{Type: "text", Text: c.Text}
}
// ImageContent contains base64-encoded image data.
type ImageContent struct {
Data []byte // base64-encoded
MIMEType string
}
func (c ImageContent) ToWire() WireContent {
return WireContent{Type: "image", MIMEType: c.MIMEType, Data: c.Data}
}
// AudioContent contains base64-encoded audio data.
type AudioContent struct {
Data []byte
MIMEType string
}
func (c AudioContent) ToWire() WireContent {
return WireContent{Type: "audio", MIMEType: c.MIMEType, Data: c.Data}
}
// ResourceContent contains embedded resources.
type ResourceContent struct {
Resource EmbeddedResource
}
func (r ResourceContent) ToWire() WireContent {
res := r.Resource.toWire()
return WireContent{Type: "resource", Resource: &res}
}
type EmbeddedResource interface {
toWire() WireResource
}
// The {Text,Blob}ResourceContents types match the protocol definitions,
// but we represent both as a single type on the wire.
// A TextResourceContents is the contents of a text resource.
type TextResourceContents struct {
URI string
MIMEType string
Text string
}
func (r TextResourceContents) toWire() WireResource {
return WireResource{
URI: r.URI,
MIMEType: r.MIMEType,
Text: r.Text,
func NewTextResourceContents(uri, mimeType, text string) ResourceContents {
return ResourceContents{
URI: uri,
MIMEType: mimeType,
Text: text,
// Blob is nil, indicating this is a TextResourceContents.
}
}
@ -133,43 +88,10 @@ type BlobResourceContents struct {
Blob []byte
}
func (r BlobResourceContents) toWire() WireResource {
return WireResource{
URI: r.URI,
MIMEType: r.MIMEType,
Blob: r.Blob,
}
}
// ContentFromWireContent converts content from the jsonrpc2 wire format to a
// typed Content value.
func ContentFromWireContent(c WireContent) Content {
switch c.Type {
case "text":
return TextContent{Text: c.Text}
case "image":
return ImageContent{Data: c.Data, MIMEType: c.MIMEType}
case "audio":
return AudioContent{Data: c.Data, MIMEType: c.MIMEType}
case "resource":
r := ResourceContent{}
if c.Resource != nil {
if c.Resource.Blob != nil {
r.Resource = BlobResourceContents{
URI: c.Resource.URI,
MIMEType: c.Resource.MIMEType,
Blob: c.Resource.Blob,
}
} else {
r.Resource = TextResourceContents{
URI: c.Resource.URI,
MIMEType: c.Resource.MIMEType,
Text: c.Resource.Text,
}
}
}
return r
default:
panic(fmt.Sprintf("unrecognized wire content type %q", c.Type))
func NewBlobResourceContents(uri, mimeType string, blob []byte) ResourceContents {
return ResourceContents{
URI: uri,
MIMEType: mimeType,
Blob: blob,
}
}

View file

@ -5,6 +5,7 @@
package mcp_test
import (
"encoding/json"
"testing"
"github.com/google/go-cmp/cmp"
@ -14,57 +15,45 @@ import (
func TestContent(t *testing.T) {
tests := []struct {
in mcp.Content
want mcp.WireContent
want string // json serialization
}{
{mcp.TextContent{Text: "hello"}, mcp.WireContent{Type: "text", Text: "hello"}},
{mcp.NewTextContent("hello"), `{"type":"text","text":"hello"}`},
{
mcp.ImageContent{Data: []byte("a1b2c3"), MIMEType: "image/png"},
mcp.WireContent{Type: "image", Data: []byte("a1b2c3"), MIMEType: "image/png"},
mcp.NewImageContent([]byte("a1b2c3"), "image/png"),
`{"type":"image","mimeType":"image/png","data":"YTFiMmMz"}`,
},
{
mcp.AudioContent{Data: []byte("a1b2c3"), MIMEType: "audio/wav"},
mcp.WireContent{Type: "audio", Data: []byte("a1b2c3"), MIMEType: "audio/wav"},
mcp.NewAudioContent([]byte("a1b2c3"), "audio/wav"),
`{"type":"audio","mimeType":"audio/wav","data":"YTFiMmMz"}`,
},
{
mcp.ResourceContent{
Resource: mcp.TextResourceContents{
URI: "file://foo",
MIMEType: "text",
Text: "abc",
},
},
mcp.WireContent{
Type: "resource",
Resource: &mcp.WireResource{
URI: "file://foo",
MIMEType: "text",
Text: "abc",
},
},
mcp.NewResourceContent(
mcp.NewTextResourceContents("file://foo", "text", "abc"),
),
`{"type":"resource","resource":{"uri":"file://foo","mimeType":"text","text":"abc"}}`,
},
{
mcp.ResourceContent{
Resource: mcp.BlobResourceContents{
URI: "file://foo",
MIMEType: "text",
Blob: []byte("a1b2c3"),
},
},
mcp.WireContent{
Type: "resource",
Resource: &mcp.WireResource{
URI: "file://foo",
MIMEType: "text",
Blob: []byte("a1b2c3"),
},
},
mcp.NewResourceContent(
mcp.NewBlobResourceContents("file://foo", "image/png", []byte("a1b2c3")),
),
`{"type":"resource","resource":{"uri":"file://foo","mimeType":"image/png","blob":"YTFiMmMz"}}`,
},
}
for _, test := range tests {
got := test.in.ToWire()
if diff := cmp.Diff(test.want, got); diff != "" {
t.Errorf("ToWire mismatch (-want +got):\n%s", diff)
got, err := json.Marshal(test.in)
if err != nil {
t.Fatal(err)
}
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.Content
if err := json.Unmarshal(got, &out); err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(test.in, out); diff != "" {
t.Errorf("json.Unmarshal(%q) mismatch (-want +got):\n%s", string(got), diff)
}
}
}

View file

@ -107,7 +107,7 @@ var declarations = config{
Fields: config{"Params": {Name: "ReadResourceParams"}},
},
"ReadResourceResult": {
Fields: config{"Contents": {Substitute: "*WireResource"}},
Fields: config{"Contents": {Substitute: "*ResourceContents"}},
},
"Resource": {},
"Role": {},
@ -288,7 +288,7 @@ func writeType(w io.Writer, config *typeConfig, def *jsonschema.Schema, named ma
if slices.ContainsFunc(def.AnyOf, func(s *jsonschema.Schema) bool {
return s.Ref == "#/definitions/TextContent"
}) {
fmt.Fprintf(w, "WireContent")
fmt.Fprintf(w, "Content")
} else {
// E.g. union types.
fmt.Fprintf(w, "json.RawMessage")

View file

@ -28,7 +28,7 @@ func sayHi(ctx context.Context, cc *ServerConnection, v hiParams) ([]Content, er
if err := cc.Ping(ctx); err != nil {
return nil, fmt.Errorf("ping failed: %v", err)
}
return []Content{TextContent{Text: "hi " + v.Name}}, nil
return []Content{NewTextContent("hi " + v.Name)}, nil
}
func TestEndToEnd(t *testing.T) {
@ -53,7 +53,7 @@ func TestEndToEnd(t *testing.T) {
return &GetPromptResult{
Description: "Code review prompt",
Messages: []PromptMessage{
{Role: "user", Content: TextContent{Text: "Please review the following code: " + params.Code}.ToWire()},
{Role: "user", Content: NewTextContent("Please review the following code: " + params.Code)},
},
}, nil
}),
@ -116,7 +116,7 @@ func TestEndToEnd(t *testing.T) {
wantReview := &GetPromptResult{
Description: "Code review prompt",
Messages: []PromptMessage{{
Content: TextContent{Text: "Please review the following code: 1+1"}.ToWire(),
Content: NewTextContent("Please review the following code: 1+1"),
Role: "user",
}},
}
@ -165,7 +165,7 @@ func TestEndToEnd(t *testing.T) {
t.Fatal(err)
}
wantHi := &CallToolResult{
Content: []WireContent{{Type: "text", Text: "hi user"}},
Content: []Content{{Type: "text", Text: "hi user"}},
}
if diff := cmp.Diff(wantHi, gotHi); diff != "" {
t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff)
@ -179,7 +179,7 @@ func TestEndToEnd(t *testing.T) {
}
wantFail := &CallToolResult{
IsError: true,
Content: []WireContent{{Type: "text", Text: failure.Error()}},
Content: []Content{{Type: "text", Text: failure.Error()}},
}
if diff := cmp.Diff(wantFail, gotFail); diff != "" {
t.Errorf("tools/call 'fail' mismatch (-want +got):\n%s", diff)
@ -201,7 +201,7 @@ func TestEndToEnd(t *testing.T) {
readHandler := func(_ context.Context, r Resource, _ *ReadResourceParams) (*ReadResourceResult, error) {
if r.URI == "file:///file1.txt" {
return &ReadResourceResult{
Contents: &WireResource{
Contents: &ResourceContents{
Text: "file contents",
},
}, nil

View file

@ -47,7 +47,7 @@ type CallToolResult struct {
// This result property is reserved by the protocol to allow clients and servers
// to attach additional metadata to their responses.
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
Content []WireContent `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).
@ -195,8 +195,8 @@ type PromptArgument struct {
// This is similar to `SamplingMessage`, but also supports the embedding of
// resources from the MCP server.
type PromptMessage struct {
Content WireContent `json:"content"`
Role Role `json:"role"`
Content Content `json:"content"`
Role Role `json:"role"`
}
type ReadResourceParams struct {
@ -210,7 +210,7 @@ type ReadResourceResult struct {
// This result property is reserved by the protocol to allow clients and servers
// to attach additional metadata to their responses.
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
Contents *WireResource `json:"contents"`
Contents *ResourceContents `json:"contents"`
}
// A known resource that the server is capable of reading.

View file

@ -18,7 +18,7 @@ type SayHiParams struct {
func SayHi(ctx context.Context, cc *mcp.ServerConnection, params *SayHiParams) ([]mcp.Content, error) {
return []mcp.Content{
mcp.TextContent{Text: "Hi " + params.Name},
mcp.NewTextContent("Hi " + params.Name),
}, nil
}

View file

@ -20,7 +20,7 @@ type AddParams struct {
func Add(ctx context.Context, cc *mcp.ServerConnection, params *AddParams) ([]mcp.Content, error) {
return []mcp.Content{
mcp.TextContent{Text: fmt.Sprintf("%d", params.X+params.Y)},
mcp.NewTextContent(fmt.Sprintf("%d", params.X+params.Y)),
}, nil
}

View file

@ -48,7 +48,7 @@ func TestSSEServer(t *testing.T) {
t.Fatal(err)
}
wantHi := &CallToolResult{
Content: []WireContent{{Type: "text", Text: "hi user"}},
Content: []Content{{Type: "text", Text: "hi user"}},
}
if diff := cmp.Diff(wantHi, gotHi); diff != "" {
t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff)

View file

@ -9,7 +9,6 @@ import (
"encoding/json"
"slices"
"github.com/modelcontextprotocol/go-sdk/internal/util"
"github.com/modelcontextprotocol/go-sdk/jsonschema"
)
@ -56,12 +55,12 @@ func NewTool[TReq any](name, description string, handler func(context.Context, *
// rather than returned as jsonrpc2 server errors.
if err != nil {
return &CallToolResult{
Content: []WireContent{TextContent{Text: err.Error()}.ToWire()},
Content: []Content{NewTextContent(err.Error())},
IsError: true,
}, nil
}
res := &CallToolResult{
Content: util.Apply(content, Content.ToWire),
Content: content,
}
return res, nil
}

View file

@ -14,11 +14,6 @@ func assert(cond bool, msg string) {
}
}
func is[T any](v any) bool {
_, ok := v.(T)
return ok
}
// Copied from crypto/rand.
// TODO: once 1.24 is assured, just use crypto/rand.
const base32alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"