internal/mcp: add resource conformance test

This uncovered a problem in how we represent resource results:
contents is a slice, not a single value.

I tried to use a file resource, but the server->client ListRoots
call didn't work because synthetic responses aren't supported.
Left a TODO.

I enhanced the hello example so I that could use the MCP Inspector to
work with resources.

The falseSchema change fixes a potential bug where schemas are
re-used, which is disallowed.

Change-Id: I1f0198043cb6221c2a03f4f83aa0bafbc71167f4
Reviewed-on: https://go-review.googlesource.com/c/tools/+/677136
Reviewed-by: Robert Findley <rfindley@google.com>
Reviewed-by: Sam Thanawalla <samthanawalla@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Jonathan Amsterdam <jba@google.com>
This commit is contained in:
Jonathan Amsterdam 2025-05-29 07:57:10 -04:00 • committed by Gopher Robot
commit a627a4a2dd
7 changed files with 164 additions and 19 deletions

View file

@ -9,6 +9,7 @@ import (
"flag" "flag"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"os" "os"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
@ -43,6 +44,14 @@ func main() {
mcp.Property("name", mcp.Description("the name to say hi to")), mcp.Property("name", mcp.Description("the name to say hi to")),
))) )))
server.AddPrompts(mcp.NewPrompt("greet", "", PromptHi)) server.AddPrompts(mcp.NewPrompt("greet", "", PromptHi))
server.AddResources(&mcp.ServerResource{
Resource: &mcp.Resource{
Name: "info",
MIMEType: "text/plain",
URI: "embedded:info",
},
Handler: handleEmbeddedResource,
})
if *httpAddr != "" { if *httpAddr != "" {
handler := mcp.NewSSEHandler(func(*http.Request) *mcp.Server { handler := mcp.NewSSEHandler(func(*http.Request) *mcp.Server {
@ -56,3 +65,25 @@ func main() {
} }
} }
} }
var embeddedResources = map[string]string{
"info": "This is the hello example server.",
}
func handleEmbeddedResource(_ context.Context, _ *mcp.ServerSession, params *mcp.ReadResourceParams) (*mcp.ReadResourceResult, error) {
u, err := url.Parse(params.URI)
if err != nil {
return nil, err
}
if u.Scheme != "embedded" {
return nil, fmt.Errorf("wrong scheme: %q", u.Scheme)
}
key := u.Opaque
text, ok := embeddedResources[key]
if !ok {
return nil, fmt.Errorf("no embedded resource named %q", key)
}
return &mcp.ReadResourceResult{
Contents: []*mcp.ResourceContents{mcp.NewTextResourceContents(params.URI, "text/plain", text)},
}, nil
}

View file

@ -24,7 +24,6 @@ import (
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts" "github.com/google/go-cmp/cmp/cmpopts"
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
"golang.org/x/tools/internal/testenv"
"golang.org/x/tools/txtar" "golang.org/x/tools/txtar"
) )
@ -56,10 +55,10 @@ type conformanceTest struct {
server []jsonrpc2.Message // server messages server []jsonrpc2.Message // server messages
} }
// TODO(jba): support synthetic responses.
// TODO(rfindley): add client conformance tests. // TODO(rfindley): add client conformance tests.
func TestServerConformance(t *testing.T) { func TestServerConformance(t *testing.T) {
testenv.NeedsGoExperiment(t, "synctest")
var tests []*conformanceTest var tests []*conformanceTest
dir := filepath.Join("testdata", "conformance", "server") dir := filepath.Join("testdata", "conformance", "server")
if err := filepath.WalkDir(dir, func(path string, _ fs.DirEntry, err error) error { if err := filepath.WalkDir(dir, func(path string, _ fs.DirEntry, err error) error {
@ -245,7 +244,7 @@ func loadConformanceTest(dir, path string) (*conformanceTest, error) {
// loadFeatures loads lists of named features from the archive file. // loadFeatures loads lists of named features from the archive file.
loadFeatures := func(data []byte) []string { loadFeatures := func(data []byte) []string {
var feats []string var feats []string
for line := range strings.SplitSeq(string(data), "\n") { for line := range strings.Lines(string(data)) {
if f := strings.TrimSpace(line); f != "" { if f := strings.TrimSpace(line); f != "" {
feats = append(feats, f) feats = append(feats, f)
} }
@ -264,7 +263,7 @@ func loadConformanceTest(dir, path string) (*conformanceTest, error) {
test.tools = loadFeatures(f.Data) test.tools = loadFeatures(f.Data)
case "prompts": case "prompts":
test.prompts = loadFeatures(f.Data) test.prompts = loadFeatures(f.Data)
case "resource": case "resources":
test.resources = loadFeatures(f.Data) test.resources = loadFeatures(f.Data)
case "client": case "client":
test.client, err = decodeMessages(f.Data) test.client, err = decodeMessages(f.Data)

View file

@ -143,7 +143,7 @@ var declarations = config{
Fields: config{"Params": {Name: "ReadResourceParams"}}, Fields: config{"Params": {Name: "ReadResourceParams"}},
}, },
"ReadResourceResult": { "ReadResourceResult": {
Fields: config{"Contents": {Substitute: "*ResourceContents"}}, Fields: config{"Contents": {Substitute: "[]*ResourceContents"}},
}, },
"Resource": {}, "Resource": {},
"ResourceListChangedNotification": { "ResourceListChangedNotification": {
@ -195,7 +195,7 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
// Resolve the schema so we have the referents of all the Refs. // Resolve the schema so we have the referents of all the Refs.
if _, err := schema.Resolve("", nil); err != nil { if _, err := schema.Resolve(nil); err != nil {
log.Fatal(err) log.Fatal(err)
} }

View file

@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net/url"
"path/filepath" "path/filepath"
"runtime" "runtime"
"slices" "slices"
@ -164,7 +165,7 @@ func TestEndToEnd(t *testing.T) {
Description: "just fail", Description: "just fail",
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
Type: "object", Type: "object",
AdditionalProperties: falseSchema, AdditionalProperties: falseSchema(),
}, },
}, },
{ {
@ -176,7 +177,7 @@ func TestEndToEnd(t *testing.T) {
Properties: map[string]*jsonschema.Schema{ Properties: map[string]*jsonschema.Schema{
"Name": {Type: "string"}, "Name": {Type: "string"},
}, },
AdditionalProperties: falseSchema, AdditionalProperties: falseSchema(),
}, },
}, },
} }
@ -245,11 +246,14 @@ func TestEndToEnd(t *testing.T) {
} else { } else {
t.Errorf("reading %s: %v", tt.uri, err) t.Errorf("reading %s: %v", tt.uri, err)
} }
} else if g, w := len(rres.Contents), 1; g != w {
t.Errorf("got %d contents, wanted %d", g, w)
} else { } else {
if got := rres.Contents.URI; got != tt.uri { c := rres.Contents[0]
if got := c.URI; got != tt.uri {
t.Errorf("got uri %q, want %q", got, tt.uri) t.Errorf("got uri %q, want %q", got, tt.uri)
} }
if got := rres.Contents.MIMEType; got != tt.mimeType { if got := c.MIMEType; got != tt.mimeType {
t.Errorf("%s: got MIME type %q, want %q", tt.uri, got, tt.mimeType) t.Errorf("%s: got MIME type %q, want %q", tt.uri, got, tt.mimeType)
} }
} }
@ -413,13 +417,41 @@ var (
MIMEType: "text/plain", MIMEType: "text/plain",
URI: "file:///fail.txt", URI: "file:///fail.txt",
} }
resource3 = &Resource{
Name: "info",
MIMEType: "text/plain",
URI: "embedded:info",
}
readHandler = fileResourceHandler("testdata/files") readHandler = fileResourceHandler("testdata/files")
resources = map[string]*ServerResource{ resources = map[string]*ServerResource{
"info.txt": {resource1, readHandler}, "info.txt": {resource1, readHandler},
"fail.txt": {resource2, readHandler}, "fail.txt": {resource2, readHandler},
"info": {resource3, handleEmbeddedResource},
} }
) )
var embeddedResources = map[string]string{
"info": "This is the MCP test server.",
}
func handleEmbeddedResource(_ context.Context, _ *ServerSession, params *ReadResourceParams) (*ReadResourceResult, error) {
u, err := url.Parse(params.URI)
if err != nil {
return nil, err
}
if u.Scheme != "embedded" {
return nil, fmt.Errorf("wrong scheme: %q", u.Scheme)
}
key := u.Opaque
text, ok := embeddedResources[key]
if !ok {
return nil, fmt.Errorf("no embedded resource named %q", key)
}
return &ReadResourceResult{
Contents: []*ResourceContents{NewTextResourceContents(params.URI, "text/plain", text)},
}, nil
}
// Add calls the given function to add the named features. // Add calls the given function to add the named features.
func add[T any](m map[string]T, add func(...T), names ...string) { func add[T any](m map[string]T, add func(...T), names ...string) {
for _, name := range names { for _, name := range names {
@ -643,4 +675,4 @@ func traceCalls[S ClientSession | ServerSession](w io.Writer, prefix string) Mid
} }
} }
var falseSchema = &jsonschema.Schema{Not: &jsonschema.Schema{}} func falseSchema() *jsonschema.Schema { return &jsonschema.Schema{Not: &jsonschema.Schema{}} }

View file

@ -430,8 +430,8 @@ func (x *ReadResourceParams) GetMeta() *Meta { return &x.Meta }
type ReadResourceResult struct { type ReadResourceResult struct {
// This property is reserved by the protocol to allow clients and servers to // This property is reserved by the protocol to allow clients and servers to
// attach additional metadata to their responses. // attach additional metadata to their responses.
Meta Meta `json:"_meta,omitempty"` Meta Meta `json:"_meta,omitempty"`
Contents *ResourceContents `json:"contents"` Contents []*ResourceContents `json:"contents"`
} }
func (x *ReadResourceResult) GetMeta() *Meta { return &x.Meta } func (x *ReadResourceResult) GetMeta() *Meta { return &x.Meta }

View file

@ -277,11 +277,13 @@ func (s *Server) readResource(ctx context.Context, ss *ServerSession, params *Re
return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri) return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri)
} }
// As a convenience, populate some fields. // As a convenience, populate some fields.
if res.Contents.URI == "" { for _, c := range res.Contents {
res.Contents.URI = uri if c.URI == "" {
} c.URI = uri
if res.Contents.MIMEType == "" { }
res.Contents.MIMEType = resource.Resource.MIMEType if c.MIMEType == "" {
c.MIMEType = resource.Resource.MIMEType
}
} }
return res, nil return res, nil
} }
@ -329,7 +331,7 @@ func fileResourceHandler(dir string) ResourceHandler {
return nil, err return nil, err
} }
// TODO(jba): figure out mime type. // TODO(jba): figure out mime type.
return &ReadResourceResult{Contents: NewBlobResourceContents(params.URI, "text/plain", data)}, nil return &ReadResourceResult{Contents: []*ResourceContents{NewBlobResourceContents(params.URI, "text/plain", data)}}, nil
} }
} }

View file

@ -0,0 +1,81 @@
Check behavior of a server with just resources.
Fixed bugs:
- A resource result holds a slice of contents, not just one.
-- resources --
info
-- client --
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "ExampleClient", "version": "1.0.0" }
}
}
{ "jsonrpc": "2.0", "id": 2, "method": "resources/list" }
{
"jsonrpc": "2.0", "id": 3,
"method": "resources/read",
"params": {
"uri": "embedded:info"
}
}
-- server --
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"_meta": {},
"capabilities": {
"completions": {},
"logging": {},
"prompts": {
"listChanged": true
},
"resources": {
"listChanged": true
},
"tools": {
"listChanged": true
}
},
"protocolVersion": "2024-11-05",
"serverInfo": {
"name": "testServer",
"version": "v1.0.0"
}
}
}
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"_meta": {},
"resources": [
{
"mimeType": "text/plain",
"name": "info",
"uri": "embedded:info"
}
]
}
}
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"_meta": {},
"contents": [
{
"uri": "embedded:info",
"mimeType": "text/plain",
"text": "This is the MCP test server."
}
]
}
}