internal/mcp: clean up handling of content
Add support for all content types, and formalize the conversion of content to and from the wire format (as a discriminated union). Change-Id: I93678ce98c02176a524d2c82425fb1267ad68bf0 Reviewed-on: https://go-review.googlesource.com/c/tools/+/669135 LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Jonathan Amsterdam <jba@google.com> Auto-Submit: Robert Findley <rfindley@google.com>
This commit is contained in:
parent
af4d138732
commit
bf608eeeae
14 changed files with 282 additions and 104 deletions
|
|
@ -6,7 +6,6 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -29,19 +28,10 @@ func SayHi(ctx context.Context, cc *mcp.ClientConnection, params *HiParams) ([]m
|
||||||
}
|
}
|
||||||
|
|
||||||
func PromptHi(ctx context.Context, cc *mcp.ClientConnection, params *HiParams) (*protocol.GetPromptResult, error) {
|
func PromptHi(ctx context.Context, cc *mcp.ClientConnection, params *HiParams) (*protocol.GetPromptResult, error) {
|
||||||
// (see related TODOs about cleaning up content construction)
|
|
||||||
content, err := json.Marshal(protocol.TextContent{
|
|
||||||
Type: "text",
|
|
||||||
Text: "Say hi to " + params.Name,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &protocol.GetPromptResult{
|
return &protocol.GetPromptResult{
|
||||||
Description: "Code review prompt",
|
Description: "Code review prompt",
|
||||||
Messages: []protocol.PromptMessage{
|
Messages: []protocol.PromptMessage{
|
||||||
// TODO: move 'Content' to the protocol package.
|
{Role: "user", Content: mcp.TextContent{Text: "Say hi to " + params.Name}.ToWire()},
|
||||||
{Role: "user", Content: json.RawMessage(content)},
|
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
internal/protocol/content.go
Normal file
42
internal/protocol/content.go
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by an MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package protocol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Content is the wire format for content, including all fields.
|
||||||
|
type Content struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
MIMEType string `json:"mimeType,omitempty"`
|
||||||
|
Data string `json:"data,omitempty"`
|
||||||
|
Resource *Resource `json:"resource,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource is the wire format for embedded resources, including all fields.
|
||||||
|
type Resource struct {
|
||||||
|
URI string `json:"uri,"`
|
||||||
|
MIMEType string `json:"mimeType,omitempty"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Blob *string `json:"blob"` // blob is a pointer to distinguish empty from missing data
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
switch c2.Type {
|
||||||
|
case "text", "image", "audio", "resource":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unrecognized content type %s", c.Type)
|
||||||
|
}
|
||||||
|
*c = Content(c2)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -91,7 +91,6 @@ var declarations = config{
|
||||||
"Tools": {Name: "ToolCapabilities"},
|
"Tools": {Name: "ToolCapabilities"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"TextContent": {Name: "TextContent"},
|
|
||||||
"Tool": {
|
"Tool": {
|
||||||
Name: "Tool",
|
Name: "Tool",
|
||||||
Fields: config{"InputSchema": {Substitute: "*jsonschema.Schema"}},
|
Fields: config{"InputSchema": {Substitute: "*jsonschema.Schema"}},
|
||||||
|
|
@ -249,8 +248,15 @@ func writeType(w io.Writer, config *typeConfig, def *jsonschema.Schema, named ma
|
||||||
}
|
}
|
||||||
|
|
||||||
if def.Type == "" {
|
if def.Type == "" {
|
||||||
// E.g. union types.
|
// special case: recognize Content
|
||||||
fmt.Fprintf(w, "json.RawMessage")
|
if slices.ContainsFunc(def.AnyOf, func(s *jsonschema.Schema) bool {
|
||||||
|
return s.Ref == "#/definitions/TextContent"
|
||||||
|
}) {
|
||||||
|
fmt.Fprintf(w, "Content")
|
||||||
|
} else {
|
||||||
|
// E.g. union types.
|
||||||
|
fmt.Fprintf(w, "json.RawMessage")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
switch def.Type {
|
switch def.Type {
|
||||||
case "array":
|
case "array":
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ type CallToolResult struct {
|
||||||
// This result property is reserved by the protocol to allow clients and servers
|
// This result property is reserved by the protocol to allow clients and servers
|
||||||
// to attach additional metadata to their responses.
|
// to attach additional metadata to their responses.
|
||||||
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
|
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
|
||||||
Content []json.RawMessage `json:"content"`
|
Content []Content `json:"content"`
|
||||||
// Whether the tool call ended in an error.
|
// Whether the tool call ended in an error.
|
||||||
//
|
//
|
||||||
// If not set, this is assumed to be false (the call was successful).
|
// If not set, this is assumed to be false (the call was successful).
|
||||||
|
|
@ -198,8 +198,8 @@ type PromptCapabilities struct {
|
||||||
// This is similar to `SamplingMessage`, but also supports the embedding of
|
// This is similar to `SamplingMessage`, but also supports the embedding of
|
||||||
// resources from the MCP server.
|
// resources from the MCP server.
|
||||||
type PromptMessage struct {
|
type PromptMessage struct {
|
||||||
Content json.RawMessage `json:"content"`
|
Content Content `json:"content"`
|
||||||
Role Role `json:"role"`
|
Role Role `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Present if the server offers any resources to read.
|
// Present if the server offers any resources to read.
|
||||||
|
|
@ -231,15 +231,6 @@ type ServerCapabilities struct {
|
||||||
Tools *ToolCapabilities `json:"tools,omitempty"`
|
Tools *ToolCapabilities `json:"tools,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text provided to or from an LLM.
|
|
||||||
type TextContent struct {
|
|
||||||
// Optional annotations for the client.
|
|
||||||
Annotations *Annotations `json:"annotations,omitempty"`
|
|
||||||
// The text content of the message.
|
|
||||||
Text string `json:"text"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Definition for a tool the client can call.
|
// Definition for a tool the client can call.
|
||||||
type Tool struct {
|
type Tool struct {
|
||||||
// Optional additional tool information.
|
// Optional additional tool information.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,15 @@ import (
|
||||||
"slices"
|
"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.
|
// Helpers below are copied from gopls' moremaps package.
|
||||||
|
|
||||||
// sorted returns an iterator over the entries of m in key order.
|
// sorted returns an iterator over the entries of m in key order.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ package mcp
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"iter"
|
"iter"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
@ -194,7 +193,7 @@ func (sc *ServerConnection) ListTools(ctx context.Context) ([]protocol.Tool, err
|
||||||
// TODO(jba): make the following true:
|
// TODO(jba): make the following true:
|
||||||
// If the provided arguments do not conform to the schema for the given tool,
|
// If the provided arguments do not conform to the schema for the given tool,
|
||||||
// the call fails.
|
// the call fails.
|
||||||
func (sc *ServerConnection) CallTool(ctx context.Context, name string, args map[string]any) (_ []Content, err error) {
|
func (sc *ServerConnection) CallTool(ctx context.Context, name string, args map[string]any) (_ *protocol.CallToolResult, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("calling tool %q: %w", name, err)
|
err = fmt.Errorf("calling tool %q: %w", name, err)
|
||||||
|
|
@ -218,15 +217,5 @@ func (sc *ServerConnection) CallTool(ctx context.Context, name string, args map[
|
||||||
if err := call(ctx, sc.conn, "tools/call", params, &result); err != nil {
|
if err := call(ctx, sc.conn, "tools/call", params, &result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
content, err := unmarshalContent(result.Content)
|
return &result, nil
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("unmarshaling tool content: %v", err)
|
|
||||||
}
|
|
||||||
if result.IsError {
|
|
||||||
if len(content) != 1 || !is[TextContent](content[0]) {
|
|
||||||
return nil, errors.New("malformed error content")
|
|
||||||
}
|
|
||||||
return nil, errors.New(content[0].(TextContent).Text)
|
|
||||||
}
|
|
||||||
return content, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
const runAsServer = "_MCP_RUN_AS_SERVER"
|
const runAsServer = "_MCP_RUN_AS_SERVER"
|
||||||
|
|
@ -57,7 +58,9 @@ func TestCmdTransport(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
want := []mcp.Content{mcp.TextContent{Text: "Hi user"}}
|
want := &protocol.CallToolResult{
|
||||||
|
Content: []protocol.Content{{Type: "text", Text: "Hi user"}},
|
||||||
|
}
|
||||||
if diff := cmp.Diff(want, got); diff != "" {
|
if diff := cmp.Diff(want, got); diff != "" {
|
||||||
t.Errorf("greet returned unexpected content (-want +got):\n%s", diff)
|
t.Errorf("greet returned unexpected content (-want +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
144
mcp/content.go
144
mcp/content.go
|
|
@ -5,53 +5,17 @@
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/protocol"
|
"github.com/modelcontextprotocol/go-sdk/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Content is the abstract result of a Tool call.
|
// Content is the union of supported content types: [TextContent],
|
||||||
|
// [ImageContent], [AudioContent], and [ResourceContent].
|
||||||
//
|
//
|
||||||
// TODO: support all content types.
|
// ToWire converts content to its jsonrpc2 wire format.
|
||||||
type Content interface {
|
type Content interface {
|
||||||
toProtocol() any
|
ToWire() protocol.Content
|
||||||
}
|
|
||||||
|
|
||||||
func marshalContent(content []Content) []json.RawMessage {
|
|
||||||
var msgs []json.RawMessage
|
|
||||||
for _, c := range content {
|
|
||||||
msg, err := json.Marshal(c.toProtocol())
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Sprintf("marshaling content: %v", err))
|
|
||||||
}
|
|
||||||
msgs = append(msgs, msg)
|
|
||||||
}
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
|
|
||||||
func unmarshalContent(msgs []json.RawMessage) ([]Content, error) {
|
|
||||||
var content []Content
|
|
||||||
for _, msg := range msgs {
|
|
||||||
var allContent struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text json.RawMessage
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(msg, &allContent); err != nil {
|
|
||||||
return nil, fmt.Errorf("content missing \"type\"")
|
|
||||||
}
|
|
||||||
switch allContent.Type {
|
|
||||||
case "text":
|
|
||||||
var text string
|
|
||||||
if err := json.Unmarshal(allContent.Text, &text); err != nil {
|
|
||||||
return nil, fmt.Errorf("unmarshalling text content: %v", err)
|
|
||||||
}
|
|
||||||
content = append(content, TextContent{Text: text})
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported content type %q", allContent.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return content, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TextContent is a textual content.
|
// TextContent is a textual content.
|
||||||
|
|
@ -59,6 +23,102 @@ type TextContent struct {
|
||||||
Text string
|
Text string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c TextContent) toProtocol() any {
|
func (c TextContent) ToWire() protocol.Content {
|
||||||
return protocol.TextContent{Type: "text", Text: c.Text}
|
return protocol.Content{Type: "text", Text: c.Text}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageContent contains base64-encoded image data.
|
||||||
|
type ImageContent struct {
|
||||||
|
Data string
|
||||||
|
MimeType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ImageContent) ToWire() protocol.Content {
|
||||||
|
return protocol.Content{Type: "image", MIMEType: c.MimeType, Data: c.Data}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioContent contains base64-encoded audio data.
|
||||||
|
type AudioContent struct {
|
||||||
|
Data string
|
||||||
|
MimeType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c AudioContent) ToWire() protocol.Content {
|
||||||
|
return protocol.Content{Type: "audio", MIMEType: c.MimeType, Data: c.Data}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResourceContent contains embedded resources.
|
||||||
|
type ResourceContent struct {
|
||||||
|
Resource Resource
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ResourceContent) ToWire() protocol.Content {
|
||||||
|
res := r.Resource.ToWire()
|
||||||
|
return protocol.Content{Type: "resource", Resource: &res}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Resource interface {
|
||||||
|
ToWire() protocol.Resource
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextResource struct {
|
||||||
|
URI string
|
||||||
|
MimeType string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r TextResource) ToWire() protocol.Resource {
|
||||||
|
return protocol.Resource{
|
||||||
|
URI: r.URI,
|
||||||
|
MIMEType: r.MimeType,
|
||||||
|
Text: r.Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlobResource struct {
|
||||||
|
URI string
|
||||||
|
MimeType string
|
||||||
|
Blob string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r BlobResource) ToWire() protocol.Resource {
|
||||||
|
blob := r.Blob
|
||||||
|
return protocol.Resource{
|
||||||
|
URI: r.URI,
|
||||||
|
MIMEType: r.MimeType,
|
||||||
|
Blob: &blob,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentFromWireContent converts content from the jsonrpc2 wire format to a
|
||||||
|
// typed Content value.
|
||||||
|
func ContentFromWireContent(c protocol.Content) 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 = BlobResource{
|
||||||
|
URI: c.Resource.URI,
|
||||||
|
MimeType: c.Resource.MIMEType,
|
||||||
|
Blob: *c.Resource.Blob,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
r.Resource = TextResource{
|
||||||
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
75
mcp/content_test.go
Normal file
75
mcp/content_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by an MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContent(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in mcp.Content
|
||||||
|
want protocol.Content
|
||||||
|
}{
|
||||||
|
{mcp.TextContent{Text: "hello"}, protocol.Content{Type: "text", Text: "hello"}},
|
||||||
|
{
|
||||||
|
mcp.ImageContent{Data: "a1b2c3", MimeType: "image/png"},
|
||||||
|
protocol.Content{Type: "image", Data: "a1b2c3", MIMEType: "image/png"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mcp.AudioContent{Data: "a1b2c3", MimeType: "audio/wav"},
|
||||||
|
protocol.Content{Type: "audio", Data: "a1b2c3", MIMEType: "audio/wav"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mcp.ResourceContent{
|
||||||
|
Resource: mcp.TextResource{
|
||||||
|
URI: "file://foo",
|
||||||
|
MimeType: "text",
|
||||||
|
Text: "abc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
protocol.Content{
|
||||||
|
Type: "resource",
|
||||||
|
Resource: &protocol.Resource{
|
||||||
|
URI: "file://foo",
|
||||||
|
MIMEType: "text",
|
||||||
|
Text: "abc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mcp.ResourceContent{
|
||||||
|
Resource: mcp.BlobResource{
|
||||||
|
URI: "file://foo",
|
||||||
|
MimeType: "text",
|
||||||
|
Blob: "a1b2c3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
protocol.Content{
|
||||||
|
Type: "resource",
|
||||||
|
Resource: &protocol.Resource{
|
||||||
|
URI: "file://foo",
|
||||||
|
MIMEType: "text",
|
||||||
|
Blob: ptr("a1b2c3"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](t T) *T {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
@ -50,19 +49,10 @@ func TestEndToEnd(t *testing.T) {
|
||||||
|
|
||||||
s.AddPrompts(
|
s.AddPrompts(
|
||||||
MakePrompt("code_review", "do a code review", func(_ context.Context, _ *ClientConnection, params struct{ Code string }) (*protocol.GetPromptResult, error) {
|
MakePrompt("code_review", "do a code review", func(_ context.Context, _ *ClientConnection, params struct{ Code string }) (*protocol.GetPromptResult, error) {
|
||||||
// TODO(rfindley): clean up this handling of content.
|
|
||||||
content, err := json.Marshal(protocol.TextContent{
|
|
||||||
Type: "text",
|
|
||||||
Text: "Please review the following code: " + params.Code,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &protocol.GetPromptResult{
|
return &protocol.GetPromptResult{
|
||||||
Description: "Code review prompt",
|
Description: "Code review prompt",
|
||||||
Messages: []protocol.PromptMessage{
|
Messages: []protocol.PromptMessage{
|
||||||
// TODO: move 'Content' to the protocol package.
|
{Role: "user", Content: TextContent{Text: "Please review the following code: " + params.Code}.ToWire()},
|
||||||
{Role: "user", Content: json.RawMessage(content)},
|
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}),
|
}),
|
||||||
|
|
@ -126,10 +116,17 @@ func TestEndToEnd(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// TODO: assert on the full review, once content is easier to create.
|
wantReview := &protocol.GetPromptResult{
|
||||||
if got, want := gotReview.Description, "Code review prompt"; got != want {
|
Description: "Code review prompt",
|
||||||
t.Errorf("prompts/get 'code_review': got description %q, want %q", got, want)
|
Messages: []protocol.PromptMessage{{
|
||||||
|
Content: TextContent{Text: "Please review the following code: 1+1"}.ToWire(),
|
||||||
|
Role: "user",
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
|
if diff := cmp.Diff(wantReview, gotReview); diff != "" {
|
||||||
|
t.Errorf("prompts/get 'code_review' mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := sc.GetPrompt(ctx, "fail", map[string]string{}); err == nil || !strings.Contains(err.Error(), failure.Error()) {
|
if _, err := sc.GetPrompt(ctx, "fail", map[string]string{}); err == nil || !strings.Contains(err.Error(), failure.Error()) {
|
||||||
t.Errorf("fail returned unexpected error: got %v, want containing %v", err, failure)
|
t.Errorf("fail returned unexpected error: got %v, want containing %v", err, failure)
|
||||||
}
|
}
|
||||||
|
|
@ -165,13 +162,25 @@ func TestEndToEnd(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
wantHi := []Content{TextContent{Text: "hi user"}}
|
wantHi := &protocol.CallToolResult{
|
||||||
|
Content: []protocol.Content{{Type: "text", Text: "hi user"}},
|
||||||
|
}
|
||||||
if diff := cmp.Diff(wantHi, gotHi); diff != "" {
|
if diff := cmp.Diff(wantHi, gotHi); diff != "" {
|
||||||
t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff)
|
t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := sc.CallTool(ctx, "fail", map[string]any{}); err == nil || !strings.Contains(err.Error(), failure.Error()) {
|
gotFail, err := sc.CallTool(ctx, "fail", map[string]any{})
|
||||||
t.Errorf("fail returned unexpected error: got %v, want containing %v", err, failure)
|
// Counter-intuitively, when a tool fails, we don't expect an RPC error for
|
||||||
|
// call tool: instead, the failure is embedded in the result.
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantFail := &protocol.CallToolResult{
|
||||||
|
IsError: true,
|
||||||
|
Content: []protocol.Content{{Type: "text", Text: failure.Error()}},
|
||||||
|
}
|
||||||
|
if diff := cmp.Diff(wantFail, gotFail); diff != "" {
|
||||||
|
t.Errorf("tools/call 'fail' mismatch (-want +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disconnect.
|
// Disconnect.
|
||||||
|
|
|
||||||
|
|
@ -40,11 +40,11 @@ func ExampleServer() {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := serverConnection.CallTool(ctx, "greet", map[string]any{"name": "user"})
|
res, err := serverConnection.CallTool(ctx, "greet", map[string]any{"name": "user"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
fmt.Println(content[0].(mcp.TextContent).Text)
|
fmt.Println(res.Content[0].Text)
|
||||||
|
|
||||||
serverConnection.Close()
|
serverConnection.Close()
|
||||||
clientConnection.Wait()
|
clientConnection.Wait()
|
||||||
|
|
|
||||||
|
|
@ -40,11 +40,11 @@ func ExampleSSEHandler() {
|
||||||
}
|
}
|
||||||
defer serverConn.Close()
|
defer serverConn.Close()
|
||||||
|
|
||||||
content, err := serverConn.CallTool(ctx, "add", map[string]any{"x": 1, "y": 2})
|
res, err := serverConn.CallTool(ctx, "add", map[string]any{"x": 1, "y": 2})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
fmt.Println(content[0].(mcp.TextContent).Text)
|
fmt.Println(res.Content[0].Text)
|
||||||
|
|
||||||
// Output: 3
|
// Output: 3
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSSEServer(t *testing.T) {
|
func TestSSEServer(t *testing.T) {
|
||||||
|
|
@ -48,7 +49,9 @@ func TestSSEServer(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
wantHi := []Content{TextContent{Text: "hi user"}}
|
wantHi := &protocol.CallToolResult{
|
||||||
|
Content: []protocol.Content{{Type: "text", Text: "hi user"}},
|
||||||
|
}
|
||||||
if diff := cmp.Diff(wantHi, gotHi); diff != "" {
|
if diff := cmp.Diff(wantHi, gotHi); diff != "" {
|
||||||
t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff)
|
t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonschema"
|
"github.com/modelcontextprotocol/go-sdk/internal/jsonschema"
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/protocol"
|
"github.com/modelcontextprotocol/go-sdk/internal/protocol"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A ToolHandler handles a call to tools/call.
|
// A ToolHandler handles a call to tools/call.
|
||||||
|
|
@ -56,12 +57,12 @@ func MakeTool[TReq any](name, description string, handler func(context.Context,
|
||||||
// rather than returned as jsonrpc2 server errors.
|
// rather than returned as jsonrpc2 server errors.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &protocol.CallToolResult{
|
return &protocol.CallToolResult{
|
||||||
Content: marshalContent([]Content{TextContent{Text: err.Error()}}),
|
Content: []protocol.Content{TextContent{Text: err.Error()}.ToWire()},
|
||||||
IsError: true,
|
IsError: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
res := &protocol.CallToolResult{
|
res := &protocol.CallToolResult{
|
||||||
Content: marshalContent(content),
|
Content: util.Apply(content, Content.ToWire),
|
||||||
}
|
}
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue