mcp_authorization 0.5.5 → 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 +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +2 -0
- data/app/controllers/mcp_authorization/mcp_controller.rb +108 -7
- data/lib/mcp_authorization/cache/memory_store.rb +62 -0
- data/lib/mcp_authorization/cache/null_store.rb +20 -0
- data/lib/mcp_authorization/cache/recorder.rb +110 -0
- data/lib/mcp_authorization/cache/redis_store.rb +102 -0
- data/lib/mcp_authorization/cache.rb +222 -0
- data/lib/mcp_authorization/configuration.rb +32 -0
- data/lib/mcp_authorization/engine.rb +1 -0
- data/lib/mcp_authorization/rbs_schema_compiler.rb +15 -3
- data/lib/mcp_authorization/tool_registry.rb +17 -0
- data/lib/mcp_authorization/version.rb +1 -1
- data/lib/mcp_authorization.rb +1 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 63bcf798beee003e44d7894f8e2e80177a6ddb1e7357b68507a74464c2665ce6
|
|
4
|
+
data.tar.gz: 047560ce4c65b3b960f7c66904ca48c70b513e90510d7714edbd93996c39f919
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 41b2c6a57ffeda77b7c74e2d2a849da6653d6678f78e9a76b9dec0cdaef5059b4ea00449063c57254e526c9cf6ebc780a7f43b59bfefeb57fabcc12e2f966842
|
|
7
|
+
data.tar.gz: 7dc31d4854025087e495bc9963ee46079a35f3cc6cc6fcbd3a904b00383017e6f68b7718db51a49fb78585657d86158e637fcbd5a182a72b1ad6d09ec83f362e
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,44 @@ 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
|
+
|
|
40
|
+
## [0.5.6] - 2026-06-08
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
- **Single-line and column-aligned `# @rbs type` record aliases are now collected.** A record alias written on one line — `# @rbs type ok = { a: String, b: Integer }` — was silently dropped: `collect_inline_aliases` truncated the body to a bare `{` and only a closing brace on a *following* line ever balanced it. Any union or field referencing such an alias resolved to the `{type: "object"}` fallback (no properties, no per-request gating), so the advertised schema and runtime projection both lost the type's shape. The opening-line body is now captured whole and stored immediately when its braces balance. Relatedly, the alias regex now tolerates arbitrary whitespace around `=`, so column-aligned blocks (`# @rbs type success = { ... }`) parse instead of being skipped. Multi-line aliases are unaffected.
|
|
44
|
+
|
|
7
45
|
## [0.5.5] - 2026-06-04
|
|
8
46
|
|
|
9
47
|
### Fixed
|
data/README.md
CHANGED
|
@@ -4,6 +4,8 @@ Rails engine for serving MCP tools with per-request schema discrimination compil
|
|
|
4
4
|
|
|
5
5
|
Add it to your Gemfile and your Rails app speaks [MCP](https://modelcontextprotocol.io). Write `@rbs type` comments in plain Ruby service classes, tag fields and variants with `@requires(:flag)`, and the gem compiles tailored JSON Schema per request. The type definitions are the authorization policy.
|
|
6
6
|
|
|
7
|
+
> Looking for task-oriented "how do I X?" recipes rather than reference? See the **[Cookbook](COOKBOOK.md)**.
|
|
8
|
+
|
|
7
9
|
## Three layers of authorization
|
|
8
10
|
|
|
9
11
|
The gem gives you three independent controls over what each user sees:
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -1476,10 +1476,22 @@ module McpAuthorization
|
|
|
1476
1476
|
current_body = +""
|
|
1477
1477
|
|
|
1478
1478
|
content.each_line do |line|
|
|
1479
|
-
if line =~
|
|
1479
|
+
if line =~ /#\s*@rbs type (\w+)\s*=\s*(\{.*)$/
|
|
1480
|
+
# Start of a record alias. Capture the body from the first `{` to
|
|
1481
|
+
# end of line (minus any trailing comment), so a record written on
|
|
1482
|
+
# one line — `# @rbs type ok = { a: String }` — is captured whole
|
|
1483
|
+
# rather than truncated to a bare `{` (which silently dropped the
|
|
1484
|
+
# alias). If the braces already balance it's a single-line record;
|
|
1485
|
+
# otherwise keep accumulating on the following lines. Whitespace
|
|
1486
|
+
# around `=` is tolerated so column-aligned aliases still parse.
|
|
1480
1487
|
current_name = $1.to_s
|
|
1481
|
-
current_body =
|
|
1482
|
-
|
|
1488
|
+
current_body = +strip_rbs_comment($2.to_s)
|
|
1489
|
+
if brace_balanced?(current_body)
|
|
1490
|
+
aliases[current_name] = current_body
|
|
1491
|
+
current_name = nil
|
|
1492
|
+
current_body = +""
|
|
1493
|
+
end
|
|
1494
|
+
elsif line =~ /#\s*@rbs type (\w+)\s*=\s*"([^"]+)"/
|
|
1483
1495
|
aliases[$1.to_s] = parse_string_union($2.to_s, line, content)
|
|
1484
1496
|
elsif current_name
|
|
1485
1497
|
stripped = strip_rbs_comment(line.strip.sub(/^#\s*/, ""))
|
|
@@ -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)
|
data/lib/mcp_authorization.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|