Module: McpAuthorization::Cache

Defined in:
lib/mcp_authorization/cache.rb,
lib/mcp_authorization/cache/recorder.rb,
lib/mcp_authorization/cache/null_store.rb,
lib/mcp_authorization/cache/redis_store.rb,
lib/mcp_authorization/cache/memory_store.rb

Overview

Opt-in caching for the ‘tools/list` response — the one MCP method that must materialize a per-user schema for every tool in a domain, and the dominant cost of an MCP request once `tools/call` only compiles one tool (see McpController).

The cache is keyed on the decisions the compiler makes, not on user or account identity, so it is both correct under feature flags and maximally shareable:

key = H(domain + tool_defs_digest + vocab_fingerprint + decision_vector)
  • tool_defs_digest — changes when a tool’s gates or handler source change

    (i.e. on deploy), auto-invalidating stale entries.
    
  • decision_vector — the result of every gating predicate the domain’s

    compilation consults, evaluated against this context.
    

Two ways the decision vector is obtained:

  1. Explicit (recommended for hosts that know their inputs): if the server context responds to mcp_cache_fingerprint, its return value is used verbatim as the decision component. The host is responsible for folding in everything that shapes the schema (permission set, feature flags, integrations, per-user defaults).

  2. Automatic: otherwise the gem learns the vocabulary by wrapping the context in a Recorder on the first (cold) compile of a domain, capturing every predicate / can? / default_for it reads. Subsequent requests rebuild the vector by replaying that vocabulary against the live context.

Caching is off by default (NullStore). Enable in a Rails initializer:

McpAuthorization.configure do |c|
  c.tools_list_cache = :redis        # or :memory, or a custom store
  c.tools_list_cache_ttl = 3600
end

Defined Under Namespace

Classes: MemoryStore, NullStore, Recorder, RecorderUser, RedisStore, Signature

Class Method Summary collapse

Class Method Details

.defs_digestObject

Digest of every registered tool’s gating + contract source. Changes on deploy (gate edits, handler source mtime), invalidating cached entries without an explicit bust. Memoized; cleared by reset!. : () -> String



119
120
121
122
123
# File 'lib/mcp_authorization/cache.rb', line 119

def defs_digest
  @monitor.synchronize do
    @defs_digest ||= compute_defs_digest
  end
end

.enabled?Boolean

: () -> bool

Returns:

  • (Boolean)


62
63
64
# File 'lib/mcp_authorization/cache.rb', line 62

def enabled?
  !store.is_a?(NullStore)
end

.learn!(domain:, recorder:) ⇒ Object

Merge a cold compile’s consulted decisions into the domain vocabulary. No-op in explicit-fingerprint mode (recorder is nil). : (domain: String, recorder: Recorder?) -> void



85
86
87
88
89
90
91
92
93
94
# File 'lib/mcp_authorization/cache.rb', line 85

def learn!(domain:, recorder:)
  return if recorder.nil?

  @monitor.synchronize do
    existing = @learned[domain] ||= []
    seen = existing.to_set
    recorder.consulted.each { |sig| existing << sig unless seen.include?(sig) }
    @learned_flag[domain] = true
  end
end

.recording_context(server_context) ⇒ Object

Returns [effective_context, recorder_or_nil]. When the host supplies an explicit fingerprint, no recorder is needed and the real context is used. Otherwise the context is wrapped so the cold compile is observed. : (untyped) -> [untyped, Recorder?]



75
76
77
78
79
80
# File 'lib/mcp_authorization/cache.rb', line 75

def recording_context(server_context)
  return [server_context, nil] if explicit_fingerprint(server_context)

  recorder = Recorder.new(server_context)
  [recorder, recorder]
end

.reset!Object

Clear memoized store, learned vocabulary, and the defs digest. Called by the Engine reloader so code changes in development take effect. : () -> void



128
129
130
131
132
133
134
135
# File 'lib/mcp_authorization/cache.rb', line 128

def reset!
  @monitor.synchronize do
    @store = nil
    @learned = {}
    @learned_flag = {}
    @defs_digest = nil
  end
end

.storeObject

The resolved store. Memoized; rebuilt after reset!. : () -> untyped



57
58
59
# File 'lib/mcp_authorization/cache.rb', line 57

def store
  @monitor.synchronize { @store ||= resolve_store(McpAuthorization.config.tools_list_cache) }
end

.tools_list_key(domain:, server_context:) ⇒ Object

The cache key for this domain + context, or nil when the domain has not been learned yet (auto mode), which forces a cold compile. : (domain: String, server_context: untyped) -> String?



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/mcp_authorization/cache.rb', line 99

def tools_list_key(domain:, server_context:)
  fp = explicit_fingerprint(server_context)
  if fp
    components = ["fp", domain, defs_digest, stable(fp)]
    return digest_key(components)
  end

  vocab, learned = @monitor.synchronize { [(@learned[domain] || []).dup, @learned_flag[domain]] }
  return nil unless learned

  sorted = vocab.sort_by(&:canonical)
  vector = sorted.map { |sig| [sig.canonical, stable(evaluate(sig, server_context))] }
  fingerprint = Digest::SHA256.hexdigest(sorted.map(&:canonical).join("\n"))[0, 12]
  digest_key(["v", domain, defs_digest, fingerprint, vector])
end

.ttlObject

: () -> Integer



67
68
69
# File 'lib/mcp_authorization/cache.rb', line 67

def ttl
  McpAuthorization.config.tools_list_cache_ttl
end