Skip to content

Commit 863d124

Browse files
piyushbagPiyush Bag
authored andcommitted
mcp: add ListResourcesHandler for dynamic resources/list
Adds ServerOptions.ListResourcesHandler so gateway and template-backed servers can serve resources/list on demand. Static AddResource entries are listed first when present; handler cursors pass through when they are not. Fixes #998
1 parent 71ffcb4 commit 863d124

3 files changed

Lines changed: 319 additions & 9 deletions

File tree

mcp/resource.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,21 @@ type serverResourceTemplate struct {
3838
// If it cannot find the resource, it should return the result of calling [ResourceNotFoundError].
3939
type ResourceHandler func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error)
4040

41+
// A ListResourcesHandler serves resources/list on demand.
42+
//
43+
// Set [ServerOptions.ListResourcesHandler] to enable dynamic resource listing for
44+
// gateway servers, template-backed catalogs, and other cases where the resource
45+
// set cannot be materialized up front.
46+
//
47+
// If the server also has static resources registered with [Server.AddResource],
48+
// those are listed first using SDK pagination, then this handler is invoked for
49+
// subsequent pages. If there are no static resources, the client's pagination
50+
// cursor is passed through to this handler unchanged.
51+
//
52+
// The handler should set Resources to a non-nil empty slice when there are no
53+
// resources on the page.
54+
type ListResourcesHandler func(context.Context, *ListResourcesRequest) (*ListResourcesResult, error)
55+
4156
// customresnotfounderrcode is a compatibility parameter that restores the
4257
// pre-1.7.0 behavior of [ResourceNotFoundError] and [CodeResourceNotFound],
4358
// where the error code was a custom -32002. See the documentation for the mcpgodebug

mcp/resource_list_handler_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
2+
// Use of this source code is governed by an MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package mcp
6+
7+
import (
8+
"context"
9+
"testing"
10+
11+
"github.com/google/go-cmp/cmp"
12+
)
13+
14+
func TestListResourcesHandlerDynamicOnly(t *testing.T) {
15+
ctx := context.Background()
16+
var gotCursor string
17+
server := NewServer(testImpl, &ServerOptions{
18+
ListResourcesHandler: func(_ context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) {
19+
gotCursor = req.Params.Cursor
20+
if req.Params.Cursor != "" {
21+
return &ListResourcesResult{Resources: []*Resource{}}, nil
22+
}
23+
return &ListResourcesResult{
24+
Resources: []*Resource{{URI: "dynamic://a"}},
25+
NextCursor: "page2",
26+
}, nil
27+
},
28+
})
29+
client := NewClient(testImpl, nil)
30+
st, ct := NewInMemoryTransports()
31+
ss, err := server.Connect(ctx, st, nil)
32+
if err != nil {
33+
t.Fatal(err)
34+
}
35+
defer ss.Close()
36+
cs, err := client.Connect(ctx, ct, nil)
37+
if err != nil {
38+
t.Fatal(err)
39+
}
40+
defer cs.Close()
41+
42+
res, err := cs.ListResources(ctx, nil)
43+
if err != nil {
44+
t.Fatal(err)
45+
}
46+
want := []*Resource{{URI: "dynamic://a"}}
47+
if diff := cmp.Diff(want, res.Resources); diff != "" {
48+
t.Fatalf("first page mismatch (-want +got):\n%s", diff)
49+
}
50+
if gotCursor != "" {
51+
t.Fatalf("first page cursor = %q, want empty", gotCursor)
52+
}
53+
if res.NextCursor != "page2" {
54+
t.Fatalf("NextCursor = %q, want page2", res.NextCursor)
55+
}
56+
57+
res2, err := cs.ListResources(ctx, &ListResourcesParams{Cursor: "page2"})
58+
if err != nil {
59+
t.Fatal(err)
60+
}
61+
if gotCursor != "page2" {
62+
t.Fatalf("second page cursor = %q, want page2", gotCursor)
63+
}
64+
if len(res2.Resources) != 0 {
65+
t.Fatalf("second page resources = %v, want empty", res2.Resources)
66+
}
67+
}
68+
69+
func TestListResourcesHandlerComposeWithStatic(t *testing.T) {
70+
ctx := context.Background()
71+
handlerCalls := 0
72+
server := NewServer(testImpl, &ServerOptions{
73+
PageSize: 1,
74+
ListResourcesHandler: func(_ context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) {
75+
handlerCalls++
76+
if req.Params.Cursor != "" {
77+
t.Fatalf("handler cursor = %q, want empty on first handler page", req.Params.Cursor)
78+
}
79+
return &ListResourcesResult{
80+
Resources: []*Resource{{URI: "dynamic://x"}},
81+
}, nil
82+
},
83+
})
84+
server.AddResource(&Resource{URI: "static://a"}, nil)
85+
server.AddResource(&Resource{URI: "static://b"}, nil)
86+
87+
client := NewClient(testImpl, nil)
88+
st, ct := NewInMemoryTransports()
89+
ss, err := server.Connect(ctx, st, nil)
90+
if err != nil {
91+
t.Fatal(err)
92+
}
93+
defer ss.Close()
94+
cs, err := client.Connect(ctx, ct, nil)
95+
if err != nil {
96+
t.Fatal(err)
97+
}
98+
defer cs.Close()
99+
100+
page1, err := cs.ListResources(ctx, nil)
101+
if err != nil {
102+
t.Fatal(err)
103+
}
104+
if len(page1.Resources) != 1 || page1.Resources[0].URI != "static://a" {
105+
t.Fatalf("page1 = %v, want static://a", page1.Resources)
106+
}
107+
if page1.NextCursor == "" {
108+
t.Fatal("page1 NextCursor empty, want more pages")
109+
}
110+
111+
page2, err := cs.ListResources(ctx, &ListResourcesParams{Cursor: page1.NextCursor})
112+
if err != nil {
113+
t.Fatal(err)
114+
}
115+
if len(page2.Resources) != 1 || page2.Resources[0].URI != "static://b" {
116+
t.Fatalf("page2 = %v, want static://b", page2.Resources)
117+
}
118+
if page2.NextCursor == "" {
119+
t.Fatal("page2 NextCursor empty, want handler phase")
120+
}
121+
122+
page3, err := cs.ListResources(ctx, &ListResourcesParams{Cursor: page2.NextCursor})
123+
if err != nil {
124+
t.Fatal(err)
125+
}
126+
if len(page3.Resources) != 1 || page3.Resources[0].URI != "dynamic://x" {
127+
t.Fatalf("page3 = %v, want dynamic://x", page3.Resources)
128+
}
129+
if handlerCalls != 1 {
130+
t.Fatalf("handler calls = %d, want 1", handlerCalls)
131+
}
132+
}
133+
134+
func TestListResourcesHandlerCapability(t *testing.T) {
135+
server := NewServer(testImpl, &ServerOptions{
136+
ListResourcesHandler: func(context.Context, *ListResourcesRequest) (*ListResourcesResult, error) {
137+
return &ListResourcesResult{Resources: []*Resource{}}, nil
138+
},
139+
})
140+
caps := server.capabilities()
141+
if caps.Resources == nil {
142+
t.Fatal("expected resources capability when ListResourcesHandler is set")
143+
}
144+
}

