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:
parent
4f6510b9ae
commit
dac5be2c50
11 changed files with 107 additions and 207 deletions
|
|
@ -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)
|
||||
|
|
|
|||
174
mcp/content.go
174
mcp/content.go
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue