Class: MCPClient::CachedResult

Inherits:
Object
  • Object
show all
Defined in:
lib/mcp_client/cached_result.rb

Overview

A cached server result with its freshness hint (MCP 2026-07-28 server/utilities/caching): ttlMs says how long the client MAY consider the result fresh after receipt (0 = immediately stale; absent = no hint, only older servers), and cacheScope whether the response may be shared across authorization contexts ("public") or not ("private").

Constant Summary collapse

SCOPES =
%w[public private].freeze
MIXED_CONTEXT =

The context of a private list whose pages were fetched under different credentials: it belongs to no context and never matches one.

Object.new.freeze
UNKNOWN_CONTEXT =

The context of an entry whose request nothing could record: an unknown request is not an anonymous one, so this belongs to no context either and is never served across one.

Object.new.freeze
MIXED_PARAMS =

The params fingerprint of a list whose pages were fetched under differing effective parameters: no request's parameters match it.

Object.new.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(value:, received_at:, ttl_ms:, cache_scope:) ⇒ CachedResult

Returns a new instance of CachedResult.



113
114
115
116
117
118
119
120
# File 'lib/mcp_client/cached_result.rb', line 113

def initialize(value:, received_at:, ttl_ms:, cache_scope:)
  @value = value
  @received_at = received_at
  @ttl_ms = ttl_ms
  # Kept frozen: the scope is compared against the exact sentinels and
  # is handed out through #to_info.
  @cache_scope = cache_scope.is_a?(String) ? -cache_scope : cache_scope
end

Instance Attribute Details

#authorization_context ⇒ String?

The authorization context (the Authorization header) of the request that produced a privately scoped entry; such an entry is served only in that context ("MUST NOT be shared across authorization contexts").

Returns:

  • (String, nil)


36
37
38
# File 'lib/mcp_client/cached_result.rb', line 36

def authorization_context
  @authorization_context
end

#cache_scope ⇒ String? (readonly)

Returns "public", "private", or nil when absent/unknown.

Returns:

  • (String, nil) —

    "public", "private", or nil when absent/unknown



30
31
32
# File 'lib/mcp_client/cached_result.rb', line 30

def cache_scope
  @cache_scope
end

#fetch_token ⇒ Object?

Identity of the fetch that recorded this entry: the list that fetch converts afterwards attaches to this entry and to no other.

Returns:

  • (Object, nil)


41
42
43
# File 'lib/mcp_client/cached_result.rb', line 41

def fetch_token
  @fetch_token
end

#params_fingerprint ⇒ String?

Fingerprint of the effective request parameters (host _meta) the request that produced this entry went out with; the entry is served only to requests that would carry the same.

Returns:

  • (String, nil)


47
48
49
# File 'lib/mcp_client/cached_result.rb', line 47

def params_fingerprint
  @params_fingerprint
end

#received_at ⇒ Float (readonly)

Returns monotonic receipt time (seconds).

Returns:

  • (Float) —

    monotonic receipt time (seconds)



26
27
28
# File 'lib/mcp_client/cached_result.rb', line 26

def received_at
  @received_at
end

#ttl_ms ⇒ Integer, ... (readonly)

Returns the server's ttlMs (nil when it sent none).

Returns:

  • (Integer, Float, nil) —

    the server's ttlMs (nil when it sent none)



28
29
30
# File 'lib/mcp_client/cached_result.rb', line 28

def ttl_ms
  @ttl_ms
end

#value ⇒ Object

Returns the cached value (whatever the caller stored).

Returns:

  • (Object) —

    the cached value (whatever the caller stored)



13
14
15
# File 'lib/mcp_client/cached_result.rb', line 13

def value
  @value
end

Class Method Details

.combine(entries, value, now:) ⇒ CachedResult

Combine the hints of several pages of one list into one entry: the shortest TTL wins, and one private page makes the whole list private.

Parameters:

  • entries (Array<CachedResult>) —

    per-page entries

  • value (Object) —

    the combined value

  • now (Float) —

    monotonic receipt time

Returns:



75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/mcp_client/cached_result.rb', line 75