mcp/server.go

Lines changed: 160 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"path/filepath"
2121
"reflect"
2222
"slices"
23+
"strings"
2324
"sync"
2425
"sync/atomic"
2526
"time"
@@ -90,6 +91,9 @@ type ServerOptions struct {
9091
SubscribeHandler func(context.Context, *SubscribeRequest) error
9192
// Function called when a client session unsubscribes from a resource.
9293
UnsubscribeHandler func(context.Context, *UnsubscribeRequest) error
94+
// ListResourcesHandler, if non-nil, serves resources/list dynamically.
95+
// See [ListResourcesHandler] for semantics.
96+
ListResourcesHandler ListResourcesHandler
9397

9498
// Capabilities optionally configures the server's default capabilities,
9599
// before any capabilities are inferred from other configuration or server
@@ -621,7 +625,7 @@ func (s *Server) capabilities() *ServerCapabilities {
621625
}
622626

623627
// Augment with resources capability if resources/templates exist or legacy HasResources is set.
624-
if s.opts.HasResources || s.resources.len() > 0 || s.resourceTemplates.len() > 0 {
628+
if s.opts.HasResources || s.resources.len() > 0 || s.resourceTemplates.len() > 0 || s.opts.ListResourcesHandler != nil {
625629
if caps.Resources == nil {
626630
caps.Resources = &ResourceCapabilities{ListChanged: true}
627631
}
@@ -854,18 +858,165 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR
854858
return res, err
855859
}
856860

857-
func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) {
858-
s.mu.Lock()
859-
defer s.mu.Unlock()
861+
func (s *Server) listResources(ctx context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) {
860862
if req.Params == nil {
861863
req.Params = &ListResourcesParams{}
862864
}
863-
return paginateList(s.resources, s.opts.PageSize, req.Params, &ListResourcesResult{}, func(res *ListResourcesResult, resources []*serverResource) {
864-
res.Resources = []*Resource{} // avoid JSON null
865-
for _, r := range resources {
866-
res.Resources = append(res.Resources, r.resource)
865+
if s.opts.ListResourcesHandler == nil {
866+
s.mu.Lock()
867+
defer s.mu.Unlock()
868+
return paginateList(s.resources, s.opts.PageSize, req.Params, &ListResourcesResult{}, populateListResourcesResult)
869+
}
870+
return s.listResourcesWithHandler(ctx, req)
871+
}
872+
873+
func populateListResourcesResult(res *ListResourcesResult, resources []*serverResource) {
874+
res.Resources = []*Resource{} // avoid JSON null
875+
for _, r := range resources {
876+
res.Resources = append(res.Resources, r.resource)
877+
}
878+
}
879+
880+
const (
881+
listResourcesPhaseStatic = "static"
882+
listResourcesPhaseHandler = "handler"
883+
listResourcesCursorPrefix = "lr1:"
884+
)
885+
886+
type listResourcesCursor struct {
887+
Phase string
888+
StaticCursor string
889+
HandlerCursor string
890+
}
891+
892+
func encodeListResourcesCursor(c listResourcesCursor) (string, error) {
893+
var buf bytes.Buffer
894+
if err := gob.NewEncoder(&buf).Encode(c); err != nil {
895+
return "", fmt.Errorf("failed to encode list resources cursor: %w", err)
896+
}
897+
return listResourcesCursorPrefix + base64.URLEncoding.EncodeToString(buf.Bytes()), nil
898+
}
899+
900+
func decodeListResourcesCursor(cursor string) (*listResourcesCursor, error) {
901+
if cursor == "" {
902+
return &listResourcesCursor{Phase: listResourcesPhaseStatic}, nil
903+
}
904+
if !strings.HasPrefix(cursor, listResourcesCursorPrefix) {
905+
return nil, fmt.Errorf("not a list resources cursor")
906+
}
907+
decoded, err := base64.URLEncoding.DecodeString(cursor[len(listResourcesCursorPrefix):])
908+
if err != nil {
909+
return nil, fmt.Errorf("failed to decode list resources cursor: %w", err)
910+
}
911+
var c listResourcesCursor
912+
if err := gob.NewDecoder(bytes.NewReader(decoded)).Decode(&c); err != nil {
913+
return nil, fmt.Errorf("failed to decode list resources cursor: %w", err)
914+
}
915+
return &c, nil
916+
}
917+
918+
func normalizeListResourcesResult(res *ListResourcesResult) *ListResourcesResult {
919+
if res == nil {
920+
return &ListResourcesResult{Resources: []*Resource{}}
921+
}
922+
if res.Resources == nil {
923+
res2 := *res
924+
res2.Resources = []*Resource{}
925+
return &res2
926+
}
927+
return res
928+
}
929+
930+
func (s *Server) listResourcesWithHandler(ctx context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) {
931+
handler := s.opts.ListResourcesHandler
932+
933+
s.mu.Lock()
934+
hasStatic := s.resources.len() > 0
935+
pageSize := s.opts.PageSize
936+
s.mu.Unlock()
937+
938+
if !hasStatic {
939+
res, err := handler(ctx, req)
940+
if err != nil {
941+
return nil, err
867942
}
868-
})
943+
return normalizeListResourcesResult(res), nil
944+
}
945+
946+
phase, err := decodeListResourcesCursor(req.Params.Cursor)
947+
if err != nil {
948+
return nil, jsonrpc2.ErrInvalidParams
949+
}
950+
951+
if phase.Phase == listResourcesPhaseHandler {
952+
handlerReq := &ListResourcesRequest{
953+
Session: req.Session,
954+
Params: &ListResourcesParams{Meta: req.Params.Meta, Cursor: phase.HandlerCursor},
955+
}
956+
res, err := handler(ctx, handlerReq)
957+
if err != nil {
958+
return nil, err
959+
}
960+
res = normalizeListResourcesResult(res)
961+
if res.NextCursor != "" {
962+
res.NextCursor, err = encodeListResourcesCursor(listResourcesCursor{
963+
Phase: listResourcesPhaseHandler,
964+
HandlerCursor: res.NextCursor,
965+
})
966+
if err != nil {
967+
return nil, err
968+
}
969+
}
970+
return res, nil
971+
}
972+
973+
s.mu.Lock()
974+
staticParams := &ListResourcesParams{Meta: req.Params.Meta, Cursor: phase.StaticCursor}
975+
res, err := paginateList(s.resources, pageSize, staticParams, &ListResourcesResult{}, populateListResourcesResult)
976+
s.mu.Unlock()
977+
if err != nil {
978+
return nil, err
979+
}
980+
981+
if res.NextCursor != "" {
982+
res.NextCursor, err = encodeListResourcesCursor(listResourcesCursor{
983+
Phase: listResourcesPhaseStatic,
984+
StaticCursor: res.NextCursor,
985+
})
986+
if err != nil {
987+
return nil, err
988+
}
989+
return res, nil
990+
}
991+
992+
if len(res.Resources) > 0 {
993+
res.NextCursor, err = encodeListResourcesCursor(listResourcesCursor{Phase: listResourcesPhaseHandler})
994+
if err != nil {
995+
return nil, err
996+
}
997+
return res, nil
998+
}
999+
1000+
handlerReq := &ListResourcesRequest{
1001+
Session: req.Session,
1002+
Params: &ListResourcesParams{Meta: req.Params.Meta},
1003+
}
1004+
hRes, err := handler(ctx, handlerReq)
1005+
if err != nil {
1006+
return nil, err
1007+
}
1008+
hRes = normalizeListResourcesResult(hRes)
1009+
1010+
if hRes.NextCursor != "" {
1011+
hRes.NextCursor, err = encodeListResourcesCursor(listResourcesCursor{
1012+
Phase: listResourcesPhaseHandler,
1013+
HandlerCursor: hRes.NextCursor,
1014+
})
1015+
if err != nil {
1016+
return nil, err
1017+
}
1018+
}
1019+
return hRes, nil
8691020
}
8701021

8711022
func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTemplatesRequest) (*ListResourceTemplatesResult, error) {

0 commit comments

Comments
 (0)