Skip to content

Commit 5f94b1e

Browse files
committed
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 65ece59 commit 5f94b1e

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"
@@ -104,6 +105,9 @@ type ServerOptions struct {
104105
SubscribeHandler func(context.Context, *SubscribeRequest) error
105106
// Function called when a client session unsubscribes from a resource.
106107
UnsubscribeHandler func(context.Context, *UnsubscribeRequest) error
108+
// ListResourcesHandler, if non-nil, serves resources/list dynamically.
109+
// See [ListResourcesHandler] for semantics.
110+
ListResourcesHandler ListResourcesHandler
107111

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

644648
// Augment with resources capability if resources/templates exist or legacy HasResources is set.
645-
if s.opts.HasResources || s.resources.len() > 0 || s.resourceTemplates.len() > 0 {
649+
if s.opts.HasResources || s.resources.len() > 0 || s.resourceTemplates.len() > 0 || s.opts.ListResourcesHandler != nil {
646650
if caps.Resources == nil {
647651
caps.Resources = &ResourceCapabilities{ListChanged: true}
648652
}
@@ -953,25 +957,177 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR
953957
return res, err
954958
}
955959

956-
func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) {
957-
s.mu.Lock()
958-
defer s.mu.Unlock()
960+
func (s *Server) listResources(ctx context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) {
959961
if req.Params == nil {
960962
req.Params = &ListResourcesParams{}
961963
}
962-
res, err := paginateList(s.resources, s.opts.PageSize, req.Params, &ListResourcesResult{}, func(res *ListResourcesResult, resources []*serverResource) {
963-
res.Resources = []*Resource{} // avoid JSON null
964-
for _, r := range resources {
965-
res.Resources = append(res.Resources, r.resource)
966-
}
967-
})
964+
var (
965+
res *ListResourcesResult
966+
err error
967+
)
968+
if s.opts.ListResourcesHandler == nil {
969+
s.mu.Lock()
970+
res, err = paginateList(s.resources, s.opts.PageSize, req.Params, &ListResourcesResult{}, populateListResourcesResult)
971+
s.mu.Unlock()
972+
} else {
973+
res, err = s.listResourcesWithHandler(ctx, req)
974+
}
968975
if err != nil {
969976
return nil, err
970977
}
971978
res.setDefaultCacheableValues()
972979
return res, nil
973980
}
974981

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

0 commit comments

Comments
 (0)