Skip to content
Merged
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
21 changes: 19 additions & 2 deletions mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -1386,26 +1386,43 @@ func (cs *ClientSession) ListResourceTemplates(ctx context.Context, params *List

// ReadResource asks the server to read a resource and return its contents.
func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceParams) (*ReadResourceResult, error) {
if cs.usesNewProtocol() {
// The cache is keyed by URI alone. A request that carries input responses
// or request state is a multi round-trip retry whose result depends on
// those inputs, so the caching specification keeps it out of the cache in
// both directions: it is not served from the cache and its result is not
// stored (2026-07-28, Caching, "Cache Key").
useCache := cs.usesNewProtocol() && !isMultiRoundTripRetry(params)
if useCache {
var uri string
if params != nil {
uri = params.URI
}
if result, ok := cs.readResourceCache.get(uri); ok {
return result, nil
}
}
if cs.usesNewProtocol() {
params = injectRequestMeta(cs, params)
}
result, err := handleSend[*ReadResourceResult](ctx, methodReadResource, newClientRequest(cs, orZero[Params](params)))
if err != nil {
return nil, err
}
if cs.usesNewProtocol() {
// A result the middleware obtained by retrying is excluded for the same
// reason, and an input-required result is an interim one that the
// specification declares not cacheable.
if useCache && !result.retried && !result.NeedsInput() {
cs.readResourceCache.put(params.URI, result)
}
return result, nil
}

// isMultiRoundTripRetry reports whether params carry the input responses or
// request state of a multi round-trip retry. A nil params is a plain read.
func isMultiRoundTripRetry(params *ReadResourceParams) bool {
return params != nil && (params.InputResponses != nil || params.RequestState != "")
}

func (cs *ClientSession) Complete(ctx context.Context, params *CompleteParams) (*CompleteResult, error) {
if cs.usesNewProtocol() {
params = injectRequestMeta(cs, params)
Expand Down
11 changes: 11 additions & 0 deletions mcp/mrtr.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ func asksForInput(res multiRoundTripResponse) bool {
return res.inputRequests() != nil || res.requestState() != ""
}

// A retriedResult is a result that can record that a multi round-trip retry
// produced it. The caching specification forbids caching such results, so
// the one cacheable multi round-trip result, [ReadResourceResult], implements
// it and [ClientSession.ReadResource] consults it.
type retriedResult interface {
markRetried()
}

// validateMultiRoundTripResult rejects a result that carries both content and
// input requests or a request state. [annotateResultType] labels the result
// later, in [ServerSession.handle], so this function does not set resultType.
Expand Down Expand Up @@ -91,6 +99,9 @@ func clientMultiRoundTripMiddleware() Middleware {
// (SEP-2322 requires at least one of the two fields), so the
// result type, not the map alone, decides whether to retry.
if reqMap == nil && !mrtrResult.NeedsInput() {
if r, ok := res.(retriedResult); ok && retries > 1 && !res.isNil() {
r.markRetried()
}
return res, nil
}
// A result with a requestState asks to continue, so it counts as
Expand Down
131 changes: 131 additions & 0 deletions mcp/mrtr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1310,3 +1310,134 @@ func mustConnectOldProtocol(t *testing.T, s *Server, clientOpts *ClientOptions)
})
return cs
}

// gatedResourceServer returns a server whose "test://gated" resource asks for
// an elicitation before it reveals its contents, and whose "test://plain"
// resource answers at once. Every result is marked cacheable for a minute.
// handlerCalls counts invocations of the gated handler.
func gatedResourceServer(handlerCalls *atomic.Int32) *Server {
srv := NewServer(testImpl, &ServerOptions{
SetCacheable: func(_ context.Context, _ Request, c *Cacheable) {
c.TTLMs = 60_000
c.CacheScope = "private"
},
})
srv.AddResource(&Resource{URI: "test://gated", Name: "gated"}, func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) {
handlerCalls.Add(1)
if len(req.Params.InputResponses) == 0 {
return &ReadResourceResult{
InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Reveal the document?"}},
RequestState: "gated-state",
}, nil
}
return &ReadResourceResult{
Contents: []*ResourceContents{{URI: req.Params.URI, Text: "the document"}},
}, nil
})
srv.AddResource(&Resource{URI: "test://plain", Name: "plain"}, func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) {
return &ReadResourceResult{
Contents: []*ResourceContents{{URI: req.Params.URI, Text: "plain"}},
}, nil
})
return srv
}