def self.combine(entries, value, now:)
  hinted = entries.select(&:hint?)
  # Each page expires at its own received_at + ttlMs; the combined entry
  # (received now) lives until the earliest of those.
  ttl = hinted.map { |e| [e.ttl_ms - ((now - e.received_at) * 1000.0), 0].max }.min
  scope = if entries.any? { |e| e.cache_scope == 'private' }
            'private'
          else
            entries.map(&:cache_scope).compact.first
          end
  combined = new(value: value, received_at: now, ttl_ms: ttl, cache_scope: scope)
  combined.params_fingerprint = entries.first&.params_fingerprint
  combined
end

.from_result(result, value, now:, assume_zero: false) ⇒ CachedResult

Build an entry from a CacheableResult.

Parameters:

  • result (Hash, nil) —

    the JSON-RPC result carrying ttlMs/cacheScope

  • value (Object) —

    what to cache

  • now (Float) —

    monotonic receipt time

  • assume_zero (Boolean) (defaults to: false) —

    treat an absent ttlMs as 0 ("if ttlMs is absent, clients SHOULD assume 0"): the rule for a 2026-07-28 server; an older server keeps the client's own heuristic

Returns:



56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/mcp_client/cached_result.rb', line 56

def self.from_result(result, value, now:, assume_zero: false)
  ttl = if result.is_a?(Hash) && result.key?('ttlMs')
          normalize_ttl(result['ttlMs'])
        elsif assume_zero
          0
        end
  # Cross-context reuse needs an explicit "public": an absent or unknown
  # cacheScope keeps the entry within the authorization context that
  # produced it.
  scope = result.is_a?(Hash) ? result['cacheScope'] : nil
  new(value: value, received_at: now, ttl_ms: ttl, cache_scope: SCOPES.include?(scope) ? scope : 'private')
end

.normalize_ttl(raw) ⇒ Integer, Float

"Servers MUST provide a ttlMs value that is >= 0"; anything else is treated as 0 (immediately stale).

Parameters:

  • raw (Object) —

    the ttlMs member

Returns:

  • (Integer, Float)


107
108
109
110
111
# File 'lib/mcp_client/cached_result.rb', line 107

def self.normalize_ttl(raw)
  return 0 unless raw.is_a?(Numeric) && raw.finite?

  [raw, 0].max
end

.stale(now:, like: nil) ⇒ CachedResult

An entry that is stale from the start: what a change notification leaves behind so the kind reads as "known and stale", not "unknown".

Parameters:

  • now (Float) —

    monotonic time

  • like (CachedResult, nil) (defaults to: nil) —

    the entry being replaced: its scope and authorization context are kept, so a stale private entry stays private

Returns:



96
97
98
99
100
101
# File 'lib/mcp_client/cached_result.rb', line 96

def self.stale(now:, like: nil)
  entry = new(value: nil, received_at: now, ttl_ms: 0, cache_scope: like&.cache_scope)
  entry.authorization_context = like&.authorization_context
  entry.params_fingerprint = like&.params_fingerprint
  entry
end

Instance Method Details

#fresh?(now:) ⇒ Boolean

Fresh while now < t_received + ttlMs. Without a hint the client keeps its own heuristic (cache until a change notification), so the entry counts as fresh.

Parameters:

  • now (Float) —

    monotonic time

Returns:

  • (Boolean)


132
133
134
135
136
# File 'lib/mcp_client/cached_result.rb', line 132

def fresh?(now:)
  return true unless hint?

  now < @received_at + (@ttl_ms / 1000.0)
end

#hint? ⇒ Boolean

Returns whether the server gave a ttlMs at all.

Returns:

  • (Boolean) —

    whether the server gave a ttlMs at all



123
124
125
# File 'lib/mcp_client/cached_result.rb', line 123

def hint?
  !@ttl_ms.nil?
end

#to_info(now:) ⇒ Hash

Returns ttl_ms, cache_scope, received_at, fresh.

Parameters:

  • now (Float) —

    monotonic time

Returns:

  • (Hash) —

    ttl_ms, cache_scope, received_at, fresh



140
141
142
143
# File 'lib/mcp_client/cached_result.rb', line 140

def to_info(now:)
  # Detached values: nothing a caller does to them reaches the entry.
  { ttl_ms: @ttl_ms, cache_scope: @cache_scope&.dup, received_at: @received_at, fresh: fresh?(now: now) }
end