Skip to content

Commit 37b9de7

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 827f90b commit 37b9de7

3 files changed

Lines changed: 325 additions & 10 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: 166 additions & 10 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"
@@ -105,6 +106,9 @@ type ServerOptions struct {
105106
SubscribeHandler func(context.Context, *SubscribeRequest) error
106107
// Function called when a client session unsubscribes from a resource.
107108
UnsubscribeHandler func(context.Context, *UnsubscribeRequest) error
109+
// ListResourcesHandler, if non-nil, serves resources/list dynamically.
110+
// See [ListResourcesHandler] for semantics.
111+
ListResourcesHandler ListResourcesHandler
108112

109113
// Capabilities optionally configures the server's default capabilities,
110114
// before any capabilities are inferred from other configuration or server
@@ -643,7 +647,7 @@ func (s *Server) capabilities() *ServerCapabilities {
643647
}
644648

645649
// Augment with resources capability if resources/templates exist or legacy HasResources is set.
646-
if s.opts.HasResources || s.resources.len() > 0 || s.resourceTemplates.len() > 0 {
650+
if s.opts.HasResources || s.resources.len() > 0 || s.resourceTemplates.len() > 0 || s.opts.ListResourcesHandler != nil {
647651
if caps.Resources == nil {
648652
caps.Resources = &ResourceCapabilities{ListChanged: true}
649653
}
@@ -974,25 +978,177 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR
974978
return res, err
975979
}
976980

977-
func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) {
978-
s.mu.Lock()
979-
defer s.mu.Unlock()
981+
func (s *Server) listResources(ctx context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) {
980982
if req.Params == nil {
981983
req.Params = &ListResourcesParams{}
982984
}
983-
res, err := paginateList(s.resources, s.opts.PageSize, req.Params, &ListResourcesResult{}, func(res *ListResourcesResult, resources []*serverResource) {
984-
res.Resources = []*Resource{} // avoid JSON null
985-
for _, r := range resources {
986-
res.Resources = append(res.Resources, r.resource)
987-
}
988-
})
985+
var (
986+
res *ListResourcesResult
987+
err error
988+
)
989+
if s.opts.ListResourcesHandler == nil {
990+
s.mu.Lock()
991+
res, err = paginateList(s.resources, s.opts.PageSize, req.Params, &ListResourcesResult{}, populateListResourcesResult)
992+
s.mu.Unlock()
993+
} else {
994+
res, err = s.listResourcesWithHandler(ctx, req)
995+
}
989996
if err != nil {
990997
return nil, err
991998
}
992999
res.setDefaultCacheableValues()
9931000
return res, nil
9941001
}
9951002

1003+
func populateListResourcesResult(res *ListResourcesResult, resources []*serverResource) {
1004+
res.Resources = []*Resource{} // avoid JSON null
1005+
for _, r := range resources {
1006+
res.Resources = append(res.Resources, r.resource)
1007+
}
1008+
}
1009+
1010+
const (
1011+
listResourcesPhaseStatic = "static"
1012+
listResourcesPhaseHandler = "handler"
1013+
listResourcesCursorPrefix = "lr1:"
1014+
)
1015+
1016+
type listResourcesCursor struct {
1017+
Phase string
1018+
StaticCursor string
1019+
HandlerCursor string
1020+
}
1021+
1022+
func encodeListResourcesCursor(c listResourcesCursor) (string, error) {
1023+
var buf bytes.Buffer
1024+
if err := gob.NewEncoder(&buf).Encode(c); err != nil {
1025+
return "", fmt.Errorf("failed to encode list resources cursor: %w", err)
1026+
}
1027+
return listResourcesCursorPrefix + base64.URLEncoding.EncodeToString(buf.Bytes()), nil
1028+
}
1029+
1030+
func decodeListResourcesCursor(cursor string) (*listResourcesCursor, error) {
1031+
if cursor == "" {
1032+
return &listResourcesCursor{Phase: listResourcesPhaseStatic}, nil
1033+
}
1034+
if !strings.HasPrefix(cursor, listResourcesCursorPrefix) {
1035+
return nil, fmt.Errorf("not a list resources cursor")
1036+
}
1037+
decoded, err := base64.URLEncoding.DecodeString(cursor[len(listResourcesCursorPrefix):])
1038+
if err != nil {
1039+
return nil, fmt.Errorf("failed to decode list resources cursor: %w", err)
1040+
}
1041+
var c listResourcesCursor
1042+
if err := gob.NewDecoder(bytes.NewReader(decoded)).Decode(&c); err != nil {
1043+
return nil, fmt.Errorf("failed to decode list resources cursor: %w", err)
1044+
}
1045+
return &c, nil
1046+
}
1047+
1048+
func normalizeListResourcesResult(res *ListResourcesResult) *ListResourcesResult {
1049+
if res == nil {
1050+
return &ListResourcesResult{Resources: []*Resource{}}
1051+
}
1052+
if res.Resources == nil {
1053+
res2 := *res
1054+
res2.Resources = []*Resource{}
1055+
return &res2
1056+
}
1057+
return res
1058+
}
1059+
1060+
func (s *Server) listResourcesWithHandler(ctx context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) {
1061+
handler := s.opts.ListResourcesHandler
1062+
1063+
s.mu.Lock()
1064+
hasStatic := s.resources.len() > 0
1065+
pageSize := s.opts.PageSize
1066+
s.mu.Unlock()
1067+
1068+
if !hasStatic {
1069+
res, err := handler(ctx, req)
1070+
if err != nil {
1071+
return nil, err
1072+
}
1073+
return normalizeListResourcesResult(res), nil
1074+
}
1075+
1076+
phase, err := decodeListResourcesCursor(req.Params.Cursor)
1077+
if err != nil {
1078+
return nil, jsonrpc2.ErrInvalidParams
1079+
}
1080+
1081+
if phase.Phase == listResourcesPhaseHandler {
1082+
handlerReq := &ListResourcesRequest{
1083+
Session: req.Session,
1084+
Params: &ListResourcesParams{Meta: req.Params.Meta, Cursor: phase.HandlerCursor},
1085+
}
1086+
res, err := handler(ctx, handlerReq)
1087+
if err != nil {
1088+
return nil, err
1089+
}
1090+
res = normalizeListResourcesResult(res)
1091+
if res.NextCursor != "" {
1092+
res.NextCursor, err = encodeListResourcesCursor(listResourcesCursor{
1093+
Phase: listResourcesPhaseHandler,
1094+
HandlerCursor: res.NextCursor,
1095+
})
1096+
if err != nil {
1097+
return nil, err
1098+
}
1099+
}
1100+
return res, nil
1101+
}
1102+
1103+
s.mu.Lock()
1104+
staticParams := &ListResourcesParams{Meta: req.Params.Meta, Cursor: phase.StaticCursor}
1105+
res, err := paginateList(s.resources, pageSize, staticParams, &ListResourcesResult{}, populateListResourcesResult)
1106+
s.mu.Unlock()
1107+
if err != nil {
1108+
return nil, err
1109+
}
1110+
1111+
if res.NextCursor != "" {
1112+
res.NextCursor, err = encodeListResourcesCursor(listResourcesCursor{
1113+
Phase: listResourcesPhaseStatic,
1114+
StaticCursor: res.NextCursor,
1115+
})
1116+
if err != nil {
1117+
return nil, err
1118+
}
1119+
return res, nil
1120+
}
1121+
1122+
if len(res.Resources) > 0 {
1123+
res.NextCursor, err = encodeListResourcesCursor(listResourcesCursor{Phase: listResourcesPhaseHandler})
1124+
if err != nil {
1125+
return nil, err
1126+
}
1127+
return res, nil
1128+
}
1129+
1130+
handlerReq := &ListResourcesRequest{
1131+
Session: req.Session,
1132+
Params: &ListResourcesParams{Meta: req.Params.Meta},
1133+
}
1134+
hRes, err := handler(ctx, handlerReq)
1135+
if err != nil {
1136+
return nil, err
1137+
}
1138+
hRes = normalizeListResourcesResult(hRes)
1139+
1140+
if hRes.NextCursor != "" {
1141+
hRes.NextCursor, err = encodeListResourcesCursor(listResourcesCursor{
1142+
Phase: listResourcesPhaseHandler,
1143+
HandlerCursor: hRes.NextCursor,
1144+
})
1145+
if err != nil {
1146+
return nil, err
1147+
}
1148+
}
1149+
return hRes, nil
1150+
}
1151+
9961152
func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTemplatesRequest) (*ListResourceTemplatesResult, error) {
9971153
s.mu.Lock()
9981154
defer s.mu.Unlock()

0 commit comments

Comments
 (0)