From b6c25651bc068da0f016f49f5ba8b2a0203482c4 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 7 May 2026 12:37:15 +0000 Subject: [PATCH 1/9] feat: add Cacheable interface and support for TTL and cache scope to protocol results --- mcp/protocol.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/mcp/protocol.go b/mcp/protocol.go index 1646788a..a70136d9 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -756,11 +756,37 @@ func (x *ListPromptsParams) GetProgressToken() any { return getProgressToken(x) func (x *ListPromptsParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *ListPromptsParams) cursorPtr() *string { return &x.Cursor } +// CacheableResult is a result that supports a time-to-live (TTL) hint for +// client-side caching. +type CacheableResult interface { + Result + GetTTL() *int + GetCacheScope() string +} + +// Cacheable describes a result that supports a time-to-live (TTL) hint for +// client-side caching. +type Cacheable struct { + // A hint from the server indicating how long (in seconds) the + // client MAY cache this response before re-fetching. + TTL *int `json:"ttl,omitempty"` + + // Indicates the intended scope of the cached response. + CacheScope string `json:"cacheScope,omitempty"` +} + +// GetTTL returns the TTL hint. +func (c Cacheable) GetTTL() *int { return c.TTL } + +// GetCacheScope returns the cache scope. +func (c Cacheable) GetCacheScope() string { return c.CacheScope } + // The server's response to a prompts/list request from the client. type ListPromptsResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + Cacheable // An opaque token representing the pagination position after the last returned // result. If present, there may be more results available. NextCursor string `json:"nextCursor,omitempty"` @@ -789,6 +815,7 @@ type ListResourceTemplatesResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + Cacheable // An opaque token representing the pagination position after the last returned // result. If present, there may be more results available. NextCursor string `json:"nextCursor,omitempty"` @@ -817,6 +844,7 @@ type ListResourcesResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + Cacheable // An opaque token representing the pagination position after the last returned // result. If present, there may be more results available. NextCursor string `json:"nextCursor,omitempty"` @@ -867,6 +895,7 @@ type ListToolsResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + Cacheable // An opaque token representing the pagination position after the last returned // result. If present, there may be more results available. NextCursor string `json:"nextCursor,omitempty"` @@ -1097,6 +1126,7 @@ type ReadResourceResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + Cacheable Contents []*ResourceContents `json:"contents"` } From b5a9773e9cd13b73c6892ca5a02f64ea97ed2c59 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Mon, 11 May 2026 14:03:59 +0000 Subject: [PATCH 2/9] feat: implement client-side TTL caching for list and read results as per SEP-2549 --- mcp/client.go | 172 ++++++++++++++++++++++++++++++++++++++++++++---- mcp/protocol.go | 27 +++++--- 2 files changed, 180 insertions(+), 19 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 6e24c5a3..2e2dfb20 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -158,6 +158,11 @@ type ClientOptions struct { // If the peer fails to respond to pings originating from the keepalive check, // the session is automatically closed. KeepAlive time.Duration + // DisableCache disables client-side TTL caching of list and read results + // (SEP-2549). When true, every call to ListTools, ListPrompts, + // ListResources, ListResourceTemplates, and ReadResource makes a fresh + // request to the server regardless of any ttlMs hint. + DisableCache bool } // toolContextKeyType is the context key type for passing tool definitions @@ -299,6 +304,69 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp return cs, nil } +// methodCache is a per-method TTL cache for list results, as described in +// SEP-2549. Each entry is keyed by cursor (for paginated list methods) or URI +// (for resources/read). +type methodCache[R CacheableResult] struct { + mu sync.Mutex + cachedValues map[string]*cacheEntry[R] +} + +type cacheEntry[R any] struct { + result R + receivedAt time.Time + ttlMs int +} + +func (e *cacheEntry[R]) isValid() bool { + return time.Since(e.receivedAt) < time.Duration(e.ttlMs)*time.Millisecond +} + +func (mc *methodCache[R]) get(key string) (R, bool) { + mc.mu.Lock() + defer mc.mu.Unlock() + entry, ok := mc.cachedValues[key] + if !ok { + var zero R + return zero, false + } + if !entry.isValid() { + delete(mc.cachedValues, key) + var zero R + return zero, false + } + return entry.result, true +} + +func (mc *methodCache[R]) put(key string, result R) { + ttl := result.GetTTLMs() + if ttl <= 0 { + return + } + mc.mu.Lock() + defer mc.mu.Unlock() + if mc.cachedValues == nil { + mc.cachedValues = make(map[string]*cacheEntry[R]) + } + mc.cachedValues[key] = &cacheEntry[R]{ + result: result, + receivedAt: time.Now(), + ttlMs: ttl, + } +} + +func (mc *methodCache[R]) invalidate() { + mc.mu.Lock() + defer mc.mu.Unlock() + clear(mc.cachedValues) +} + +func (mc *methodCache[R]) invalidateKey(key string) { + mc.mu.Lock() + defer mc.mu.Unlock() + delete(mc.cachedValues, key) +} + // A ClientSession is a logical connection with an MCP server. Its // methods can be used to send requests or notifications to the server. Create // a session by calling [Client.Connect]. @@ -321,16 +389,20 @@ type ClientSession struct { // only set synchronously during Client.Connect. state clientSessionState + // Per-method TTL caches for list results (SEP-2549). + toolsCache methodCache[*ListToolsResult] + promptsCache methodCache[*ListPromptsResult] + resourcesCache methodCache[*ListResourcesResult] + resourceTemplatesCache methodCache[*ListResourceTemplatesResult] + readResourceCache methodCache[*ReadResourceResult] + + // Per-tool cache for CallTool context injection. + toolCacheMu sync.RWMutex + toolCache map[string]*Tool + // Pending URL elicitations waiting for completion notifications. pendingElicitationsMu sync.Mutex pendingElicitations map[string]chan struct{} - - // toolCacheMu guards toolCache. - toolCacheMu sync.RWMutex - // toolCache stores tool definitions keyed by name. - // It is used to look up x-mcp-header annotations when - // constructing Mcp-Param-* headers for tools/call requests. - toolCache map[string]*Tool } type clientSessionState struct { @@ -999,7 +1071,22 @@ func (cs *ClientSession) Ping(ctx context.Context, params *PingParams) error { // ListPrompts lists prompts that are currently available on the server. func (cs *ClientSession) ListPrompts(ctx context.Context, params *ListPromptsParams) (*ListPromptsResult, error) { - return handleSend[*ListPromptsResult](ctx, methodListPrompts, newClientRequest(cs, orZero[Params](params))) + if params != nil { + if result, ok := cs.promptsCache.get(params.Cursor); ok { + return result, nil + } + } + result, err := handleSend[*ListPromptsResult](ctx, methodListPrompts, newClientRequest(cs, orZero[Params](params))) + if err != nil { + if params != nil && params.Cursor != "" { + cs.promptsCache.invalidate() + } + return nil, err + } + if !cs.client.opts.DisableCache && params != nil { + cs.promptsCache.put(params.Cursor, result) + } + return result, nil } // GetPrompt gets a prompt from the server. @@ -1009,12 +1096,23 @@ func (cs *ClientSession) GetPrompt(ctx context.Context, params *GetPromptParams) // ListTools lists tools that are currently available on the server. func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) (*ListToolsResult, error) { + if params != nil { + if result, ok := cs.toolsCache.get(params.Cursor); ok { + return result, nil + } + } result, err := handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params))) if err != nil { + if params != nil && params.Cursor != "" { + cs.toolsCache.invalidate() + } return nil, err } result.Tools = filterValidTools(cs.client.opts.Logger, result.Tools) cs.cacheTools(result.Tools) + if !cs.client.opts.DisableCache && params != nil { + cs.toolsCache.put(params.Cursor, result) + } return result, nil } @@ -1042,17 +1140,56 @@ func (cs *ClientSession) SetLoggingLevel(ctx context.Context, params *SetLogging // ListResources lists the resources that are currently available on the server. func (cs *ClientSession) ListResources(ctx context.Context, params *ListResourcesParams) (*ListResourcesResult, error) { - return handleSend[*ListResourcesResult](ctx, methodListResources, newClientRequest(cs, orZero[Params](params))) + if params != nil { + if result, ok := cs.resourcesCache.get(params.Cursor); ok { + return result, nil + } + } + result, err := handleSend[*ListResourcesResult](ctx, methodListResources, newClientRequest(cs, orZero[Params](params))) + if err != nil { + if params != nil && params.Cursor != "" { + cs.resourcesCache.invalidate() + } + return nil, err + } + if !cs.client.opts.DisableCache && params != nil { + cs.resourcesCache.put(params.Cursor, result) + } + return result, nil } // ListResourceTemplates lists the resource templates that are currently available on the server. func (cs *ClientSession) ListResourceTemplates(ctx context.Context, params *ListResourceTemplatesParams) (*ListResourceTemplatesResult, error) { - return handleSend[*ListResourceTemplatesResult](ctx, methodListResourceTemplates, newClientRequest(cs, orZero[Params](params))) + if params != nil { + if result, ok := cs.resourceTemplatesCache.get(params.Cursor); ok { + return result, nil + } + } + result, err := handleSend[*ListResourceTemplatesResult](ctx, methodListResourceTemplates, newClientRequest(cs, orZero[Params](params))) + if err != nil { + return nil, err + } + if !cs.client.opts.DisableCache && params != nil { + cs.resourceTemplatesCache.put(params.Cursor, result) + } + return result, nil } // ReadResource asks the server to read a resource and return its contents. func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceParams) (*ReadResourceResult, error) { - return handleSend[*ReadResourceResult](ctx, methodReadResource, newClientRequest(cs, orZero[Params](params))) + if params != nil { + if result, ok := cs.readResourceCache.get(params.URI); ok { + return result, nil + } + } + result, err := handleSend[*ReadResourceResult](ctx, methodReadResource, newClientRequest(cs, orZero[Params](params))) + if err != nil { + return nil, err + } + if !cs.client.opts.DisableCache && params != nil { + cs.readResourceCache.put(params.URI, result) + } + return result, nil } func (cs *ClientSession) Complete(ctx context.Context, params *CompleteParams) (*CompleteResult, error) { @@ -1074,6 +1211,9 @@ func (cs *ClientSession) Unsubscribe(ctx context.Context, params *UnsubscribePar } func (c *Client) callToolChangedHandler(ctx context.Context, req *ToolListChangedRequest) (Result, error) { + if cs, ok := req.GetSession().(*ClientSession); ok { + cs.toolsCache.invalidate() + } if h := c.opts.ToolListChangedHandler; h != nil { h(ctx, req) } @@ -1081,6 +1221,9 @@ func (c *Client) callToolChangedHandler(ctx context.Context, req *ToolListChange } func (c *Client) callPromptChangedHandler(ctx context.Context, req *PromptListChangedRequest) (Result, error) { + if cs, ok := req.GetSession().(*ClientSession); ok { + cs.promptsCache.invalidate() + } if h := c.opts.PromptListChangedHandler; h != nil { h(ctx, req) } @@ -1088,6 +1231,10 @@ func (c *Client) callPromptChangedHandler(ctx context.Context, req *PromptListCh } func (c *Client) callResourceChangedHandler(ctx context.Context, req *ResourceListChangedRequest) (Result, error) { + if cs, ok := req.GetSession().(*ClientSession); ok { + cs.resourcesCache.invalidate() + cs.resourceTemplatesCache.invalidate() + } if h := c.opts.ResourceListChangedHandler; h != nil { h(ctx, req) } @@ -1095,6 +1242,9 @@ func (c *Client) callResourceChangedHandler(ctx context.Context, req *ResourceLi } func (c *Client) callResourceUpdatedHandler(ctx context.Context, req *ResourceUpdatedNotificationRequest) (Result, error) { + if cs, ok := req.GetSession().(*ClientSession); ok && req.Params != nil { + cs.readResourceCache.invalidateKey(req.Params.URI) + } if h := c.opts.ResourceUpdatedHandler; h != nil { h(ctx, req) } diff --git a/mcp/protocol.go b/mcp/protocol.go index a70136d9..d8ceca3b 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -760,23 +760,34 @@ func (x *ListPromptsParams) cursorPtr() *string { return &x.Cursor } // client-side caching. type CacheableResult interface { Result - GetTTL() *int + GetTTLMs() int GetCacheScope() string } // Cacheable describes a result that supports a time-to-live (TTL) hint for // client-side caching. type Cacheable struct { - // A hint from the server indicating how long (in seconds) the - // client MAY cache this response before re-fetching. - TTL *int `json:"ttl,omitempty"` + // A hint from the server indicating how long (in milliseconds) the + // client MAY cache this response before re-fetching. Semantics are + // analogous to HTTP Cache-Control max-age. + // + // If 0, the response SHOULD be considered immediately stale. + // If positive, the client SHOULD consider the result fresh for this + // many milliseconds after receiving the response. + TTLMs int `json:"ttlMs"` - // Indicates the intended scope of the cached response. + // Indicates the intended scope of the cached response, analogous to + // HTTP Cache-Control: public vs Cache-Control: private. + // + // "public": Any client or intermediary MAY cache and serve the response. + // "private": Only the requesting user's client MAY cache the response. + // + // Defaults to "public" if absent. CacheScope string `json:"cacheScope,omitempty"` } -// GetTTL returns the TTL hint. -func (c Cacheable) GetTTL() *int { return c.TTL } +// GetTTLMs returns the TTL hint in milliseconds. +func (c Cacheable) GetTTLMs() int { return c.TTLMs } // GetCacheScope returns the cache scope. func (c Cacheable) GetCacheScope() string { return c.CacheScope } @@ -1125,7 +1136,7 @@ func (x *ReadResourceParams) SetProgressToken(t any) { setProgressToken(x, t) } type ReadResourceResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. - Meta `json:"_meta,omitempty"` + Meta `json:"_meta,omitempty"` Cacheable Contents []*ResourceContents `json:"contents"` } From 5d99559561bf46c8d337dec84dd5d3d336242815 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Tue, 2 Jun 2026 17:56:21 +0000 Subject: [PATCH 3/9] refactor(mcp): unify tool definition lookup into toolsCache Remove the separate toolCache (name->*Tool map) used by CallTool to inject tool definitions into the request context for transport-layer features (x-mcp-header annotations). Instead, source tool definitions from the existing TTL cache (toolsCache) via a new ClientSession.lookupTool helper that walks cached ListToolsResult entries. toolsCache is now always populated by ListTools (independent of DisableCache and TTL hint), so the x-mcp-header feature keeps working even when caching is disabled. The TTL hint still governs whether ListTools returns a cached result on the next call. --- mcp/client.go | 75 ++++++++++++++++++++++++----------------- mcp/client_test.go | 84 +++++++++++++++++++++++++--------------------- 2 files changed, 89 insertions(+), 70 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 2e2dfb20..4e30f5e1 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -330,8 +330,7 @@ func (mc *methodCache[R]) get(key string) (R, bool) { var zero R return zero, false } - if !entry.isValid() { - delete(mc.cachedValues, key) + if entry.ttlMs <= 0 || !entry.isValid() { var zero R return zero, false } @@ -339,10 +338,6 @@ func (mc *methodCache[R]) get(key string) (R, bool) { } func (mc *methodCache[R]) put(key string, result R) { - ttl := result.GetTTLMs() - if ttl <= 0 { - return - } mc.mu.Lock() defer mc.mu.Unlock() if mc.cachedValues == nil { @@ -351,7 +346,18 @@ func (mc *methodCache[R]) put(key string, result R) { mc.cachedValues[key] = &cacheEntry[R]{ result: result, receivedAt: time.Now(), - ttlMs: ttl, + ttlMs: result.GetTTLMs(), + } +} + +// forEach calls f for each entry currently in the cache, regardless of TTL. +// This is used to look up data (such as a tool definition) that is derived +// from list results but not subject to TTL-based expiry on the lookup path. +func (mc *methodCache[R]) forEach(f func(R)) { + mc.mu.Lock() + defer mc.mu.Unlock() + for _, entry := range mc.cachedValues { + f(entry.result) } } @@ -396,10 +402,6 @@ type ClientSession struct { resourceTemplatesCache methodCache[*ListResourceTemplatesResult] readResourceCache methodCache[*ReadResourceResult] - // Per-tool cache for CallTool context injection. - toolCacheMu sync.RWMutex - toolCache map[string]*Tool - // Pending URL elicitations waiting for completion notifications. pendingElicitationsMu sync.Mutex pendingElicitations map[string]chan struct{} @@ -448,19 +450,25 @@ func (cs *ClientSession) Wait() error { return cs.conn.Wait() } -func (cs *ClientSession) cacheTools(tools []*Tool) { - cs.toolCacheMu.Lock() - defer cs.toolCacheMu.Unlock() - cs.toolCache = make(map[string]*Tool, len(tools)) - for _, tool := range tools { - cs.toolCache[tool.Name] = tool - } -} - -func (cs *ClientSession) getCachedTool(name string) *Tool { - cs.toolCacheMu.RLock() - defer cs.toolCacheMu.RUnlock() - return cs.toolCache[name] +// lookupTool returns the most recently seen definition of the tool with the +// given name across all cached ListTools results, or nil if no such tool has +// been seen. It is used by CallTool to inject the tool definition into the +// outgoing request context for transport-layer features (e.g. x-mcp-header +// param annotations). +func (cs *ClientSession) lookupTool(name string) *Tool { + var found *Tool + cs.toolsCache.forEach(func(r *ListToolsResult) { + if found != nil { + return + } + for _, t := range r.Tools { + if t.Name == name { + found = t + return + } + } + }) + return found } // registerElicitationWaiter registers a waiter for an elicitation complete @@ -1096,23 +1104,28 @@ func (cs *ClientSession) GetPrompt(ctx context.Context, params *GetPromptParams) // ListTools lists tools that are currently available on the server. func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) (*ListToolsResult, error) { + var cursor string if params != nil { - if result, ok := cs.toolsCache.get(params.Cursor); ok { + cursor = params.Cursor + } + if !cs.client.opts.DisableCache { + if result, ok := cs.toolsCache.get(cursor); ok { return result, nil } } result, err := handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params))) if err != nil { - if params != nil && params.Cursor != "" { + if cursor != "" { cs.toolsCache.invalidate() } return nil, err } result.Tools = filterValidTools(cs.client.opts.Logger, result.Tools) - cs.cacheTools(result.Tools) - if !cs.client.opts.DisableCache && params != nil { - cs.toolsCache.put(params.Cursor, result) - } + // Always cache the result so CallTool can look up tool definitions by name + // for transport-layer features (e.g. x-mcp-header annotations). The TTL + // hint controls whether a future ListTools call returns the cached value + // (see methodCache.get), independent of name lookup. + cs.toolsCache.put(cursor, result) return result, nil } @@ -1127,7 +1140,7 @@ func (cs *ClientSession) CallTool(ctx context.Context, params *CallToolParams) ( // Avoid sending nil over the wire. params.Arguments = map[string]any{} } - if tool := cs.getCachedTool(params.Name); tool != nil { + if tool := cs.lookupTool(params.Name); tool != nil { ctx = context.WithValue(ctx, toolContextKey, tool) } return handleSend[*CallToolResult](ctx, methodCallTool, newClientRequest(cs, orZero[Params](params))) diff --git a/mcp/client_test.go b/mcp/client_test.go index 609fd501..2fb7232c 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -440,16 +440,22 @@ func TestClientCapabilities(t *testing.T) { } } -func TestToolCache(t *testing.T) { +func TestLookupTool(t *testing.T) { tool1 := &Tool{Name: "tool1", Description: "first"} tool2 := &Tool{Name: "tool2", Description: "second"} tool1Updated := &Tool{Name: "tool1", Description: "updated"} + // page represents a single cached ListToolsResult, keyed by cursor. + type page struct { + cursor string + tools []*Tool + } + testCases := []struct { - name string - cacheBatches [][]*Tool - lookup string - want *Tool + name string + pages []page + lookup string + want *Tool }{ { name: "empty cache", @@ -457,58 +463,58 @@ func TestToolCache(t *testing.T) { want: nil, }, { - name: "single tool found", - cacheBatches: [][]*Tool{{tool1}}, - lookup: "tool1", - want: tool1, - }, - { - name: "unknown tool", - cacheBatches: [][]*Tool{{tool1}}, - lookup: "nonexistent", - want: nil, + name: "single tool found", + pages: []page{{cursor: "", tools: []*Tool{tool1}}}, + lookup: "tool1", + want: tool1, }, { - name: "multiple tools single batch", - cacheBatches: [][]*Tool{{tool1, tool2}}, - lookup: "tool2", - want: tool2, + name: "unknown tool", + pages: []page{{cursor: "", tools: []*Tool{tool1}}}, + lookup: "nonexistent", + want: nil, }, { - name: "replace clears old entries", - cacheBatches: [][]*Tool{{tool1}, {tool2}}, - lookup: "tool1", - want: nil, + name: "multiple tools single page", + pages: []page{{cursor: "", tools: []*Tool{tool1, tool2}}}, + lookup: "tool2", + want: tool2, }, { - name: "replace keeps new entries", - cacheBatches: [][]*Tool{{tool1}, {tool2}}, - lookup: "tool2", - want: tool2, + name: "tool found across paginated pages", + pages: []page{ + {cursor: "", tools: []*Tool{tool1}}, + {cursor: "page2", tools: []*Tool{tool2}}, + }, + lookup: "tool2", + want: tool2, }, { - name: "overwrite existing entry", - cacheBatches: [][]*Tool{{tool1}, {tool1Updated}}, - lookup: "tool1", - want: tool1Updated, + name: "re-list same cursor overwrites entry", + pages: []page{ + {cursor: "", tools: []*Tool{tool1}}, + {cursor: "", tools: []*Tool{tool1Updated}}, + }, + lookup: "tool1", + want: tool1Updated, }, { - name: "empty batch no-op", - cacheBatches: [][]*Tool{{}}, - lookup: "tool1", - want: nil, + name: "empty page no-op", + pages: []page{{cursor: "", tools: []*Tool{}}}, + lookup: "tool1", + want: nil, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { cs := &ClientSession{} - for _, batch := range tc.cacheBatches { - cs.cacheTools(batch) + for _, p := range tc.pages { + cs.toolsCache.put(p.cursor, &ListToolsResult{Tools: p.tools}) } - got := cs.getCachedTool(tc.lookup) + got := cs.lookupTool(tc.lookup) if diff := cmp.Diff(tc.want, got); diff != "" { - t.Errorf("getCachedTool(%q) mismatch (-want +got):\n%s", tc.lookup, diff) + t.Errorf("lookupTool(%q) mismatch (-want +got):\n%s", tc.lookup, diff) } }) } From b086429cfcd195a0463ab1b1232d128ac01529ce Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 4 Jun 2026 14:31:57 +0000 Subject: [PATCH 4/9] refactor: migrate client-side caching to a dedicated module and update server to support cache scope metadata. --- mcp/cache.go | 75 +++++++++ mcp/client.go | 152 ++++-------------- mcp/protocol.go | 2 +- mcp/server.go | 54 ++++++- .../conformance/server/lifecycle.txtar | 2 + mcp/testdata/conformance/server/prompts.txtar | 2 + .../conformance/server/resources.txtar | 3 + .../spec-sep-973-additional-metadata.txtar | 3 + mcp/testdata/conformance/server/tools.txtar | 3 + 9 files changed, 172 insertions(+), 124 deletions(-) create mode 100644 mcp/cache.go diff --git a/mcp/cache.go b/mcp/cache.go new file mode 100644 index 00000000..21dfff3c --- /dev/null +++ b/mcp/cache.go @@ -0,0 +1,75 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package mcp + +import ( + "sync" + "time" +) + +// methodCache is a per-method TTL cache for list and read results, as +// described in SEP-2549. Each entry is keyed by cursor (for paginated list +// methods) or URI (for resources/read). +type methodCache[R CacheableResult] struct { + mu sync.Mutex + cachedValues map[string]*cacheEntry[R] +} + +type cacheEntry[R CacheableResult] struct { + result R + receivedAt time.Time +} + +func (e *cacheEntry[R]) isValid() bool { + return time.Since(e.receivedAt) < time.Duration(e.result.GetTTLMs())*time.Millisecond +} + +func (mc *methodCache[R]) get(key string) (R, bool) { + mc.mu.Lock() + defer mc.mu.Unlock() + entry, ok := mc.cachedValues[key] + if !ok { + var zero R + return zero, false + } + if entry.result.GetTTLMs() <= 0 || !entry.isValid() { + delete(mc.cachedValues, key) + var zero R + return zero, false + } + return entry.result, true +} + +func (mc *methodCache[R]) put(key string, result R) { + mc.mu.Lock() + defer mc.mu.Unlock() + if mc.cachedValues == nil { + mc.cachedValues = make(map[string]*cacheEntry[R]) + } + mc.cachedValues[key] = &cacheEntry[R]{ + result: result, + receivedAt: time.Now(), + } +} + +func (mc *methodCache[R]) forEach(f func(R)) { + mc.mu.Lock() + defer mc.mu.Unlock() + for _, entry := range mc.cachedValues { + f(entry.result) + } +} + +func (mc *methodCache[R]) invalidate() { + mc.mu.Lock() + defer mc.mu.Unlock() + clear(mc.cachedValues) +} + +func (mc *methodCache[R]) invalidateKey(key string) { + mc.mu.Lock() + defer mc.mu.Unlock() + delete(mc.cachedValues, key) +} diff --git a/mcp/client.go b/mcp/client.go index 2b49968c..c4e46ace 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -174,11 +174,6 @@ type ClientOptions struct { // reset" guidance, letting a transient miss pass without tearing down an // otherwise live session. Has no effect unless KeepAlive is non-zero. KeepAliveFailureThreshold int - // DisableCache disables client-side TTL caching of list and read results - // (SEP-2549). When true, every call to ListTools, ListPrompts, - // ListResources, ListResourceTemplates, and ReadResource makes a fresh - // request to the server regardless of any ttlMs hint. - DisableCache bool } // toolContextKeyType is the context key type for passing tool definitions @@ -341,75 +336,6 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp return cs, nil } -// methodCache is a per-method TTL cache for list results, as described in -// SEP-2549. Each entry is keyed by cursor (for paginated list methods) or URI -// (for resources/read). -type methodCache[R CacheableResult] struct { - mu sync.Mutex - cachedValues map[string]*cacheEntry[R] -} - -type cacheEntry[R any] struct { - result R - receivedAt time.Time - ttlMs int -} - -func (e *cacheEntry[R]) isValid() bool { - return time.Since(e.receivedAt) < time.Duration(e.ttlMs)*time.Millisecond -} - -func (mc *methodCache[R]) get(key string) (R, bool) { - mc.mu.Lock() - defer mc.mu.Unlock() - entry, ok := mc.cachedValues[key] - if !ok { - var zero R - return zero, false - } - if entry.ttlMs <= 0 || !entry.isValid() { - var zero R - return zero, false - } - return entry.result, true -} - -func (mc *methodCache[R]) put(key string, result R) { - mc.mu.Lock() - defer mc.mu.Unlock() - if mc.cachedValues == nil { - mc.cachedValues = make(map[string]*cacheEntry[R]) - } - mc.cachedValues[key] = &cacheEntry[R]{ - result: result, - receivedAt: time.Now(), - ttlMs: result.GetTTLMs(), - } -} - -// forEach calls f for each entry currently in the cache, regardless of TTL. -// This is used to look up data (such as a tool definition) that is derived -// from list results but not subject to TTL-based expiry on the lookup path. -func (mc *methodCache[R]) forEach(f func(R)) { - mc.mu.Lock() - defer mc.mu.Unlock() - for _, entry := range mc.cachedValues { - f(entry.result) - } -} - -func (mc *methodCache[R]) invalidate() { - mc.mu.Lock() - defer mc.mu.Unlock() - clear(mc.cachedValues) -} - -func (mc *methodCache[R]) invalidateKey(key string) { - mc.mu.Lock() - defer mc.mu.Unlock() - delete(mc.cachedValues, key) -} - // discover sends a SEP-2575 server/discover request to probe the server for // stateless protocol support. // @@ -1211,25 +1137,25 @@ func (cs *ClientSession) Ping(ctx context.Context, params *PingParams) error { } // ListPrompts lists prompts that are currently available on the server. +// +// Results may be served from a client-side TTL cache populated by previous +// calls; see SEP-2549. func (cs *ClientSession) ListPrompts(ctx context.Context, params *ListPromptsParams) (*ListPromptsResult, error) { - if params != nil && !cs.client.opts.DisableCache { - if result, ok := cs.promptsCache.get(params.Cursor); ok { - return result, nil - } + var cursor string + if params != nil { + cursor = params.Cursor + } + if result, ok := cs.promptsCache.get(cursor); ok { + return result, nil } if cs.usesNewProtocol() { params = injectRequestMeta(cs, params) } result, err := handleSend[*ListPromptsResult](ctx, methodListPrompts, newClientRequest(cs, orZero[Params](params))) if err != nil { - if params != nil && params.Cursor != "" { - cs.promptsCache.invalidate() - } return nil, err } - if !cs.client.opts.DisableCache && params != nil { - cs.promptsCache.put(params.Cursor, result) - } + cs.promptsCache.put(cursor, result) return result, nil } @@ -1247,26 +1173,17 @@ func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) if params != nil { cursor = params.Cursor } - if !cs.client.opts.DisableCache { - if result, ok := cs.toolsCache.get(cursor); ok { - return result, nil - } + if result, ok := cs.toolsCache.get(cursor); ok { + return result, nil } if cs.usesNewProtocol() { params = injectRequestMeta(cs, params) } result, err := handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params))) if err != nil { - if cursor != "" { - cs.toolsCache.invalidate() - } return nil, err } result.Tools = filterValidTools(cs.client.opts.Logger, result.Tools) - // Always cache the result so CallTool can look up tool definitions by name - // for transport-layer features (e.g. x-mcp-header annotations). The TTL - // hint controls whether a future ListTools call returns the cached value - // (see methodCache.get), independent of name lookup. cs.toolsCache.put(cursor, result) return result, nil } @@ -1298,33 +1215,32 @@ func (cs *ClientSession) SetLoggingLevel(ctx context.Context, params *SetLogging // ListResources lists the resources that are currently available on the server. func (cs *ClientSession) ListResources(ctx context.Context, params *ListResourcesParams) (*ListResourcesResult, error) { - if params != nil && !cs.client.opts.DisableCache { - if result, ok := cs.resourcesCache.get(params.Cursor); ok { - return result, nil - } + var cursor string + if params != nil { + cursor = params.Cursor + } + if result, ok := cs.resourcesCache.get(cursor); ok { + return result, nil } if cs.usesNewProtocol() { params = injectRequestMeta(cs, params) } result, err := handleSend[*ListResourcesResult](ctx, methodListResources, newClientRequest(cs, orZero[Params](params))) if err != nil { - if params != nil && params.Cursor != "" { - cs.resourcesCache.invalidate() - } return nil, err } - if !cs.client.opts.DisableCache && params != nil { - cs.resourcesCache.put(params.Cursor, result) - } + cs.resourcesCache.put(cursor, result) return result, nil } // ListResourceTemplates lists the resource templates that are currently available on the server. func (cs *ClientSession) ListResourceTemplates(ctx context.Context, params *ListResourceTemplatesParams) (*ListResourceTemplatesResult, error) { - if params != nil && !cs.client.opts.DisableCache { - if result, ok := cs.resourceTemplatesCache.get(params.Cursor); ok { - return result, nil - } + var cursor string + if params != nil { + cursor = params.Cursor + } + if result, ok := cs.resourceTemplatesCache.get(cursor); ok { + return result, nil } if cs.usesNewProtocol() { params = injectRequestMeta(cs, params) @@ -1333,18 +1249,18 @@ func (cs *ClientSession) ListResourceTemplates(ctx context.Context, params *List if err != nil { return nil, err } - if !cs.client.opts.DisableCache && params != nil { - cs.resourceTemplatesCache.put(params.Cursor, result) - } + cs.resourceTemplatesCache.put(cursor, result) return result, nil } // ReadResource asks the server to read a resource and return its contents. func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceParams) (*ReadResourceResult, error) { - if params != nil && !cs.client.opts.DisableCache { - if result, ok := cs.readResourceCache.get(params.URI); ok { - return result, nil - } + 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) @@ -1353,9 +1269,7 @@ func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceP if err != nil { return nil, err } - if !cs.client.opts.DisableCache && params != nil { - cs.readResourceCache.put(params.URI, result) - } + cs.readResourceCache.put(uri, result) return result, nil } diff --git a/mcp/protocol.go b/mcp/protocol.go index 6f46855d..015e4c71 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1118,7 +1118,7 @@ type Cacheable struct { // If 0, the response SHOULD be considered immediately stale. // If positive, the client SHOULD consider the result fresh for this // many milliseconds after receiving the response. - TTLMs int `json:"ttlMs"` + TTLMs int `json:"ttlMs,omitempty"` // Indicates the intended scope of the cached response, analogous to // HTTP Cache-Control: public vs Cache-Control: private. diff --git a/mcp/server.go b/mcp/server.go index ba03fbfe..e9690f94 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -154,6 +154,23 @@ type ServerOptions struct { // GetSessionID is not consulted when [StreamableHTTPOptions.Stateless] is // true, since stateless servers do not maintain sessions. GetSessionID func() string + + // CacheControl, if non-nil, is called for each response to a cacheable + // method (tools/list, prompts/list, resources/list, + // resources/templates/list, resources/read) to populate the SEP-2549 + // `ttlMs` and `cacheScope` fields. It is invoked after the result has + // been assembled but before it is returned to the client. + // + // The concrete type of req.GetParams() identifies the method (for + // example, *ListToolsParams for tools/list or *ReadResourceParams for + // resources/read), and res holds the assembled response (e.g. + // *ListToolsResult), allowing the policy to depend on response + // contents as well as the request. + // + // If CacheControl is nil, the response is sent with the safe default + // `Cacheable{CacheScope: "public"}` (TTLMs: 0), which tells clients to + // treat the result as immediately stale and not cache it. + CacheControl func(req Request, res Result) Cacheable } // NewServer creates a new MCP server. The resulting server has no features: @@ -730,18 +747,31 @@ func (s *Server) Sessions() iter.Seq[*ServerSession] { return slices.Values(clients) } +func (s *Server) applyCacheControl(req Request, res Result, c *Cacheable) { + if s.opts.CacheControl != nil { + *c = s.opts.CacheControl(req, res) + return + } + *c = Cacheable{CacheScope: "public"} +} + func (s *Server) listPrompts(_ context.Context, req *ListPromptsRequest) (*ListPromptsResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { req.Params = &ListPromptsParams{} } - return paginateList(s.prompts, s.opts.PageSize, req.Params, &ListPromptsResult{}, func(res *ListPromptsResult, prompts []*serverPrompt) { + res, err := paginateList(s.prompts, s.opts.PageSize, req.Params, &ListPromptsResult{}, func(res *ListPromptsResult, prompts []*serverPrompt) { res.Prompts = []*Prompt{} // avoid JSON null for _, p := range prompts { res.Prompts = append(res.Prompts, p.prompt) } }) + if err != nil { + return nil, err + } + s.applyCacheControl(req, res, &res.Cacheable) + return res, nil } func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetPromptResult, error) { @@ -777,12 +807,17 @@ func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListTools if req.Params == nil { req.Params = &ListToolsParams{} } - return paginateList(s.tools, s.opts.PageSize, req.Params, &ListToolsResult{}, func(res *ListToolsResult, tools []*serverTool) { + res, err := paginateList(s.tools, s.opts.PageSize, req.Params, &ListToolsResult{}, func(res *ListToolsResult, tools []*serverTool) { res.Tools = []*Tool{} // avoid JSON null for _, t := range tools { res.Tools = append(res.Tools, t.tool) } }) + if err != nil { + return nil, err + } + s.applyCacheControl(req, res, &res.Cacheable) + return res, nil } // getServerTool looks up a server tool by name. @@ -820,12 +855,17 @@ func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*L if req.Params == nil { req.Params = &ListResourcesParams{} } - return paginateList(s.resources, s.opts.PageSize, req.Params, &ListResourcesResult{}, func(res *ListResourcesResult, resources []*serverResource) { + res, err := 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 err != nil { + return nil, err + } + s.applyCacheControl(req, res, &res.Cacheable) + return res, nil } func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTemplatesRequest) (*ListResourceTemplatesResult, error) { @@ -834,13 +874,18 @@ func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTempl if req.Params == nil { req.Params = &ListResourceTemplatesParams{} } - return paginateList(s.resourceTemplates, s.opts.PageSize, req.Params, &ListResourceTemplatesResult{}, + res, err := paginateList(s.resourceTemplates, s.opts.PageSize, req.Params, &ListResourceTemplatesResult{}, func(res *ListResourceTemplatesResult, rts []*serverResourceTemplate) { res.ResourceTemplates = []*ResourceTemplate{} // avoid JSON null for _, rt := range rts { res.ResourceTemplates = append(res.ResourceTemplates, rt.resourceTemplate) } }) + if err != nil { + return nil, err + } + s.applyCacheControl(req, res, &res.Cacheable) + return res, nil } func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { @@ -875,6 +920,7 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R c.MIMEType = mimeType } } + s.applyCacheControl(req, res, &res.Cacheable) return res, nil } diff --git a/mcp/testdata/conformance/server/lifecycle.txtar b/mcp/testdata/conformance/server/lifecycle.txtar index 0a8cf34b..64298541 100644 --- a/mcp/testdata/conformance/server/lifecycle.txtar +++ b/mcp/testdata/conformance/server/lifecycle.txtar @@ -53,6 +53,7 @@ See also modelcontextprotocol/go-sdk#225. "jsonrpc": "2.0", "id": 2, "result": { + "cacheScope": "public", "tools": [] } } @@ -60,6 +61,7 @@ See also modelcontextprotocol/go-sdk#225. "jsonrpc": "2.0", "id": 3, "result": { + "cacheScope": "public", "tools": [] } } diff --git a/mcp/testdata/conformance/server/prompts.txtar b/mcp/testdata/conformance/server/prompts.txtar index fdaf7932..2825f985 100644 --- a/mcp/testdata/conformance/server/prompts.txtar +++ b/mcp/testdata/conformance/server/prompts.txtar @@ -45,6 +45,7 @@ code_review "jsonrpc": "2.0", "id": 2, "result": { + "cacheScope": "public", "tools": [] } } @@ -52,6 +53,7 @@ code_review "jsonrpc": "2.0", "id": 4, "result": { + "cacheScope": "public", "prompts": [ { "arguments": [ diff --git a/mcp/testdata/conformance/server/resources.txtar b/mcp/testdata/conformance/server/resources.txtar index 314817b8..34db4759 100644 --- a/mcp/testdata/conformance/server/resources.txtar +++ b/mcp/testdata/conformance/server/resources.txtar @@ -67,6 +67,7 @@ info.txt "jsonrpc": "2.0", "id": 2, "result": { + "cacheScope": "public", "resources": [ { "mimeType": "text/plain", @@ -85,6 +86,7 @@ info.txt "jsonrpc": "2.0", "id": 3, "result": { + "cacheScope": "public", "contents": [ { "uri": "embedded:info", @@ -103,6 +105,7 @@ info.txt "jsonrpc": "2.0", "id": 3, "result": { + "cacheScope": "public", "contents": [ { "uri": "file:///info.txt", diff --git a/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar b/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar index 2add92ef..8dd0f101 100644 --- a/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar +++ b/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar @@ -87,6 +87,7 @@ infoWithIcon "jsonrpc": "2.0", "id": 2, "result": { + "cacheScope": "public", "tools": [ { "description": "return resourceLink content with Icon", @@ -140,6 +141,7 @@ infoWithIcon "jsonrpc": "2.0", "id": 3, "result": { + "cacheScope": "public", "resources": [ { "mimeType": "text/plain", @@ -164,6 +166,7 @@ infoWithIcon "jsonrpc": "2.0", "id": 4, "result": { + "cacheScope": "public", "prompts": [ { "arguments": [ diff --git a/mcp/testdata/conformance/server/tools.txtar b/mcp/testdata/conformance/server/tools.txtar index aadad122..4d77ddfb 100644 --- a/mcp/testdata/conformance/server/tools.txtar +++ b/mcp/testdata/conformance/server/tools.txtar @@ -64,6 +64,7 @@ inc "jsonrpc": "2.0", "id": 2, "result": { + "cacheScope": "public", "tools": [ { "description": "say hi", @@ -169,6 +170,7 @@ inc "jsonrpc": "2.0", "id": 3, "result": { + "cacheScope": "public", "resources": [] } } @@ -176,6 +178,7 @@ inc "jsonrpc": "2.0", "id": 4, "result": { + "cacheScope": "public", "prompts": [] } } From e484ef745ea1c4a04161118496a8dea1bf67ad3a Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 4 Jun 2026 20:34:21 +0000 Subject: [PATCH 5/9] feat: enforce explicit TTLMs in cacheable responses --- mcp/protocol.go | 4 ++-- mcp/server.go | 2 +- mcp/testdata/conformance/server/lifecycle.txtar | 2 ++ mcp/testdata/conformance/server/prompts.txtar | 2 ++ mcp/testdata/conformance/server/resources.txtar | 3 +++ .../conformance/server/spec-sep-973-additional-metadata.txtar | 3 +++ mcp/testdata/conformance/server/tools.txtar | 3 +++ 7 files changed, 16 insertions(+), 3 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index 015e4c71..2d931713 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1118,7 +1118,7 @@ type Cacheable struct { // If 0, the response SHOULD be considered immediately stale. // If positive, the client SHOULD consider the result fresh for this // many milliseconds after receiving the response. - TTLMs int `json:"ttlMs,omitempty"` + TTLMs int `json:"ttlMs"` // Indicates the intended scope of the cached response, analogous to // HTTP Cache-Control: public vs Cache-Control: private. @@ -1127,7 +1127,7 @@ type Cacheable struct { // "private": Only the requesting user's client MAY cache the response. // // Defaults to "public" if absent. - CacheScope string `json:"cacheScope,omitempty"` + CacheScope string `json:"cacheScope"` } // GetTTLMs returns the TTL hint in milliseconds. diff --git a/mcp/server.go b/mcp/server.go index e9690f94..ce6d5826 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -752,7 +752,7 @@ func (s *Server) applyCacheControl(req Request, res Result, c *Cacheable) { *c = s.opts.CacheControl(req, res) return } - *c = Cacheable{CacheScope: "public"} + *c = Cacheable{TTLMs: 0, CacheScope: "public"} } func (s *Server) listPrompts(_ context.Context, req *ListPromptsRequest) (*ListPromptsResult, error) { diff --git a/mcp/testdata/conformance/server/lifecycle.txtar b/mcp/testdata/conformance/server/lifecycle.txtar index 64298541..652aa7f3 100644 --- a/mcp/testdata/conformance/server/lifecycle.txtar +++ b/mcp/testdata/conformance/server/lifecycle.txtar @@ -53,6 +53,7 @@ See also modelcontextprotocol/go-sdk#225. "jsonrpc": "2.0", "id": 2, "result": { + "ttlMs": 0, "cacheScope": "public", "tools": [] } @@ -61,6 +62,7 @@ See also modelcontextprotocol/go-sdk#225. "jsonrpc": "2.0", "id": 3, "result": { + "ttlMs": 0, "cacheScope": "public", "tools": [] } diff --git a/mcp/testdata/conformance/server/prompts.txtar b/mcp/testdata/conformance/server/prompts.txtar index 2825f985..2d8e1686 100644 --- a/mcp/testdata/conformance/server/prompts.txtar +++ b/mcp/testdata/conformance/server/prompts.txtar @@ -45,6 +45,7 @@ code_review "jsonrpc": "2.0", "id": 2, "result": { + "ttlMs": 0, "cacheScope": "public", "tools": [] } @@ -53,6 +54,7 @@ code_review "jsonrpc": "2.0", "id": 4, "result": { + "ttlMs": 0, "cacheScope": "public", "prompts": [ { diff --git a/mcp/testdata/conformance/server/resources.txtar b/mcp/testdata/conformance/server/resources.txtar index 34db4759..1c3ed809 100644 --- a/mcp/testdata/conformance/server/resources.txtar +++ b/mcp/testdata/conformance/server/resources.txtar @@ -67,6 +67,7 @@ info.txt "jsonrpc": "2.0", "id": 2, "result": { + "ttlMs": 0, "cacheScope": "public", "resources": [ { @@ -86,6 +87,7 @@ info.txt "jsonrpc": "2.0", "id": 3, "result": { + "ttlMs": 0, "cacheScope": "public", "contents": [ { @@ -105,6 +107,7 @@ info.txt "jsonrpc": "2.0", "id": 3, "result": { + "ttlMs": 0, "cacheScope": "public", "contents": [ { diff --git a/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar b/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar index 8dd0f101..356be180 100644 --- a/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar +++ b/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar @@ -87,6 +87,7 @@ infoWithIcon "jsonrpc": "2.0", "id": 2, "result": { + "ttlMs": 0, "cacheScope": "public", "tools": [ { @@ -141,6 +142,7 @@ infoWithIcon "jsonrpc": "2.0", "id": 3, "result": { + "ttlMs": 0, "cacheScope": "public", "resources": [ { @@ -166,6 +168,7 @@ infoWithIcon "jsonrpc": "2.0", "id": 4, "result": { + "ttlMs": 0, "cacheScope": "public", "prompts": [ { diff --git a/mcp/testdata/conformance/server/tools.txtar b/mcp/testdata/conformance/server/tools.txtar index 4d77ddfb..ba6fd33e 100644 --- a/mcp/testdata/conformance/server/tools.txtar +++ b/mcp/testdata/conformance/server/tools.txtar @@ -64,6 +64,7 @@ inc "jsonrpc": "2.0", "id": 2, "result": { + "ttlMs": 0, "cacheScope": "public", "tools": [ { @@ -170,6 +171,7 @@ inc "jsonrpc": "2.0", "id": 3, "result": { + "ttlMs": 0, "cacheScope": "public", "resources": [] } @@ -178,6 +180,7 @@ inc "jsonrpc": "2.0", "id": 4, "result": { + "ttlMs": 0, "cacheScope": "public", "prompts": [] } From 8a52b9eafdf364bb9b4b5249d1a5935bee9ae02b Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Wed, 10 Jun 2026 07:57:22 +0000 Subject: [PATCH 6/9] fix: remove explicit zero TTL from default cacheable response --- mcp/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/server.go b/mcp/server.go index b845b572..d0d05d56 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -752,7 +752,7 @@ func (s *Server) applyCacheControl(req Request, res Result, c *Cacheable) { *c = s.opts.CacheControl(req, res) return } - *c = Cacheable{TTLMs: 0, CacheScope: "public"} + *c = Cacheable{CacheScope: "public"} } func (s *Server) listPrompts(_ context.Context, req *ListPromptsRequest) (*ListPromptsResult, error) { From 23119b85a9d2b9f2eaacaec44f116072c9632eda Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Fri, 12 Jun 2026 15:51:29 +0000 Subject: [PATCH 7/9] refactor: remove CacheControl callback and integrate default Cacheable values into result types --- mcp/protocol.go | 5 +++++ mcp/server.go | 31 +------------------------------ mcp/shared.go | 3 +++ 3 files changed, 9 insertions(+), 30 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index ae4444a3..7710afe0 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1136,6 +1136,11 @@ func (c Cacheable) GetTTLMs() int { return c.TTLMs } // GetCacheScope returns the cache scope. func (c Cacheable) GetCacheScope() string { return c.CacheScope } +// setDefaultValues sets the default values for the cacheable fields. +func (c *Cacheable) setDefaultValues() { + c.CacheScope = "public" +} + // The server's response to a prompts/list request from the client. type ListPromptsResult struct { // This property is reserved by the protocol to allow clients and servers to diff --git a/mcp/server.go b/mcp/server.go index d0d05d56..127b68d3 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -154,23 +154,6 @@ type ServerOptions struct { // GetSessionID is not consulted when [StreamableHTTPOptions.Stateless] is // true, since stateless servers do not maintain sessions. GetSessionID func() string - - // CacheControl, if non-nil, is called for each response to a cacheable - // method (tools/list, prompts/list, resources/list, - // resources/templates/list, resources/read) to populate the SEP-2549 - // `ttlMs` and `cacheScope` fields. It is invoked after the result has - // been assembled but before it is returned to the client. - // - // The concrete type of req.GetParams() identifies the method (for - // example, *ListToolsParams for tools/list or *ReadResourceParams for - // resources/read), and res holds the assembled response (e.g. - // *ListToolsResult), allowing the policy to depend on response - // contents as well as the request. - // - // If CacheControl is nil, the response is sent with the safe default - // `Cacheable{CacheScope: "public"}` (TTLMs: 0), which tells clients to - // treat the result as immediately stale and not cache it. - CacheControl func(req Request, res Result) Cacheable } // NewServer creates a new MCP server. The resulting server has no features: @@ -747,14 +730,6 @@ func (s *Server) Sessions() iter.Seq[*ServerSession] { return slices.Values(clients) } -func (s *Server) applyCacheControl(req Request, res Result, c *Cacheable) { - if s.opts.CacheControl != nil { - *c = s.opts.CacheControl(req, res) - return - } - *c = Cacheable{CacheScope: "public"} -} - func (s *Server) listPrompts(_ context.Context, req *ListPromptsRequest) (*ListPromptsResult, error) { s.mu.Lock() defer s.mu.Unlock() @@ -770,7 +745,6 @@ func (s *Server) listPrompts(_ context.Context, req *ListPromptsRequest) (*ListP if err != nil { return nil, err } - s.applyCacheControl(req, res, &res.Cacheable) return res, nil } @@ -852,7 +826,6 @@ func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListTools if err != nil { return nil, err } - s.applyCacheControl(req, res, &res.Cacheable) return res, nil } @@ -900,7 +873,6 @@ func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*L if err != nil { return nil, err } - s.applyCacheControl(req, res, &res.Cacheable) return res, nil } @@ -920,7 +892,6 @@ func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTempl if err != nil { return nil, err } - s.applyCacheControl(req, res, &res.Cacheable) return res, nil } @@ -956,7 +927,6 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R c.MIMEType = mimeType } } - s.applyCacheControl(req, res, &res.Cacheable) return res, nil } @@ -1828,5 +1798,6 @@ func paginateList[P listParams, R listResult[T], T any](fs *featureSet[T], pageS return zero, err } *res.nextCursorPtr() = nextCursor + res.setDefaultValues() return res, nil } diff --git a/mcp/shared.go b/mcp/shared.go index 778f57c0..f88b6c5d 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -757,6 +757,9 @@ type listParams interface { type listResult[T any] interface { // Returns a pointer to the param's NextCursor field. nextCursorPtr() *string + + // setDefaultValues sets the default values for the cacheable fields. + setDefaultValues() } // keepaliveSession represents a session that supports keepalive functionality. From f8a29e2a876352a52b0985921911128282a31c23 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Sun, 14 Jun 2026 21:49:01 +0000 Subject: [PATCH 8/9] refactor: rename and explicitly invoke setDefaultCacheableValues for cacheable results in server handlers --- mcp/protocol.go | 4 ++-- mcp/server.go | 6 +++++- mcp/shared.go | 3 --- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index 7710afe0..01b94637 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1136,8 +1136,8 @@ func (c Cacheable) GetTTLMs() int { return c.TTLMs } // GetCacheScope returns the cache scope. func (c Cacheable) GetCacheScope() string { return c.CacheScope } -// setDefaultValues sets the default values for the cacheable fields. -func (c *Cacheable) setDefaultValues() { +// setDefaultCacheableValues sets the default values for the cacheable fields. +func (c *Cacheable) setDefaultCacheableValues() { c.CacheScope = "public" } diff --git a/mcp/server.go b/mcp/server.go index 127b68d3..57078572 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -745,6 +745,7 @@ func (s *Server) listPrompts(_ context.Context, req *ListPromptsRequest) (*ListP if err != nil { return nil, err } + res.setDefaultCacheableValues() return res, nil } @@ -826,6 +827,7 @@ func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListTools if err != nil { return nil, err } + res.setDefaultCacheableValues() return res, nil } @@ -873,6 +875,7 @@ func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*L if err != nil { return nil, err } + res.setDefaultCacheableValues() return res, nil } @@ -892,6 +895,7 @@ func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTempl if err != nil { return nil, err } + res.setDefaultCacheableValues() return res, nil } @@ -912,6 +916,7 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { return nil, err } + res.setDefaultCacheableValues() if res.resultType == resultTypeInputRequired { return res, nil } @@ -1798,6 +1803,5 @@ func paginateList[P listParams, R listResult[T], T any](fs *featureSet[T], pageS return zero, err } *res.nextCursorPtr() = nextCursor - res.setDefaultValues() return res, nil } diff --git a/mcp/shared.go b/mcp/shared.go index f88b6c5d..778f57c0 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -757,9 +757,6 @@ type listParams interface { type listResult[T any] interface { // Returns a pointer to the param's NextCursor field. nextCursorPtr() *string - - // setDefaultValues sets the default values for the cacheable fields. - setDefaultValues() } // keepaliveSession represents a session that supports keepalive functionality. From c8f51944da39e62b978a3fe255d59edc12208304 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Mon, 15 Jun 2026 14:05:52 +0000 Subject: [PATCH 9/9] refactor: implement generic cachedListResult to unify pagination cache handling for list methods --- mcp/cache.go | 20 ++++++++++++++ mcp/client.go | 74 +++++++++++++++++++++++---------------------------- 2 files changed, 54 insertions(+), 40 deletions(-) diff --git a/mcp/cache.go b/mcp/cache.go index 21dfff3c..3db23e75 100644 --- a/mcp/cache.go +++ b/mcp/cache.go @@ -73,3 +73,23 @@ func (mc *methodCache[R]) invalidateKey(key string) { defer mc.mu.Unlock() delete(mc.cachedValues, key) } + +// cursorParams is the constraint for list-method params that carry a pagination +// cursor and can be checked for nil. Both methods are already implemented by +// every concrete list-params type. +type cursorParams interface { + Params + cursorPtr() *string +} + +// cachedListResult returns a cached list result keyed by the request cursor +// (SEP-2549). It returns the zero value and false on miss or when params is nil. +func cachedListResult[P cursorParams, R CacheableResult](cache *methodCache[R], params P) (R, bool) { + key := "" + if !params.isNil() { + if cp := params.cursorPtr(); cp != nil { + key = *cp + } + } + return cache.get(key) +} diff --git a/mcp/client.go b/mcp/client.go index e8c3bf65..d73ac101 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -1145,21 +1145,19 @@ func (cs *ClientSession) Ping(ctx context.Context, params *PingParams) error { // Results may be served from a client-side TTL cache populated by previous // calls; see SEP-2549. func (cs *ClientSession) ListPrompts(ctx context.Context, params *ListPromptsParams) (*ListPromptsResult, error) { - var cursor string - if params != nil { - cursor = params.Cursor - } - if result, ok := cs.promptsCache.get(cursor); ok { - return result, nil - } if cs.usesNewProtocol() { + if result, ok := cachedListResult(&cs.promptsCache, params); ok { + return result, nil + } params = injectRequestMeta(cs, params) } result, err := handleSend[*ListPromptsResult](ctx, methodListPrompts, newClientRequest(cs, orZero[Params](params))) if err != nil { return nil, err } - cs.promptsCache.put(cursor, result) + if cs.usesNewProtocol() { + cs.promptsCache.put(params.Cursor, result) + } return result, nil } @@ -1173,14 +1171,10 @@ func (cs *ClientSession) GetPrompt(ctx context.Context, params *GetPromptParams) // ListTools lists tools that are currently available on the server. func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) (*ListToolsResult, error) { - var cursor string - if params != nil { - cursor = params.Cursor - } - if result, ok := cs.toolsCache.get(cursor); ok { - return result, nil - } if cs.usesNewProtocol() { + if result, ok := cachedListResult(&cs.toolsCache, params); ok { + return result, nil + } params = injectRequestMeta(cs, params) } result, err := handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params))) @@ -1188,7 +1182,9 @@ func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) return nil, err } result.Tools = filterValidTools(cs.client.opts.Logger, result.Tools) - cs.toolsCache.put(cursor, result) + if cs.usesNewProtocol() { + cs.toolsCache.put(params.Cursor, result) + } return result, nil } @@ -1219,61 +1215,59 @@ func (cs *ClientSession) SetLoggingLevel(ctx context.Context, params *SetLogging // ListResources lists the resources that are currently available on the server. func (cs *ClientSession) ListResources(ctx context.Context, params *ListResourcesParams) (*ListResourcesResult, error) { - var cursor string - if params != nil { - cursor = params.Cursor - } - if result, ok := cs.resourcesCache.get(cursor); ok { - return result, nil - } if cs.usesNewProtocol() { + if result, ok := cachedListResult(&cs.resourcesCache, params); ok { + return result, nil + } params = injectRequestMeta(cs, params) } result, err := handleSend[*ListResourcesResult](ctx, methodListResources, newClientRequest(cs, orZero[Params](params))) if err != nil { return nil, err } - cs.resourcesCache.put(cursor, result) + if cs.usesNewProtocol() { + cs.resourcesCache.put(params.Cursor, result) + } return result, nil } // ListResourceTemplates lists the resource templates that are currently available on the server. func (cs *ClientSession) ListResourceTemplates(ctx context.Context, params *ListResourceTemplatesParams) (*ListResourceTemplatesResult, error) { - var cursor string - if params != nil { - cursor = params.Cursor - } - if result, ok := cs.resourceTemplatesCache.get(cursor); ok { - return result, nil - } if cs.usesNewProtocol() { + if result, ok := cachedListResult(&cs.resourceTemplatesCache, params); ok { + return result, nil + } params = injectRequestMeta(cs, params) } result, err := handleSend[*ListResourceTemplatesResult](ctx, methodListResourceTemplates, newClientRequest(cs, orZero[Params](params))) if err != nil { return nil, err } - cs.resourceTemplatesCache.put(cursor, result) + if cs.usesNewProtocol() { + cs.resourceTemplatesCache.put(params.Cursor, result) + } return result, nil } // ReadResource asks the server to read a resource and return its contents. func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceParams) (*ReadResourceResult, error) { - var uri string - if params != nil { - uri = params.URI - } - if result, ok := cs.readResourceCache.get(uri); ok { - return result, nil - } if cs.usesNewProtocol() { + var uri string + if params != nil { + uri = params.URI + } + if result, ok := cs.readResourceCache.get(uri); ok { + return result, nil + } params = injectRequestMeta(cs, params) } result, err := handleSend[*ReadResourceResult](ctx, methodReadResource, newClientRequest(cs, orZero[Params](params))) if err != nil { return nil, err } - cs.readResourceCache.put(uri, result) + if cs.usesNewProtocol() { + cs.readResourceCache.put(params.URI, result) + } return result, nil }