Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions mcp/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
144 changes: 144 additions & 0 deletions mcp/resource_list_handler_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
169 changes: 160 additions & 9 deletions mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"path/filepath"
"reflect"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
}
Expand Down Expand Up @@ -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) {
Expand Down