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:
parent
92a0c702cf
commit
a627a4a2dd
7 changed files with 164 additions and 19 deletions
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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{}} }
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
81
mcp/testdata/conformance/server/resources.txtar
vendored
Normal file
81
mcp/testdata/conformance/server/resources.txtar
vendored
Normal 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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue