diff --git a/mcp/resource.go b/mcp/resource.go index fff38a22..39bfe5d6 100644 --- a/mcp/resource.go +++ b/mcp/resource.go @@ -38,6 +38,21 @@ type serverResourceTemplate struct { // If it cannot find the resource, it should return the result of calling [ResourceNotFoundError]. type ResourceHandler func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error) +// A ListResourcesHandler serves resources/list on demand. +// +// Set [ServerOptions.ListResourcesHandler] to enable dynamic resource listing for +// gateway servers, template-backed catalogs, and other cases where the resource +// set cannot be materialized up front. +// +// If the server also has static resources registered with [Server.AddResource], +// those are listed first using SDK pagination, then this handler is invoked for +// subsequent pages. If there are no static resources, the client's pagination +// cursor is passed through to this handler unchanged. +// +// The handler should set Resources to a non-nil empty slice when there are no +// resources on the page. +type ListResourcesHandler func(context.Context, *ListResourcesRequest) (*ListResourcesResult, error) + // customresnotfounderrcode is a compatibility parameter that restores the // pre-1.7.0 behavior of [ResourceNotFoundError] and [CodeResourceNotFound], // where the error code was a custom -32002. See the documentation for the mcpgodebug diff --git a/mcp/resource_list_handler_test.go b/mcp/resource_list_handler_test.go new file mode 100644 index 00000000..d417c9f5 --- /dev/null +++ b/mcp/resource_list_handler_test.go @@ -0,0 +1,144 @@ +// 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 + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestListResourcesHandlerDynamicOnly(t *testing.T) { + ctx := context.Background() + var gotCursor string + server := NewServer(testImpl, &ServerOptions{ + ListResourcesHandler: func(_ context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) { + gotCursor = req.Params.Cursor + if req.Params.Cursor != "" { + return &ListResourcesResult{Resources: []*Resource{}}, nil + } + return &ListResourcesResult{ + Resources: []*Resource{{URI: "dynamic://a"}}, + NextCursor: "page2", + }, nil + }, + }) + client := NewClient(testImpl, nil) + st, ct := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + res, err := cs.ListResources(ctx, nil) + if err != nil { + t.Fatal(err) + } + want := []*Resource{{URI: "dynamic://a"}} + if diff := cmp.Diff(want, res.Resources); diff != "" { + t.Fatalf("first page mismatch (-want +got):\n%s", diff) + } + if gotCursor != "" { + t.Fatalf("first page cursor = %q, want empty", gotCursor) + } + if res.NextCursor != "page2" { + t.Fatalf("NextCursor = %q, want page2", res.NextCursor) + } + + res2, err := cs.ListResources(ctx, &ListResourcesParams{Cursor: "page2"}) + if err != nil { + t.Fatal(err) + } + if gotCursor != "page2" { + t.Fatalf("second page cursor = %q, want page2", gotCursor) + } + if len(res2.Resources) != 0 { + t.Fatalf("second page resources = %v, want empty", res2.Resources) + } +} + +func TestListResourcesHandlerComposeWithStatic(t *testing.T) { + ctx := context.Background() + handlerCalls := 0 + server := NewServer(testImpl, &ServerOptions{ + PageSize: 1, + ListResourcesHandler: func(_ context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) { + handlerCalls++ + if req.Params.Cursor != "" { + t.Fatalf("handler cursor = %q, want empty on first handler page", req.Params.Cursor) + } + return &ListResourcesResult{ + Resources: []*Resource{{URI: "dynamic://x"}}, + }, nil + }, + }) + server.AddResource(&Resource{URI: "static://a"}, nil) + server.AddResource(&Resource{URI: "static://b"}, nil) + + client := NewClient(testImpl, nil) + st, ct := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + page1, err := cs.ListResources(ctx, nil) + if err != nil { + t.Fatal(err) + } + if len(page1.Resources) != 1 || page1.Resources[0].URI != "static://a" { + t.Fatalf("page1 = %v, want static://a", page1.Resources) + } + if page1.NextCursor == "" { + t.Fatal("page1 NextCursor empty, want more pages") + } + + page2, err := cs.ListResources(ctx, &ListResourcesParams{Cursor: page1.NextCursor}) + if err != nil { + t.Fatal(err) + } + if len(page2.Resources) != 1 || page2.Resources[0].URI != "static://b" { + t.Fatalf("page2 = %v, want static://b", page2.Resources) + } + if page2.NextCursor == "" { + t.Fatal("page2 NextCursor empty, want handler phase") + } + + page3, err := cs.ListResources(ctx, &ListResourcesParams{Cursor: page2.NextCursor}) + if err != nil { + t.Fatal(err) + } + if len(page3.Resources) != 1 || page3.Resources[0].URI != "dynamic://x" { + t.Fatalf("page3 = %v, want dynamic://x", page3.Resources) + } + if handlerCalls != 1 { + t.Fatalf("handler calls = %d, want 1", handlerCalls) + } +} + +func TestListResourcesHandlerCapability(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{ + ListResourcesHandler: func(context.Context, *ListResourcesRequest) (*ListResourcesResult, error) { + return &ListResourcesResult{Resources: []*Resource{}}, nil + }, + }) + caps := server.capabilities() + if caps.Resources == nil { + t.Fatal("expected resources capability when ListResourcesHandler is set") + } +} diff --git a/mcp/server.go b/mcp/server.go index 912dea98..48de9a1d 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -20,6 +20,7 @@ import ( "path/filepath" "reflect" "slices" + "strings" "sync" "sync/atomic" "time" @@ -90,6 +91,9 @@ type ServerOptions struct { SubscribeHandler func(context.Context, *SubscribeRequest) error // Function called when a client session unsubscribes from a resource. UnsubscribeHandler func(context.Context, *UnsubscribeRequest) error + // ListResourcesHandler, if non-nil, serves resources/list dynamically. + // See [ListResourcesHandler] for semantics. + ListResourcesHandler ListResourcesHandler // Capabilities optionally configures the server's default capabilities, // before any capabilities are inferred from other configuration or server @@ -617,7 +621,7 @@ func (s *Server) capabilities() *ServerCapabilities { } // Augment with resources capability if resources/templates exist or legacy HasResources is set. - if s.opts.HasResources || s.resources.len() > 0 || s.resourceTemplates.len() > 0 { + if s.opts.HasResources || s.resources.len() > 0 || s.resourceTemplates.len() > 0 || s.opts.ListResourcesHandler != nil { if caps.Resources == nil { caps.Resources = &ResourceCapabilities{ListChanged: true} } @@ -850,18 +854,165 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR return res, err } -func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) { - s.mu.Lock() - defer s.mu.Unlock() +func (s *Server) listResources(ctx context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) { if req.Params == nil { req.Params = &ListResourcesParams{} } - return paginateList(s.resources, s.opts.PageSize, req.Params, &ListResourcesResult{}, func(res *ListResourcesResult, resources []*serverResource) { - res.Resources = []*Resource{} // avoid JSON null - for _, r := range resources { - res.Resources = append(res.Resources, r.resource) + if s.opts.ListResourcesHandler == nil { + s.mu.Lock() + defer s.mu.Unlock() + return paginateList(s.resources, s.opts.PageSize, req.Params, &ListResourcesResult{}, populateListResourcesResult) + } + return s.listResourcesWithHandler(ctx, req) +} + +func populateListResourcesResult(res *ListResourcesResult, resources []*serverResource) { + res.Resources = []*Resource{} // avoid JSON null + for _, r := range resources { + res.Resources = append(res.Resources, r.resource) + } +} + +const ( + listResourcesPhaseStatic = "static" + listResourcesPhaseHandler = "handler" + listResourcesCursorPrefix = "lr1:" +) + +type listResourcesCursor struct { + Phase string + StaticCursor string + HandlerCursor string +} + +func encodeListResourcesCursor(c listResourcesCursor) (string, error) { + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode(c); err != nil { + return "", fmt.Errorf("failed to encode list resources cursor: %w", err) + } + return listResourcesCursorPrefix + base64.URLEncoding.EncodeToString(buf.Bytes()), nil +} + +func decodeListResourcesCursor(cursor string) (*listResourcesCursor, error) { + if cursor == "" { + return &listResourcesCursor{Phase: listResourcesPhaseStatic}, nil + } + if !strings.HasPrefix(cursor, listResourcesCursorPrefix) { + return nil, fmt.Errorf("not a list resources cursor") + } + decoded, err := base64.URLEncoding.DecodeString(cursor[len(listResourcesCursorPrefix):]) + if err != nil { + return nil, fmt.Errorf("failed to decode list resources cursor: %w", err) + } + var c listResourcesCursor + if err := gob.NewDecoder(bytes.NewReader(decoded)).Decode(&c); err != nil { + return nil, fmt.Errorf("failed to decode list resources cursor: %w", err) + } + return &c, nil +} + +func normalizeListResourcesResult(res *ListResourcesResult) *ListResourcesResult { + if res == nil { + return &ListResourcesResult{Resources: []*Resource{}} + } + if res.Resources == nil { + res2 := *res + res2.Resources = []*Resource{} + return &res2 + } + return res +} + +func (s *Server) listResourcesWithHandler(ctx context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) { + handler := s.opts.ListResourcesHandler + + s.mu.Lock() + hasStatic := s.resources.len() > 0 + pageSize := s.opts.PageSize + s.mu.Unlock() + + if !hasStatic { + res, err := handler(ctx, req) + if err != nil { + return nil, err } - }) + return normalizeListResourcesResult(res), nil + } + + phase, err := decodeListResourcesCursor(req.Params.Cursor) + if err != nil { + return nil, jsonrpc2.ErrInvalidParams + } + + if phase.Phase == listResourcesPhaseHandler { + handlerReq := &ListResourcesRequest{ + Session: req.Session, + Params: &ListResourcesParams{Meta: req.Params.Meta, Cursor: phase.HandlerCursor}, + } + res, err := handler(ctx, handlerReq) + if err != nil { + return nil, err + } + res = normalizeListResourcesResult(res) + if res.NextCursor != "" { + res.NextCursor, err = encodeListResourcesCursor(listResourcesCursor{ + Phase: listResourcesPhaseHandler, + HandlerCursor: res.NextCursor, + }) + if err != nil { + return nil, err + } + } + return res, nil + } + + s.mu.Lock() + staticParams := &ListResourcesParams{Meta: req.Params.Meta, Cursor: phase.StaticCursor} + res, err := paginateList(s.resources, pageSize, staticParams, &ListResourcesResult{}, populateListResourcesResult) + s.mu.Unlock() + if err != nil { + return nil, err + } + + if res.NextCursor != "" { + res.NextCursor, err = encodeListResourcesCursor(listResourcesCursor{ + Phase: listResourcesPhaseStatic, + StaticCursor: res.NextCursor, + }) + if err != nil { + return nil, err + } + return res, nil + } + + if len(res.Resources) > 0 { + res.NextCursor, err = encodeListResourcesCursor(listResourcesCursor{Phase: listResourcesPhaseHandler}) + if err != nil { + return nil, err + } + return res, nil + } + + handlerReq := &ListResourcesRequest{ + Session: req.Session, + Params: &ListResourcesParams{Meta: req.Params.Meta}, + } + hRes, err := handler(ctx, handlerReq) + if err != nil { + return nil, err + } + hRes = normalizeListResourcesResult(hRes) + + if hRes.NextCursor != "" { + hRes.NextCursor, err = encodeListResourcesCursor(listResourcesCursor{ + Phase: listResourcesPhaseHandler, + HandlerCursor: hRes.NextCursor, + }) + if err != nil { + return nil, err + } + } + return hRes, nil } func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTemplatesRequest) (*ListResourceTemplatesResult, error) {