mcp_authorization 0.5.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d12320957dc293fd39e12e752fc62783a38fba78543d2df56f4cdb382947b262
4
- data.tar.gz: 05ab5308639f0612edc056c145cea34a7a220c51b31a03cfc746646b5c58ca9e
3
+ metadata.gz: 63bcf798beee003e44d7894f8e2e80177a6ddb1e7357b68507a74464c2665ce6
4
+ data.tar.gz: 047560ce4c65b3b960f7c66904ca48c70b513e90510d7714edbd93996c39f919
5
5
  SHA512:
6
- metadata.gz: 4d10be243138d57af94278d70dcf0af30d8d09f6c7b7bee69da31c42155d1e2f46361c45ef2d1aeda1d4631ae661dca96dd200f961207bf1826b458bba243e2b
7
- data.tar.gz: 5a8f3df13aa95bc67c6c5044137b8d0c43fb72fc74553075eaf254944623c2fe038ab784d3773f87239046d40ee32096976d65c0d738a84817a2c9a826b4e980
6
+ metadata.gz: 41b2c6a57ffeda77b7c74e2d2a849da6653d6678f78e9a76b9dec0cdaef5059b4ea00449063c57254e526c9cf6ebc780a7f43b59bfefeb57fabcc12e2f966842
7
+ data.tar.gz: 7dc31d4854025087e495bc9963ee46079a35f3cc6cc6fcbd3a904b00383017e6f68b7718db51a49fb78585657d86158e637fcbd5a182a72b1ad6d09ec83f362e
data/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ All notable changes to this gem are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.6.0] - 2026-06-25
8
+
9
+ Per-request work is now scoped to what each MCP method needs, and the
10
+ remaining `tools/list` cost is cacheable.
11
+
12
+ ### Changed
13
+ - **`McpController#handle` now materializes only the tools the incoming request needs.** Every request — `initialize`, `notifications/initialized`, `ping`, the GET stream probe, and `tools/call` — previously ran `ToolRegistry.tool_classes_for`, compiling a per-user schema for *every* tool in the domain before the transport even looked at the method. Per-tool schema compilation is the dominant cost of an MCP request, so a `tools/call` (which invokes exactly one tool) and lifecycle traffic (which needs none) paid the full-domain price for nothing. `handle` now routes by JSON-RPC method: `tools/list` materializes the whole domain (unchanged), `tools/call` materializes only the invoked tool, lifecycle methods and the non-POST probe materialize none, and any unrecognized shape (e.g. a JSON-RPC batch with no top-level `method`) falls back to the full domain so routing stays correct. In a 140-tool domain this took a `tools/call` from ~2.6s to <100ms and `notifications/initialized` from ~2s to ~1ms, with no change to `tools/list` output.
14
+
15
+ ### Added
16
+ - **`ToolRegistry.tool_class_for(domain:, name:, server_context:)`** — returns the concrete `MCP::Tool` subclass for a single named tool within a domain (or `nil` when the tool is unknown in that domain or the current user is not permitted), materializing just that one tool instead of the whole domain. Complements `tool_classes_for`, which remains the path for `tools/list`.
17
+
18
+ - **Opt-in caching for the `tools/list` response.** `tools/list` must materialize a per-user schema for every tool in a domain — the dominant cost of an MCP request now that `tools/call` compiles only the invoked tool (above). It can now be cached. Enable in the host initializer:
19
+
20
+ ```ruby
21
+ McpAuthorization.configure do |c|
22
+ c.tools_list_cache = :redis # or :memory, or any object responding to get/set
23
+ c.tools_list_cache_ttl = 3600 # seconds (default)
24
+ end
25
+ ```
26
+
27
+ Default is no caching (`NullStore`), so behavior is unchanged unless opted in.
28
+
29
+ - **Decision-vector cache key — correct under feature flags, shareable across identity.** The key is `H(domain + tool_defs_digest + vocab_fingerprint + decision_vector)`, never user/account identity. The decision vector is the result of every gating decision the domain's compilation consults — `@requires`/`@feature`/`@tier`/custom predicates, tool-level `gate`/`authorization`, and `current_user.can?` / `default_for`. Two contexts that answer all of them identically (same permissions, feature flags, tiers, defaults) share an entry; flip one feature flag and the vector — and the key — change, so an admin in a flag-on account never receives a flag-off account's tools. The `tool_defs_digest` (computed from each tool's gates + handler source) changes on deploy, auto-invalidating stale entries; the TTL bounds out-of-band staleness.
30
+
31
+ - **Two ways to supply the decision vector.** Automatic: the gem learns a domain's predicate vocabulary by wrapping the context in a `Cache::Recorder` on the first (cold) compile, then replays that vocabulary against the live context on subsequent requests. Explicit: if the server context responds to `mcp_cache_fingerprint`, its return value is used verbatim as the decision component (the host folds in whatever shapes the schema). Explicit wins when present.
32
+
33
+ - **Pluggable stores.** `Cache::NullStore` (default), `Cache::MemoryStore` (process-local, bounded LRU + per-entry TTL), and `Cache::RedisStore` (shared; JSON values; per-entry TTL). The Redis store's connection resolves from an explicit client (`tools_list_cache_redis`), then `tools_list_cache_redis_url`, then `ENV["REDIS_URL"]`, then a bare `Redis.new` — i.e. it defaults to the host's Rails redis config with no extra wiring. `redis` is an optional dependency, required lazily only when the Redis store is used. Cache outages fail open (a get/set error logs and behaves as a miss, never breaking `tools/list`).
34
+
35
+ - **`McpController` serves `tools/list` through the cache** when enabled: a hit renders the cached `result` re-wrapped with the live JSON-RPC id; a miss compiles cold under a `Recorder`, learns the vocabulary, and stores the result. Error/unexpected responses are rendered but not cached. All other methods are unaffected.
36
+
37
+ ### Notes
38
+ - The cache is cleared on code reload (the Engine reloader now also calls `Cache.reset!`), so development picks up tool/schema changes immediately.
39
+
7
40
  ## [0.5.6] - 2026-06-08
8
41
 
9
42
  ### Fixed
@@ -6,11 +6,22 @@ module McpAuthorization
6
6
  #: () -> void
7
7
  def handle
8
8
  server_context = build_server_context
9
- tools = McpAuthorization::ToolRegistry.tool_classes_for(
10
- domain: params[:domain],
11
- server_context: server_context
12
- )
13
9
 
10
+ if mcp_method == "tools/list" && McpAuthorization::Cache.enabled?
11
+ return render_cached_tools_list(server_context)
12
+ end
13
+
14
+ status, headers, body = run_transport(server_context, tools_for_request(server_context))
15
+ apply_headers(headers)
16
+ render json: body.first, status: status
17
+ end
18
+
19
+ private
20
+
21
+ # Build a stateless MCP server with the given tools + context and run the
22
+ # incoming request through the transport.
23
+ #: (untyped, Array[singleton(MCP::Tool)]) -> [Integer, Hash[String, String], Array[untyped]]
24
+ def run_transport(server_context, tools)
14
25
  server = MCP::Server.new(
15
26
  name: McpAuthorization.config.server_name,
16
27
  version: McpAuthorization.config.server_version,
@@ -19,13 +30,103 @@ module McpAuthorization
19
30
  )
20
31
  transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
21
32
  server.transport = transport
33
+ transport.handle_request(request)
34
+ end
22
35
 
23
- status, headers, body = transport.handle_request(request)
36
+ #: (Hash[String, String]) -> void
37
+ def apply_headers(headers)
24
38
  headers.each { |k, v| response.set_header(k, v) }
25
- render json: body.first, status: status
26
39
  end
27
40
 
28
- private
41
+ # Serve tools/list from the configured cache, or compile it cold (observed
42
+ # by a Recorder so the cache key vocabulary is learned) and store it. The
43
+ # JSON-RPC envelope is rebuilt with the live request id, so the cached
44
+ # value is just the (id-independent) `result`.
45
+ #: (untyped) -> void
46
+ def render_cached_tools_list(server_context)
47
+ domain = params[:domain]
48
+
49
+ key = McpAuthorization::Cache.tools_list_key(domain: domain, server_context: server_context)
50
+ if key && (cached = McpAuthorization::Cache.store.get(key))
51
+ return render json: tools_list_envelope(cached)
52
+ end
53
+
54
+ effective_ctx, recorder = McpAuthorization::Cache.recording_context(server_context)
55
+ status, headers, body = run_transport(effective_ctx, all_tools(effective_ctx))
56
+ McpAuthorization::Cache.learn!(domain: domain, recorder: recorder)
57
+ apply_headers(headers)
58
+
59
+ parsed = begin
60
+ JSON.parse(body.first)
61
+ rescue StandardError
62
+ nil
63
+ end
64
+ result = parsed && parsed["result"]
65
+ unless result
66
+ return render json: body.first, status: status # error / unexpected shape — don't cache
67
+ end
68
+
69
+ store_key = McpAuthorization::Cache.tools_list_key(domain: domain, server_context: server_context)
70
+ McpAuthorization::Cache.store.set(store_key, result, ttl: McpAuthorization::Cache.ttl) if store_key
71
+ render json: tools_list_envelope(result), status: status
72
+ end
73
+
74
+ #: (untyped) -> String
75
+ def tools_list_envelope(result)
76
+ { jsonrpc: "2.0", id: mcp_request_id, result: result }.to_json
77
+ end
78
+
79
+ #: () -> untyped
80
+ def mcp_request_id
81
+ params[:id] || params.dig(:mcp, :id)
82
+ end
83
+
84
+ # Materialize only the tools the incoming JSON-RPC method needs. Compiling
85
+ # per-user schemas for every tool is the dominant cost of an MCP request, so
86
+ # a +tools/call+ compiles just the invoked tool and lifecycle methods
87
+ # (initialize, notifications/*, ping) and non-POST probes compile none.
88
+ # +tools/list+ — and any unrecognized shape such as a JSON-RPC batch —
89
+ # falls back to the full domain so routing stays correct.
90
+ #: (untyped) -> Array[singleton(MCP::Tool)]
91
+ def tools_for_request(server_context)
92
+ return [] if request.get?
93
+
94
+ case mcp_method
95
+ when "tools/list"
96
+ all_tools(server_context)
97
+ when "tools/call"
98
+ name = mcp_request_params[:name]
99
+ name ? Array(McpAuthorization::ToolRegistry.tool_class_for(
100
+ domain: params[:domain],
101
+ name: name,
102
+ server_context: server_context
103
+ )) : all_tools(server_context)
104
+ when "initialize", "ping", %r{\Anotifications/}
105
+ []
106
+ else
107
+ # Unknown or unreadable method (e.g. a JSON-RPC batch with no top-level
108
+ # method): materialize the full domain so multi-method requests route.
109
+ all_tools(server_context)
110
+ end
111
+ end
112
+
113
+ #: (untyped) -> Array[singleton(MCP::Tool)]
114
+ def all_tools(server_context)
115
+ McpAuthorization::ToolRegistry.tool_classes_for(
116
+ domain: params[:domain],
117
+ server_context: server_context
118
+ )
119
+ end
120
+
121
+ #: () -> untyped
122
+ def mcp_method
123
+ params[:method] || params.dig(:mcp, :method)
124
+ end
125
+
126
+ #: () -> untyped
127
+ def mcp_request_params
128
+ params[:params] || params.dig(:mcp, :params) || ActionController::Parameters.new
129
+ end
29
130
 
30
131
  #: () -> untyped
31
132
  def build_server_context
@@ -0,0 +1,62 @@
1
+ require "monitor"
2
+
3
+ module McpAuthorization
4
+ module Cache
5
+ # Process-local in-memory store with a bounded entry count and per-entry
6
+ # TTL. Each worker process warms its own copy — fine for the tools/list
7
+ # payload, which is identical across workers for a given decision vector.
8
+ # For cross-process/host sharing, use RedisStore.
9
+ class MemoryStore
10
+ include MonitorMixin
11
+
12
+ DEFAULT_MAX_ENTRIES = 512
13
+
14
+ #: (?max_entries: Integer) -> void
15
+ def initialize(max_entries: DEFAULT_MAX_ENTRIES)
16
+ super()
17
+ @max_entries = max_entries
18
+ @entries = {} #: Hash[String, [untyped, Float?]] # key => [value, expires_at]
19
+ end
20
+
21
+ #: (String) -> untyped
22
+ def get(key)
23
+ synchronize do
24
+ entry = @entries[key]
25
+ return nil unless entry
26
+
27
+ value, expires_at = entry
28
+ if expires_at && monotonic > expires_at
29
+ @entries.delete(key)
30
+ return nil
31
+ end
32
+
33
+ # Mark as most-recently-used.
34
+ @entries.delete(key)
35
+ @entries[key] = entry
36
+ value
37
+ end
38
+ end
39
+
40
+ #: (String, untyped, ?ttl: Integer?) -> void
41
+ def set(key, value, ttl: nil)
42
+ synchronize do
43
+ @entries.delete(key)
44
+ @entries[key] = [value, ttl ? monotonic + ttl : nil]
45
+ @entries.shift while @entries.size > @max_entries # evict oldest (insertion order)
46
+ end
47
+ end
48
+
49
+ #: () -> void
50
+ def clear
51
+ synchronize { @entries.clear }
52
+ end
53
+
54
+ private
55
+
56
+ #: () -> Float
57
+ def monotonic
58
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,20 @@
1
+ module McpAuthorization
2
+ module Cache
3
+ # No-op store. The default — caching is opt-in. Every read misses, every
4
+ # write is discarded, so behavior is identical to a gem with no cache.
5
+ class NullStore
6
+ #: (String) -> nil
7
+ def get(_key)
8
+ nil
9
+ end
10
+
11
+ #: (String, untyped, ?ttl: Integer?) -> void
12
+ def set(_key, _value, ttl: nil)
13
+ nil
14
+ end
15
+
16
+ #: () -> void
17
+ def clear; end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,110 @@
1
+ module McpAuthorization
2
+ module Cache
3
+ # A signature for one decision the compiler consulted on the server
4
+ # context: a predicate call (`feature?(:sms)`, `requires?(:admin)`, a
5
+ # custom `tier?(:enterprise)`), or a `current_user.can?` / `default_for`
6
+ # call. Identity is by (target, method, arg) so the same decision dedupes
7
+ # across tools; +arg+ is retained intact so replay calls the predicate
8
+ # with the original value, not a coerced one.
9
+ Signature = Struct.new(:target, :method, :arg) do
10
+ #: () -> String
11
+ def canonical
12
+ "#{target}\x1f#{method}\x1f#{arg.inspect}"
13
+ end
14
+
15
+ #: (untyped) -> bool
16
+ def eql?(other)
17
+ other.is_a?(Signature) && canonical == other.canonical
18
+ end
19
+
20
+ #: () -> Integer
21
+ def hash
22
+ canonical.hash
23
+ end
24
+ end
25
+
26
+ # Wraps a server context during a cold compile and records every gating
27
+ # decision the compiler reads — so the cache key can be rebuilt later from
28
+ # just those decisions. Two contexts that answer every recorded predicate
29
+ # identically (same permissions, feature flags, tiers, defaults) produce
30
+ # the same key and share a cache entry; flip one feature flag and the
31
+ # vector — and the key — change.
32
+ #
33
+ # Transparently delegates everything to the wrapped context. The compiler
34
+ # reaches the user via +current_user+, so that returns a RecorderUser to
35
+ # capture +can?+ and +default_for+.
36
+ class Recorder
37
+ #: untyped
38
+ attr_reader :__target
39
+
40
+ #: Array[Signature]
41
+ attr_reader :consulted
42
+
43
+ #: (untyped) -> void
44
+ def initialize(target)
45
+ @__target = target
46
+ @consulted = []
47
+ @__user = nil
48
+ end
49
+
50
+ #: () -> untyped
51
+ def current_user
52
+ user = @__target.current_user
53
+ return nil unless user
54
+
55
+ @__user ||= RecorderUser.new(user, @consulted)
56
+ end
57
+
58
+ #: (Symbol, ?bool) -> bool
59
+ def respond_to_missing?(name, include_private = false)
60
+ @__target.respond_to?(name, include_private)
61
+ end
62
+
63
+ #: (Symbol, *untyped) -> untyped
64
+ def method_missing(name, *args, &block)
65
+ result = @__target.public_send(name, *args, &block)
66
+ # Gating predicates are single-arg methods ending in "?"
67
+ # (feature?/requires?/tier?/<custom>?). Recording an incidental
68
+ # predicate is harmless — it just adds a deterministic dimension to
69
+ # the key — so we don't need a hardcoded allowlist.
70
+ if name.to_s.end_with?("?") && args.size == 1
71
+ @consulted << Signature.new(:context, name.to_s, args.first)
72
+ end
73
+ result
74
+ end
75
+ end
76
+
77
+ # Records +can?+ and +default_for+ on the wrapped user. +default_for+
78
+ # returns a value baked into the schema, so its result is part of the
79
+ # decision vector too, not just predicate booleans.
80
+ class RecorderUser
81
+ #: (untyped, Array[Signature]) -> void
82
+ def initialize(target, consulted)
83
+ @__target = target
84
+ @consulted = consulted
85
+ end
86
+
87
+ #: (untyped) -> untyped
88
+ def can?(flag)
89
+ @consulted << Signature.new(:user, "can?", flag)
90
+ @__target.can?(flag)
91
+ end
92
+
93
+ #: (untyped) -> untyped
94
+ def default_for(key)
95
+ @consulted << Signature.new(:user, "default_for", key)
96
+ @__target.default_for(key)
97
+ end
98
+
99
+ #: (Symbol, ?bool) -> bool
100
+ def respond_to_missing?(name, include_private = false)
101
+ @__target.respond_to?(name, include_private)
102
+ end
103
+
104
+ #: (Symbol, *untyped) -> untyped
105
+ def method_missing(name, *args, &block)
106
+ @__target.public_send(name, *args, &block)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,102 @@
1
+ require "json"
2
+
3
+ module McpAuthorization
4
+ module Cache
5
+ # Redis-backed store — shared across workers and hosts. Values are stored
6
+ # as JSON (the tools/list result is plain JSON-serializable data), with a
7
+ # per-entry TTL via +SET ... EX+.
8
+ #
9
+ # Connection resolution (first match wins), so a Rails host gets the
10
+ # "Rails redis config" for free without passing anything:
11
+ #
12
+ # 1. an explicit client — RedisStore.new(redis: $redis)
13
+ # 2. an explicit URL — RedisStore.new(url: "redis://…")
14
+ # 3. ENV["REDIS_URL"] — the conventional Rails/Heroku/Sidekiq var
15
+ # 4. Redis.new — the redis gem's own default (ENV or localhost),
16
+ # matching a bare `Redis.new` in the host
17
+ #
18
+ # The +redis+ gem is an optional dependency, required lazily here so hosts
19
+ # that don't use this store never need it.
20
+ class RedisStore
21
+ # Raised when :redis caching is requested but the redis gem is absent.
22
+ class RedisUnavailable < StandardError; end
23
+
24
+ NAMESPACE = "mcpauth".freeze
25
+
26
+ #: (?redis: untyped?, ?url: String?, ?namespace: String) -> void
27
+ def initialize(redis: nil, url: nil, namespace: NAMESPACE)
28
+ @redis = redis
29
+ @url = url
30
+ @namespace = namespace
31
+ end
32
+
33
+ #: (String) -> untyped
34
+ def get(key)
35
+ raw = client.get(namespaced(key))
36
+ raw && JSON.parse(raw)
37
+ rescue StandardError => e
38
+ log_error("get", e)
39
+ nil # never let a cache outage break tools/list
40
+ end
41
+
42
+ #: (String, untyped, ?ttl: Integer?) -> void
43
+ def set(key, value, ttl: nil)
44
+ payload = JSON.generate(value)
45
+ if ttl && ttl > 0
46
+ client.set(namespaced(key), payload, ex: ttl)
47
+ else
48
+ client.set(namespaced(key), payload)
49
+ end
50
+ nil
51
+ rescue StandardError => e
52
+ log_error("set", e)
53
+ nil
54
+ end
55
+
56
+ #: () -> void
57
+ def clear
58
+ cursor = "0"
59
+ loop do
60
+ cursor, keys = client.scan(cursor, match: namespaced("*"), count: 500)
61
+ client.del(*keys) unless keys.empty?
62
+ break if cursor == "0"
63
+ end
64
+ rescue StandardError => e
65
+ log_error("clear", e)
66
+ end
67
+
68
+ private
69
+
70
+ #: (String) -> String
71
+ def namespaced(key)
72
+ "#{@namespace}:#{key}"
73
+ end
74
+
75
+ #: () -> untyped
76
+ def client
77
+ @client ||= @redis || build_client
78
+ end
79
+
80
+ #: () -> untyped
81
+ def build_client
82
+ require "redis"
83
+ url = @url || ENV["REDIS_URL"]
84
+ url ? ::Redis.new(url: url) : ::Redis.new
85
+ rescue LoadError
86
+ raise RedisUnavailable, <<~MSG
87
+ McpAuthorization is configured to use the :redis tools/list cache,
88
+ but the `redis` gem is not available. Add `gem "redis"` to your
89
+ Gemfile, or pass an explicit client/store to
90
+ `config.tools_list_cache`.
91
+ MSG
92
+ end
93
+
94
+ #: (String, Exception) -> void
95
+ def log_error(op, error)
96
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
97
+
98
+ Rails.logger.error("[McpAuthorization] redis cache #{op} failed: #{error.class}: #{error.message}")
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,222 @@
1
+ require "digest"
2
+ require "json"
3
+ require "monitor"
4
+ require "set"
5
+
6
+ require_relative "cache/null_store"
7
+ require_relative "cache/memory_store"
8
+ require_relative "cache/redis_store"
9
+ require_relative "cache/recorder"
10
+
11
+ module McpAuthorization
12
+ # Opt-in caching for the `tools/list` response — the one MCP method that must
13
+ # materialize a per-user schema for *every* tool in a domain, and the
14
+ # dominant cost of an MCP request once `tools/call` only compiles one tool
15
+ # (see McpController).
16
+ #
17
+ # The cache is keyed on the *decisions* the compiler makes, not on user or
18
+ # account identity, so it is both correct under feature flags and maximally
19
+ # shareable:
20
+ #
21
+ # key = H(domain + tool_defs_digest + vocab_fingerprint + decision_vector)
22
+ #
23
+ # - tool_defs_digest — changes when a tool's gates or handler source change
24
+ # (i.e. on deploy), auto-invalidating stale entries.
25
+ # - decision_vector — the result of every gating predicate the domain's
26
+ # compilation consults, evaluated against this context.
27
+ #
28
+ # Two ways the decision vector is obtained:
29
+ #
30
+ # 1. Explicit (recommended for hosts that know their inputs): if the server
31
+ # context responds to +mcp_cache_fingerprint+, its return value is used
32
+ # verbatim as the decision component. The host is responsible for folding
33
+ # in everything that shapes the schema (permission set, feature flags,
34
+ # integrations, per-user defaults).
35
+ #
36
+ # 2. Automatic: otherwise the gem learns the vocabulary by wrapping the
37
+ # context in a Recorder on the first (cold) compile of a domain, capturing
38
+ # every predicate / can? / default_for it reads. Subsequent requests
39
+ # rebuild the vector by replaying that vocabulary against the live context.
40
+ #
41
+ # Caching is off by default (NullStore). Enable in a Rails initializer:
42
+ #
43
+ # McpAuthorization.configure do |c|
44
+ # c.tools_list_cache = :redis # or :memory, or a custom store
45
+ # c.tools_list_cache_ttl = 3600
46
+ # end
47
+ module Cache
48
+ @monitor = Monitor.new
49
+ @learned = {} #: Hash[String, Array[Signature]] # domain => learned signatures
50
+ @learned_flag = {} #: Hash[String, bool] # domain => has been learned at all
51
+ @store = nil
52
+ @defs_digest = nil
53
+
54
+ class << self
55
+ # The resolved store. Memoized; rebuilt after +reset!+.
56
+ #: () -> untyped
57
+ def store
58
+ @monitor.synchronize { @store ||= resolve_store(McpAuthorization.config.tools_list_cache) }
59
+ end
60
+
61
+ #: () -> bool
62
+ def enabled?
63
+ !store.is_a?(NullStore)
64
+ end
65
+
66
+ #: () -> Integer
67
+ def ttl
68
+ McpAuthorization.config.tools_list_cache_ttl
69
+ end
70
+
71
+ # Returns [effective_context, recorder_or_nil]. When the host supplies an
72
+ # explicit fingerprint, no recorder is needed and the real context is
73
+ # used. Otherwise the context is wrapped so the cold compile is observed.
74
+ #: (untyped) -> [untyped, Recorder?]
75
+ def recording_context(server_context)
76
+ return [server_context, nil] if explicit_fingerprint(server_context)
77
+
78
+ recorder = Recorder.new(server_context)
79
+ [recorder, recorder]
80
+ end
81
+
82
+ # Merge a cold compile's consulted decisions into the domain vocabulary.
83
+ # No-op in explicit-fingerprint mode (recorder is nil).
84
+ #: (domain: String, recorder: Recorder?) -> void
85
+ def learn!(domain:, recorder:)
86
+ return if recorder.nil?
87
+
88
+ @monitor.synchronize do
89
+ existing = @learned[domain] ||= []
90
+ seen = existing.to_set
91
+ recorder.consulted.each { |sig| existing << sig unless seen.include?(sig) }
92
+ @learned_flag[domain] = true
93
+ end
94
+ end
95
+
96
+ # The cache key for this domain + context, or nil when the domain has not
97
+ # been learned yet (auto mode), which forces a cold compile.
98
+ #: (domain: String, server_context: untyped) -> String?
99
+ def tools_list_key(domain:, server_context:)
100
+ fp = explicit_fingerprint(server_context)
101
+ if fp
102
+ components = ["fp", domain, defs_digest, stable(fp)]
103
+ return digest_key(components)
104
+ end
105
+
106
+ vocab, learned = @monitor.synchronize { [(@learned[domain] || []).dup, @learned_flag[domain]] }
107
+ return nil unless learned
108
+
109
+ sorted = vocab.sort_by(&:canonical)
110
+ vector = sorted.map { |sig| [sig.canonical, stable(evaluate(sig, server_context))] }
111
+ fingerprint = Digest::SHA256.hexdigest(sorted.map(&:canonical).join("\n"))[0, 12]
112
+ digest_key(["v", domain, defs_digest, fingerprint, vector])
113
+ end
114
+
115
+ # Digest of every registered tool's gating + contract source. Changes on
116
+ # deploy (gate edits, handler source mtime), invalidating cached entries
117
+ # without an explicit bust. Memoized; cleared by +reset!+.
118
+ #: () -> String
119
+ def defs_digest
120
+ @monitor.synchronize do
121
+ @defs_digest ||= compute_defs_digest
122
+ end
123
+ end
124
+
125
+ # Clear memoized store, learned vocabulary, and the defs digest. Called by
126
+ # the Engine reloader so code changes in development take effect.
127
+ #: () -> void
128
+ def reset!
129
+ @monitor.synchronize do
130
+ @store = nil
131
+ @learned = {}
132
+ @learned_flag = {}
133
+ @defs_digest = nil
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ #: (untyped) -> untyped
140
+ def resolve_store(setting)
141
+ case setting
142
+ when nil, false then NullStore.new
143
+ when :memory then MemoryStore.new
144
+ when :redis
145
+ RedisStore.new(
146
+ redis: McpAuthorization.config.tools_list_cache_redis,
147
+ url: McpAuthorization.config.tools_list_cache_redis_url
148
+ )
149
+ else
150
+ setting # assume a store-like object (responds to get/set)
151
+ end
152
+ end
153
+
154
+ #: (untyped) -> untyped
155
+ def explicit_fingerprint(server_context)
156
+ return nil unless server_context.respond_to?(:mcp_cache_fingerprint)
157
+
158
+ server_context.mcp_cache_fingerprint
159
+ rescue StandardError
160
+ nil
161
+ end
162
+
163
+ #: (Signature, untyped) -> untyped
164
+ def evaluate(sig, server_context)
165
+ target = sig.target == :user ? server_context.current_user : server_context
166
+ return :__no_target if target.nil?
167
+
168
+ target.public_send(sig.method, sig.arg)
169
+ rescue StandardError
170
+ :__error
171
+ end
172
+
173
+ #: (Array[untyped]) -> String
174
+ def digest_key(components)
175
+ "tl:" + Digest::SHA256.hexdigest(JSON.generate(components))
176
+ end
177
+
178
+ # Coerce a value into a stable, JSON-safe form for the key. Symbols become
179
+ # strings; anything exotic falls back to its inspect string.
180
+ #: (untyped) -> untyped
181
+ def stable(value)
182
+ case value
183
+ when Symbol then value.to_s
184
+ when String, Integer, Float, true, false, nil then value
185
+ when Array then value.map { |v| stable(v) }
186
+ when Hash then value.sort_by { |k, _| k.to_s }.map { |k, v| [k.to_s, stable(v)] }
187
+ else value.inspect
188
+ end
189
+ end
190
+
191
+ #: () -> String
192
+ def compute_defs_digest
193
+ sigs = McpAuthorization::ToolRegistry.registered_tools.map do |tool_class|
194
+ handler = (tool_class._contract_handler rescue nil)
195
+ source = handler_source_mtime(handler)
196
+ [
197
+ tool_class.tool_name.to_s,
198
+ (tool_class._gates || []).map { |g| [g[:name].to_s, g[:value].to_s] },
199
+ handler&.name.to_s,
200
+ source
201
+ ]
202
+ end.sort_by(&:first)
203
+ Digest::SHA256.hexdigest(JSON.generate(sigs))[0, 16]
204
+ rescue StandardError
205
+ # If anything about introspection fails, fall back to a process-stable
206
+ # digest so caching still works within a boot (just not across deploys
207
+ # via the digest — TTL still bounds staleness).
208
+ defined?(McpAuthorization::VERSION) ? McpAuthorization::VERSION : "mcpauth"
209
+ end
210
+
211
+ #: (untyped) -> Integer
212
+ def handler_source_mtime(handler)
213
+ return 0 unless handler
214
+
215
+ file = McpAuthorization::RbsSchemaCompiler.send(:find_source_file, handler)
216
+ file && File.exist?(file) ? File.mtime(file).to_i : 0
217
+ rescue StandardError
218
+ 0
219
+ end
220
+ end
221
+ end
222
+ end
@@ -79,6 +79,34 @@ module McpAuthorization
79
79
  #: bool
80
80
  attr_accessor :strict_schema
81
81
 
82
+ # Cache for the +tools/list+ response. Opt-in; defaults to no caching.
83
+ # Accepts:
84
+ # nil / false — no caching (default)
85
+ # :memory — process-local MemoryStore
86
+ # :redis — shared RedisStore (connection resolved from
87
+ # +tools_list_cache_redis+ / +tools_list_cache_redis_url+ /
88
+ # ENV["REDIS_URL"] / a bare Redis.new — the Rails redis config)
89
+ # <object> — any store responding to +get+/+set+
90
+ # See McpAuthorization::Cache for the keying strategy.
91
+ #: untyped
92
+ attr_accessor :tools_list_cache
93
+
94
+ # TTL (seconds) for cached +tools/list+ entries. Bounds staleness from
95
+ # out-of-band changes (e.g. a feature flag toggled with no deploy); the
96
+ # deploy digest invalidates on tool/schema changes independently.
97
+ #: Integer
98
+ attr_accessor :tools_list_cache_ttl
99
+
100
+ # Optional explicit Redis client for the :redis store. When nil, the store
101
+ # resolves a connection from +tools_list_cache_redis_url+, then
102
+ # ENV["REDIS_URL"], then a bare +Redis.new+.
103
+ #: untyped
104
+ attr_accessor :tools_list_cache_redis
105
+
106
+ # Optional explicit Redis URL for the :redis store.
107
+ #: String?
108
+ attr_accessor :tools_list_cache_redis_url
109
+
82
110
  #: () -> void
83
111
  def initialize
84
112
  @server_name = "mcp-authorization"
@@ -90,6 +118,10 @@ module McpAuthorization
90
118
  @context_builder = nil
91
119
  @cli_context_builder = nil
92
120
  @strict_schema = false
121
+ @tools_list_cache = nil
122
+ @tools_list_cache_ttl = 3600
123
+ @tools_list_cache_redis = nil
124
+ @tools_list_cache_redis_url = nil
93
125
  end
94
126
  end
95
127
  end
@@ -46,6 +46,7 @@ module McpAuthorization
46
46
  app.reloader.to_prepare do
47
47
  McpAuthorization::ToolRegistry.reset!
48
48
  McpAuthorization::RbsSchemaCompiler.reset_cache!
49
+ McpAuthorization::Cache.reset!
49
50
  end
50
51
  end
51
52
 
@@ -73,6 +73,23 @@ module McpAuthorization
73
73
  end
74
74
  end
75
75
 
76
+ # Concrete MCP::Tool subclass for a single named tool within a domain,
77
+ # or nil when the tool is unknown in that domain or the current user is
78
+ # not permitted to use it.
79
+ #
80
+ # Materializing a per-user schema is the dominant cost of handling an MCP
81
+ # request, so a +tools/call+ — which targets exactly one tool — should
82
+ # compile that one tool rather than the whole domain (what
83
+ # +tool_classes_for+ does for +tools/list+).
84
+ #: (domain: String, name: String, server_context: untyped) -> singleton(MCP::Tool)?
85
+ def tool_class_for(domain:, name:, server_context:)
86
+ tool_class = (tools_by_domain[domain] || []).find { |tc| tc.tool_name == name }
87
+ return nil unless tool_class
88
+ return nil unless tool_class.permitted?(server_context)
89
+
90
+ tool_class.materialize_for(server_context)
91
+ end
92
+
76
93
  # Look up a tool by its MCP tool name across all domains.
77
94
  #: (String) -> singleton(McpAuthorization::Tool)?
78
95
  def find_tool(name)
@@ -1,3 +1,3 @@
1
1
  module McpAuthorization
2
- VERSION = "0.5.6"
2
+ VERSION = "0.6.0"
3
3
  end
@@ -6,6 +6,7 @@ require_relative "mcp_authorization/dsl"
6
6
  require_relative "mcp_authorization/rbs_schema_compiler"
7
7
  require_relative "mcp_authorization/tool_registry"
8
8
  require_relative "mcp_authorization/tool"
9
+ require_relative "mcp_authorization/cache"
9
10
  require_relative "mcp_authorization/engine" if defined?(Rails)
10
11
 
11
12
  # MCP Authorization — schema-shaping authorization for MCP tool servers.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp_authorization
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.6
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - AndyGauge
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-09 00:00:00.000000000 Z
11
+ date: 2026-06-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -94,6 +94,11 @@ files:
94
94
  - README.md
95
95
  - app/controllers/mcp_authorization/mcp_controller.rb
96
96
  - lib/mcp_authorization.rb
97
+ - lib/mcp_authorization/cache.rb
98
+ - lib/mcp_authorization/cache/memory_store.rb
99
+ - lib/mcp_authorization/cache/null_store.rb
100
+ - lib/mcp_authorization/cache/recorder.rb
101
+ - lib/mcp_authorization/cache/redis_store.rb
97
102
  - lib/mcp_authorization/configuration.rb
98
103
  - lib/mcp_authorization/diagnostics.rb
99
104
  - lib/mcp_authorization/dsl.rb