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:
-
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). -
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
-
.defs_digest ⇒ Object
Digest of every registered tool’s gating + contract source.
-
.enabled? ⇒ Boolean
: () -> bool.
-
.learn!(domain:, recorder:) ⇒ Object
Merge a cold compile’s consulted decisions into the domain vocabulary.
-
.recording_context(server_context) ⇒ Object
Returns [effective_context, recorder_or_nil].
-
.reset! ⇒ Object
Clear memoized store, learned vocabulary, and the defs digest.
-
.store ⇒ Object
The resolved store.
-
.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.
-
.ttl ⇒ Object
: () -> Integer.
Class Method Details
.defs_digest ⇒ Object
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
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 |
.store ⇒ Object
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 |
.ttl ⇒ Object
: () -> Integer
67 68 69 |
# File 'lib/mcp_authorization/cache.rb', line 67 def ttl McpAuthorization.config.tools_list_cache_ttl end |