diff --git a/examples/hello/main.go b/examples/hello/main.go index aa76da7..7d1c97d 100644 --- a/examples/hello/main.go +++ b/examples/hello/main.go @@ -9,6 +9,7 @@ import ( "flag" "fmt" "net/http" + "net/url" "os" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -43,6 +44,14 @@ func main() { mcp.Property("name", mcp.Description("the name to say hi to")), ))) 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 != "" { 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 +} diff --git a/mcp/conformance_test.go b/mcp/conformance_test.go index 1f8c22a..6182136 100644 --- a/mcp/conformance_test.go +++ b/mcp/conformance_test.go @@ -24,7 +24,6 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" - "golang.org/x/tools/internal/testenv" "golang.org/x/tools/txtar" ) @@ -56,10 +55,10 @@ type conformanceTest struct { server []jsonrpc2.Message // server messages } +// TODO(jba): support synthetic responses. // TODO(rfindley): add client conformance tests. func TestServerConformance(t *testing.T) { - testenv.NeedsGoExperiment(t, "synctest") var tests []*conformanceTest dir := filepath.Join("testdata", "conformance", "server") 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 := func(data []byte) []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 != "" { feats = append(feats, f) } @@ -264,7 +263,7 @@ func loadConformanceTest(dir, path string) (*conformanceTest, error) { test.tools = loadFeatures(f.Data) case "prompts": test.prompts = loadFeatures(f.Data) - case "resource": + case "resources": test.resources = loadFeatures(f.Data) case "client": test.client, err = decodeMessages(f.Data) diff --git a/mcp/generate.go b/mcp/generate.go index 67fa2f6..7db7bf4 100644 --- a/mcp/generate.go +++ b/mcp/generate.go @@ -143,7 +143,7 @@ var declarations = config{ Fields: config{"Params": {Name: "ReadResourceParams"}}, }, "ReadResourceResult": { - Fields: config{"Contents": {Substitute: "*ResourceContents"}}, + Fields: config{"Contents": {Substitute: "[]*ResourceContents"}}, }, "Resource": {}, "ResourceListChangedNotification": { @@ -195,7 +195,7 @@ func main() { log.Fatal(err) } // 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) } diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 52e5f18..34d4b32 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "log/slog" + "net/url" "path/filepath" "runtime" "slices" @@ -164,7 +165,7 @@ func TestEndToEnd(t *testing.T) { Description: "just fail", InputSchema: &jsonschema.Schema{ Type: "object", - AdditionalProperties: falseSchema, + AdditionalProperties: falseSchema(), }, }, { @@ -176,7 +177,7 @@ func TestEndToEnd(t *testing.T) { Properties: map[string]*jsonschema.Schema{ "Name": {Type: "string"}, }, - AdditionalProperties: falseSchema, + AdditionalProperties: falseSchema(), }, }, } @@ -245,11 +246,14 @@ func TestEndToEnd(t *testing.T) { } else { 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 { - 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) } - 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) } } @@ -413,13 +417,41 @@ var ( MIMEType: "text/plain", URI: "file:///fail.txt", } + resource3 = &Resource{ + Name: "info", + MIMEType: "text/plain", + URI: "embedded:info", + } readHandler = fileResourceHandler("testdata/files") resources = map[string]*ServerResource{ "info.txt": {resource1, 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. func add[T any](m map[string]T, add func(...T), names ...string) { 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{}} } diff --git a/mcp/protocol.go b/mcp/protocol.go index 4405631..7c9d39f 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -430,8 +430,8 @@ func (x *ReadResourceParams) GetMeta() *Meta { return &x.Meta } type ReadResourceResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. - Meta Meta `json:"_meta,omitempty"` - Contents *ResourceContents `json:"contents"` + Meta Meta `json:"_meta,omitempty"` + Contents []*ResourceContents `json:"contents"` } func (x *ReadResourceResult) GetMeta() *Meta { return &x.Meta } diff --git a/mcp/server.go b/mcp/server.go index 6d33234..635df94 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -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) } // As a convenience, populate some fields. - if res.Contents.URI == "" { - res.Contents.URI = uri - } - if res.Contents.MIMEType == "" { - res.Contents.MIMEType = resource.Resource.MIMEType + for _, c := range res.Contents { + if c.URI == "" { + c.URI = uri + } + if c.MIMEType == "" { + c.MIMEType = resource.Resource.MIMEType + } } return res, nil } @@ -329,7 +331,7 @@ func fileResourceHandler(dir string) ResourceHandler { return nil, err } // 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 } } diff --git a/mcp/testdata/conformance/server/resources.txtar b/mcp/testdata/conformance/server/resources.txtar new file mode 100644 index 0000000..6f3679d --- /dev/null +++ b/mcp/testdata/conformance/server/resources.txtar @@ -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." + } + ] + } +}