Skip to content

Commit 20ba791

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 dd97816 commit 20ba791

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
@@ -617,7 +621,7 @@ func (s *Server) capabilities() *ServerCapabilities {
617621
}
618622

619623
// Augment with resources capability if resources/templates exist or legacy HasResources is set.
620-
if s.opts.HasResources || s.resources.len() > 0 || s.resourceTemplates.len() > 0 {
624+
if s.opts.HasResources || s.resources.len() > 0 || s.resourceTemplates.len() > 0 || s.opts.ListResourcesHandler != nil {
621625
if caps.Resources == nil {
622626
caps.Resources = &ResourceCapabilities{ListChanged: true}
623627
}
@@ -850,18 +854,165 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR
850854
return res, err
851855
}
852856

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

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

0 commit comments

Comments
 (0)