// TestMultiRoundTrip_ReadResource_RetryResultNotCached verifies that a
// resources/read result produced by an automatic multi round-trip retry is
// not cached: the caching specification says results of requests carrying
// inputResponses or requestState MUST NOT be cached, because they depend on
// inputs that are not part of the cache key. A second read of the same URI
// must therefore go back to the server and ask the user again, while a
// resource that needed no input stays cached.
func TestMultiRoundTrip_ReadResource_RetryResultNotCached(t *testing.T) {
ctx := context.Background()
var handlerCalls, elicitations atomic.Int32
cs := mustConnect(t, gatedResourceServer(&handlerCalls), &ClientOptions{
ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) {
elicitations.Add(1)
return &ElicitResult{Action: "accept"}, nil
},
})

for i := 1; i <= 2; i++ {
res, err := cs.ReadResource(ctx, &ReadResourceParams{URI: "test://gated"})
if err != nil {
t.Fatalf("read %d: ReadResource() error = %v", i, err)
}
if len(res.Contents) != 1 || res.Contents[0].Text != "the document" {
t.Fatalf("read %d: Contents = %+v, want the document", i, res.Contents)
}
// Each read is one input-required round plus one retry.
if got, want := handlerCalls.Load(), int32(2*i); got != want {
t.Errorf("read %d: handler calls = %d, want %d", i, got, want)
}
if got, want := elicitations.Load(), int32(i); got != want {
t.Errorf("read %d: elicitations = %d, want %d; the retry result was served from the cache", i, got, want)
}
}

// A result that needed no input is cacheable, so the second read of the
// plain resource is served without a round trip.
first, err := cs.ReadResource(ctx, &ReadResourceParams{URI: "test://plain"})
if err != nil {
t.Fatal(err)
}
second, err := cs.ReadResource(ctx, &ReadResourceParams{URI: "test://plain"})
if err != nil {
t.Fatal(err)
}
if first != second {
t.Error("second read of test://plain was not served from the cache")
}
}

// TestMultiRoundTrip_ReadResource_ManualRetryBypassesCache covers the
// caller-driven retry loop. An input-required result is not cacheable, the
// retry request carries parameters the cache key does not include and so
// must reach the server, and the retry's result must not be cached either.
func TestMultiRoundTrip_ReadResource_ManualRetryBypassesCache(t *testing.T) {
ctx := context.Background()
var handlerCalls atomic.Int32
cs := mustConnect(t, gatedResourceServer(&handlerCalls), &ClientOptions{
MultiRoundTrip: &MultiRoundTripOptions{Disabled: true},
})

interim, err := cs.ReadResource(ctx, &ReadResourceParams{URI: "test://gated"})
if err != nil {
t.Fatalf("ReadResource() error = %v", err)
}
if !interim.NeedsInput() {
t.Fatal("NeedsInput() = false, want true")
}

res, err := cs.ReadResource(ctx, &ReadResourceParams{
URI: "test://gated",
InputResponses: InputResponseMap{"confirm": &ElicitResult{Action: "accept"}},
RequestState: interim.RequestState,
})
if err != nil {
t.Fatalf("ReadResource() retry error = %v", err)
}
if res.NeedsInput() {
t.Fatal("retry: NeedsInput() = true, want false; the interim result was served from the cache")
}
if len(res.Contents) != 1 {
t.Fatalf("retry: len(Contents) = %d, want 1", len(res.Contents))
}
if got := handlerCalls.Load(); got != 2 {
t.Errorf("handler calls after the retry = %d, want 2", got)
}

// A fresh read must start the gate over instead of reusing the retry's
// contents.
again, err := cs.ReadResource(ctx, &ReadResourceParams{URI: "test://gated"})
if err != nil {
t.Fatalf("ReadResource() after retry error = %v", err)
}
if !again.NeedsInput() {
t.Error("read after the retry: NeedsInput() = false, want true; the retry result was cached")
}
if got := handlerCalls.Load(); got != 3 {
t.Errorf("handler calls after the third read = %d, want 3", got)
}
}
5 changes: 5 additions & 0 deletions mcp/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -1705,12 +1705,17 @@ type ReadResourceResult struct {
// resultType records whether the call finished or needs more client input.
// See [CallToolResult.NeedsInput].
resultType resultType
// retried records that the client middleware produced this result by
// retrying the request with input responses. Such a result depends on
// inputs that are not part of the cache key, so it must not be cached.
retried bool
}

func (*ReadResourceResult) isResult() {}
func (x *ReadResourceResult) isNil() bool { return x == nil }

func (r *ReadResourceResult) setResultType(rt resultType) { r.resultType = rt }
func (r *ReadResourceResult) markRetried() { r.retried = true }
func (r *ReadResourceResult) requestState() string { return r.RequestState }
func (r *ReadResourceResult) inputRequests() map[string]InputRequest {
if r == nil {
Expand Down
Loading