mcp_authorization 0.7.1 → 0.8.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: '058c192ec678d3ceb11d6ae5fed31c1025156a83d40cf12f7458054f50935682'
4
- data.tar.gz: 00aee0863c22ead686543f40e8658a3a019711db84c8554c28bcac287bc51253
3
+ metadata.gz: 68b3436ab106d889c6ec4c889e9fef24235db5a5fcd64d840a0365fa3fa3b313
4
+ data.tar.gz: 36946f67a327d9f7ab7a2cea555fb35b16d12d0afab370bdfe80a9dc3b20f5db
5
5
  SHA512:
6
- metadata.gz: aeeb0fa5ab4074654132516b58e0b649aa3eaf3012b3703e98cb9d519c440f39f5bc080bde6947f802a8169073dc03dc1ee2cdf6ffc91b77f520182c7741a143
7
- data.tar.gz: 89d8566f176bb0c6cb53cc84ca933adb01e858df69caa320d9d480759b7479efd814f6392c25fe61ecfef891c6246b7fbc0c6a349ba9c6892a01f1370c9f6255
6
+ metadata.gz: 873fd4f953cfcf45d561b897da001d54155315a3f1add2fab2785b4abc11018af972c8aaeaf6b196031ecac8dad44e1ba52d33b0c945d63309beac0b2332c092
7
+ data.tar.gz: 8da192fdc1e11c65dc84bfbc0ec4c5ed80042279283c784bfa7d7fe55958ce11f216b10ec7b523aa3723b23e685ae3eac67e8f73f3e3466266c65916daeead12
data/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ 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.8.0]
8
+
9
+ A supported seam for tool classes the host generates at runtime instead of
10
+ defining in a file under `tool_paths`. Additive and opt-in — a host that sets
11
+ no producers behaves exactly as before. (#35)
12
+
13
+ ### Added
14
+ - **`config.tool_producers`** — an array of callables that register generated tool classes. Invoked by `ToolRegistry.ensure_tools_loaded!` on the first read of a registry that has not finished loading, immediately after the `tool_paths` eager-load, and again after every `reset!`. Defaults to `[]`.
15
+
16
+ Registering generated tools previously had no supported hook, so hosts improvised one from a Rails boot callback — and both available callbacks are traps. Registering *before* the gem's own load tripped `ensure_tools_loaded!`'s old `return if @registered_tools&.any?` guard and silently suppressed every file-defined tool, leaving a near-empty `tools/list` in any environment that doesn't eager-load. Registering from `config.to_prepare` runs host code during `:run_prepare_callbacks`, which precedes `:eager_load!` and the railtie `after_initialize` that copies `config.i18n` onto `I18n` — so application code loaded there sees an empty `I18n.load_path`, and any class resolving a translation in its class body freezes `"Translation missing: …"` into its validators and option lists permanently, with the failures landing nowhere near MCP. Running producers from a registry read makes both unreachable by construction rather than by documentation.
17
+
18
+ - **Boot-time registry population when the host eager-loads.** The Engine reads the registry from `after_initialize` when `config.eager_load` is on, so a malformed tool — or a producer that raises — fails the deploy rather than the first `tools/list`. Development and test stay lazy. `after_initialize` specifically: it is the earliest phase where the framework is guaranteed to be fully configured, `I18n` included.
19
+
20
+ ### Changed
21
+ - **Loading completion is tracked separately from registry contents.** `ensure_tools_loaded!` previously short-circuited on `return if @registered_tools&.any?`, and `registered_tools` only triggered a load while the array was empty. That conflated "the registry has entries" with "loading finished", which is wrong as soon as loading can fail partway: `eager_load_tool_paths!` registers the file-defined tools *first*, so by the time a producer raises the array is already non-empty — as it also is when a producer registers 40 tools and raises on the 41st. Every later read then no-op'd, so a bad producer failed loudly exactly once and silently forever after, leaving a permanently incomplete surface and contradicting the documented "exceptions propagate" contract. A dedicated `@tools_loaded` flag, set only after every producer returns, replaces both guards; a raising producer is now retried on every read until it is fixed. Re-running is safe — `register` dedupes by identity and `eager_load_dir` is a no-op on an already-loaded directory.
22
+
23
+ Two consequences worth naming. A host that calls `register` directly before the first read no longer suppresses the `tool_paths` load — the silent-erasure hazard producers exist to remove is now unreachable from that direction too. And `reset!` clears the flag, so a reload still reloads.
24
+
25
+ ### Fixed
26
+ - **`ensure_tools_loaded!` no longer raises on a partially-loaded Rails.** The autoloader pass guarded on the bare `Rails` constant and then called `Rails.root` / `Rails.autoloaders`. A process where `Rails` is defined but incomplete (or an unrelated module of that name) satisfied `defined?` and raised `NoMethodError`, taking `registered_tools` down with it — including for a host whose tools all come from producers and need no autoloader at all. It now probes for the methods it actually calls, matching the `defined?(Rails) && Rails.respond_to?(:env)` idiom already used in `Diagnostics`.
27
+
28
+ ### Internal
29
+ - `ensure_tools_loaded!` is reentrant: a producer may read the registry (to inspect what is already registered, say) without recursing forever. The reentrancy guard is cleared in an `ensure`, so a raising producer does not wedge every later read.
30
+ - The `tool_paths` eager-load moved into a private `eager_load_tool_paths!`, leaving `ensure_tools_loaded!` as the ordering contract it documents.
31
+
7
32
  ## [0.7.1]
8
33
 
9
34
  Follow-ups from the 0.7.0 review (onboardiq/mcp_authorization#31).
data/README.md CHANGED
@@ -1,4 +1,5 @@
1
1
  # mcp_authorization
2
+ [read it](https://fountain.engineering/mcp_authorization/)
2
3
 
3
4
  Rails engine for serving MCP tools with per-request schema discrimination compiled from RBS type annotations.
4
5
 
@@ -6,6 +7,10 @@ Add it to your Gemfile and your Rails app speaks [MCP](https://modelcontextproto
6
7
 
7
8
  > Looking for task-oriented "how do I X?" recipes rather than reference? See the **[Cookbook](COOKBOOK.md)**.
8
9
 
10
+ <!-- site:skip -->
11
+ > Prefer these docs as a browsable site, one section per page? **<https://fountain.engineering/mcp_authorization>** — built from these same files by [`site/collect.rb`](site/collect.rb).
12
+ <!-- site:endskip -->
13
+
9
14
  ## Three layers of authorization
10
15
 
11
16
  The gem gives you three independent controls over what each user sees:
@@ -66,9 +71,22 @@ end
66
71
  | `mount_path` | `"/mcp"` | URL prefix for MCP endpoints |
67
72
  | `default_domain` | `"default"` | Domain when no `:domain` segment in path |
68
73
  | `tool_paths` | `["app/mcp"]` | Directories where tool classes live (relative to Rails.root) |
74
+ | `tool_producers` | `[]` | Callables that register tool classes built at runtime — see [Generated tools](#generated-tools) |
69
75
  | `shared_type_paths` | `["sig/shared"]` | Directories where shared `.rbs` type files live |
70
76
  | `context_builder` | *required* | `(request) -> context` |
71
77
  | `cli_context_builder` | `nil` | `(domain:, role:) -> context` for rake tasks |
78
+ | `strict_schema` | `false` | Emit stricter compiled schemas |
79
+ | `tools_list_cache` | `nil` | `:memory`, `:redis`, or any object responding to `get`/`set` — see [Caching `tools/list`](#caching-toolslist) |
80
+ | `tools_list_cache_ttl` | `3600` | Per-entry TTL in seconds |
81
+ | `tools_list_cache_redis` | `nil` | Explicit Redis client for the `:redis` store |
82
+ | `tools_list_cache_redis_url` | `nil` | Explicit Redis URL; falls back to `ENV["REDIS_URL"]`, then `Redis.new` |
83
+
84
+ Two configuration **methods** (not attributes) turn a domain into grouped facades — see [Tool grouping](#tool-grouping-facades):
85
+
86
+ | Method | Purpose |
87
+ |---|---|
88
+ | `facet_domain :domain, group_by: :category, ...` | Present this domain as one facade tool per category. Optional `schema_strategy:`, `uncategorized:`, `facade_suffix:`. |
89
+ | `categories { summary :key, "text" }` | Declare one summary line per group, used as each facade description's lead. |
72
90
 
73
91
  ## The contract
74
92
 
@@ -293,6 +311,7 @@ class MyTool < McpAuthorization::Tool
293
311
  gate :feature, :order_tracking # any predicate: hidden unless server_context.feature?(:order_tracking)
294
312
  gate :tier, :enterprise # multiple gates AND together
295
313
  tags "recruiting", "operations" # which domains this tool appears in
314
+ category :orders # group this tool belongs to in a faceted domain
296
315
  read_only! # MCP annotation hints
297
316
  dynamic_contract MyService # handler class
298
317
  end
@@ -304,6 +323,7 @@ end
304
323
  | `authorization :sym` | Tool-level RBAC visibility gate. Convenience alias for `gate :requires, :sym` — routes through the generic gate pipeline and falls back to `current_user.can?(:sym)` when the server context lacks a `requires?` method. Omit for public tools. |
305
324
  | `gate :predicate, :value` | Tool-level generic predicate gate. Calls `server_context.{predicate}?(value)`. Repeat for AND. Fail-open when the predicate method is missing (warning logged in dev). |
306
325
  | `tags "domain1", ...` | Domain(s) this tool appears under. Defaults to `["default"]`. |
326
+ | `category :name` | Group this tool belongs to when its domain is faceted (see [Tool grouping](#tool-grouping-facades)). Ignored in flat domains. Optional `summary:` kwarg supplies the group summary for single-tool groups. |
307
327
  | `dynamic_contract HandlerClass` | Handler providing description, schemas, and execution |
308
328
  | `read_only!` | Annotation: tool only reads data |
309
329
  | `not_destructive!` | Annotation: tool does not destroy data |
@@ -316,6 +336,47 @@ end
316
336
 
317
337
  Tools self-register when loaded. Put them anywhere under `tool_paths` (default: `app/mcp/`).
318
338
 
339
+ ### Generated tools
340
+
341
+ Some tools aren't worth writing by hand — one per controller action, one per row in a config table, one per endpoint in a family. For those, register a **producer**: a callable that mints the classes and registers them.
342
+
343
+ ```ruby
344
+ McpAuthorization.configure do |config|
345
+ config.tool_producers << -> { MyApp::GeneratedTools.register_all! }
346
+ end
347
+ ```
348
+
349
+ Producers run from `ToolRegistry.ensure_tools_loaded!` — on the first read of an empty registry, immediately after `tool_paths` is eager-loaded, and again after every reload. Two properties follow from that, and both matter:
350
+
351
+ - **Don't register from a Rails boot callback instead.** Generating tools usually means loading the code they derive from, and from `config.to_prepare` that happens during `:run_prepare_callbacks` — before `:eager_load!`, and before railties copy `config.i18n` onto `I18n`. Application code loaded that early sees an empty `I18n.load_path`, so any class resolving a translation in its class body freezes `"Translation missing: …"` into its validators and option lists for the life of the process. A producer sidesteps the ordering question entirely.
352
+ - **Don't register before the registry loads its own tools.** Historically `ensure_tools_loaded!` skipped the `tool_paths` pass once the registry was non-empty, so registering first made every file-defined tool silently disappear from `tools/list`. Producers run *after* that pass, and completion is now tracked separately from "the registry has entries", so the ordering is not yours to get right.
353
+
354
+ A producer must be **idempotent**. `register` dedupes by object identity, not by `tool_name`, so minting a fresh class on every call registers a second tool under the same name and leaves `find_tool` resolving an arbitrary one. Reuse the class while its inputs are unchanged. Exceptions propagate — a malformed generated tool fails the read rather than vanishing.
355
+
356
+ When the host eager-loads (production), the engine reads the registry at `after_initialize`, so a producer that raises fails the deploy instead of the first `tools/list`. Development and test stay lazy.
357
+
358
+ ### Introspecting a tool class
359
+
360
+ Every declaration is readable back off the class, which is what the registry, the facade builder, and the cache digest use:
361
+
362
+ | Reader | Returns |
363
+ |---|---|
364
+ | `_permission` | The symbol passed to `authorization` |
365
+ | `_gates` | `[{ name:, value: }, ...]` for every declared gate |
366
+ | `_tags` | Declared domains |
367
+ | `_category` | Declared category symbol, or `nil` |
368
+ | `_category_summary` | Summary passed to `category(summary:)`, or `nil` |
369
+ | `_contract_handler` | The handler class |
370
+
371
+ `ToolRegistry` is the entry point for turning those declarations into MCP tools:
372
+
373
+ | Registry method | Purpose |
374
+ |---|---|
375
+ | `tool_classes_for(domain:, server_context:)` | Every permitted tool in a domain (the `tools/list` path) |
376
+ | `tool_class_for(domain:, name:, server_context:)` | One named tool, or `nil` (the `tools/call` path) |
377
+ | `facades_for(domain:, server_context:)` | Facades for a faceted domain |
378
+ | `facade_for(domain:, name:, server_context:)` | One facade by name |
379
+
319
380
  ## Contract validation
320
381
 
321
382
  If a handler is missing required methods or schema definitions, the gem raises an `ArgumentError` on first request with a full diagnostic:
@@ -384,6 +445,19 @@ McpAuthorization.configure do |config|
384
445
  end
385
446
  ```
386
447
 
448
+ Grouping is opt-in and per-domain: a domain with no `facet_domain` behaves
449
+ exactly as before, and `category` is inert there. `facet_domain` takes:
450
+
451
+ | Option | Default | Description |
452
+ |---|---|---|
453
+ | `group_by:` | *required* | Grouping key. Only `:category` today; anything else raises `ArgumentError`. |
454
+ | `schema_strategy:` | `:vendor_extension` | Where per-tool argument schemas go — `:vendor_extension` or `:lazy` (below). |
455
+ | `uncategorized:` | `:fallback` | `:fallback` collects tools with no `category` into an `uncategorized` group; `:error` raises instead. |
456
+ | `facade_suffix:` | `"tools"` | Token appended to a category to form the facade name (`orders_tools`). |
457
+
458
+ For a group that holds a single tool, `category :orders, summary: "..."` saves a
459
+ trip to the central registry. When both are declared, `config.categories` wins.
460
+
387
461
  `tools/list` for the domain then returns one facade per group the caller has
388
462
  at least one permitted tool in (`orders_tools`, `billing_tools`), each
389
463
  describing its tools with RBAC-filtered one-liners. Calling a facade names the
@@ -394,10 +468,40 @@ inner tool and its arguments:
394
468
  "arguments": { "tool_name": "update_order", "arguments": { "id": "o_1" } } }
395
469
  ```
396
470
 
397
- Dispatch resolves the real tool through its normal call path — authorization,
398
- gating, and schema filtering apply exactly as if the tool had been called
399
- directly, and JSON-string argument blobs are coerced against the *target*
400
- tool's schema.
471
+ ### Facade shape
472
+
473
+ | Part | Value |
474
+ |---|---|
475
+ | Name | `"#{category}_#{facade_suffix}"` — default suffix `tools`, so `orders_tools` |
476
+ | Description | Group summary, then one line per tool the caller may invoke: `- tool_name — first line of that tool's description for this caller` |
477
+ | `inputSchema` | Flat object: `tool_name` (string enum of permitted tool names) + `arguments` (object). Both required. |
478
+ | `_meta` | Under `:vendor_extension`, key `"tool-input-schemas"` — a map of tool name to that caller's compiled input schema |
479
+
480
+ ### Facade dispatch
481
+
482
+ Dispatch resolves the real tool through its normal call path — there is no
483
+ second code path, and therefore no second place for authorization to be wrong:
484
+
485
+ 1. **Advertised-set check.** `tool_name` must be in the set advertised to *this*
486
+ caller, else `ArgumentError` listing the valid names.
487
+ 2. **Re-resolution.** The tool is re-fetched via `ToolRegistry.tool_class_for`,
488
+ which re-runs `permitted?` — so a stale advertised set cached by a client
489
+ cannot get a caller into a tool they've lost access to. Failure raises
490
+ `NotAuthorizedError`.
491
+ 3. **Argument coercion.** MCP clients frequently serialize nested objects as JSON
492
+ strings, and the facade's generic `arguments: object` contract cannot know
493
+ which fields are structured. The `arguments` blob itself, and any top-level
494
+ value whose type in the *target's* compiled schema is `object` or `array`, are
495
+ JSON-parsed. Invalid JSON raises `ArgumentError` naming the field.
496
+ 4. **Delegation.** The target's materialized `call` runs, applying `filter_input`
497
+ and `filter_output` exactly as in a direct call.
498
+
499
+ Direct tool names still resolve on `tools/call`, so a client that learned a
500
+ real tool name before the domain was faceted keeps working.
501
+
502
+ A `tools/call` build skips the `_meta` per-tool schema map (`for_dispatch: true`)
503
+ — nothing on the call path reads it, and building it would recompile every tool
504
+ in the group on every call.
401
505
 
402
506
  The facade `inputSchema` is always a flat object (a `tool_name` enum plus a
403
507
  permissive `arguments` object). It has to be: an LLM tool `input_schema` must
@@ -417,13 +521,31 @@ therefore only chooses where the per-tool schemas go:
417
521
 
418
522
  Uncategorized tools land in an `uncategorized` facade by default; pass
419
523
  `uncategorized: :error` to fail fast instead. Groups with zero permitted tools
420
- are hidden entirely, so a facade never advertises an empty `enum`.
524
+ are hidden entirely, so a facade never advertises an empty `enum`. A facade
525
+ name that collides with a real registered tool in the domain raises
526
+ `FacadeBuilder::FacadeNameCollisionError` rather than shadowing that tool.
421
527
 
422
528
  Facade names are `#{category}_tools` by default. Override the suffix per domain
423
529
  with `facade_suffix:` — e.g. `config.facet_domain :admin, group_by: :category,
424
530
  facade_suffix: "hire"` exposes `orders_hire`, `billing_hire`. The suffix must be
425
531
  a lowercase identifier fragment (`[a-z0-9_]`) so the derived name stays a valid
426
- MCP tool name. See `docs/designs/tool-grouping-facades.md` for the full design.
532
+ MCP tool name. See [the design doc](docs/designs/tool-grouping-facades.md) for
533
+ the full rationale.
534
+
535
+ Facet configuration participates in the [`tools/list` cache](#caching-toolslist)
536
+ digest — each tool's `category`, every `facet_domain` setting, and every group
537
+ summary — so toggling grouping or rewording a summary invalidates cached
538
+ listings the same way a gate change does.
539
+
540
+ ### Facade errors
541
+
542
+ | Error | Raised when |
543
+ |---|---|
544
+ | `FacadeBuilder::UncategorizedToolError` | A tool has no `category` in a domain faceted with `uncategorized: :error` |
545
+ | `FacadeBuilder::FacadeNameCollisionError` | A derived facade name collides with a registered tool in the domain |
546
+ | `ArgumentError` (config) | Invalid `group_by:`, `schema_strategy:`, `uncategorized:`, or `facade_suffix:` |
547
+ | `ArgumentError` (dispatch) | `tool_name` not advertised, or a string argument that isn't valid JSON |
548
+ | `NotAuthorizedError` | Re-resolution found the caller isn't permitted after all |
427
549
 
428
550
  ## RBS type syntax
429
551
 
@@ -458,6 +580,16 @@ The `@rbs type` comments compile to JSON Schema:
458
580
  # @rbs type input = { id: String, status: status }
459
581
  ```
460
582
 
583
+ Every handler must declare `@rbs type output`. It may be a single record, a reference, or a union:
584
+
585
+ ```ruby
586
+ # @rbs type output = { id: String } # inline record
587
+ # @rbs type output = applicant # reference
588
+ # @rbs type output = success | error # union
589
+ ```
590
+
591
+ When a type appears more than once in a compiled schema, it is hoisted into `$defs` and referenced with `$ref` rather than inlined repeatedly. This is automatic — nothing to declare.
592
+
461
593
  ### Constraint and annotation tags
462
594
 
463
595
  Tag any field in a `#:` annotation or `@rbs type` record to add JSON Schema constraints. Tags are written as `@tag(value)` after the type:
@@ -557,6 +689,17 @@ end
557
689
 
558
690
  The `@min` / `@max` tags are type-aware: on strings they emit `minLength`/`maxLength`, on numbers `minimum`/`maximum`, and on arrays `minItems`/`maxItems`.
559
691
 
692
+ ### Where tags may appear
693
+
694
+ | Position | Effect |
695
+ |---|---|
696
+ | On a param in `#:` | Constrains or gates that input field |
697
+ | On a field in an `@rbs type` record | Constrains or gates that output field |
698
+ | On a member of an `@rbs type` union | Gates that whole output variant |
699
+ | On an inline literal union member | Gates that individual member |
700
+
701
+ A tag trailing a whole inline literal union applies to the **field**, not to the last member — the compiler distinguishes the two by whether any non-final member carries a tag.
702
+
560
703
  ### Multiline `#:` annotations
561
704
 
562
705
  The `#:` annotation above `def call` supports multiple lines. Each line starts with `#:`:
@@ -611,6 +754,70 @@ MCP clients can narrow on `success: const true` vs `success: const false` -- the
611
754
 
612
755
  Source files are parsed once at boot and cached in memory. Only `@requires` filtering runs per request (hash lookups and `can?` calls). In development, caches are cleared automatically on file change via the Rails reloader.
613
756
 
757
+ Per-request work is also scoped to what the incoming JSON-RPC method actually needs, since per-tool schema compilation is the dominant cost of an MCP request:
758
+
759
+ | Method | Tools materialized |
760
+ |---|---|
761
+ | `tools/list` | Every permitted tool in the domain |
762
+ | `tools/call` | Only the invoked tool |
763
+ | `initialize`, `notifications/initialized`, `ping`, GET stream probe | None |
764
+ | Unrecognized shape (e.g. a batch with no top-level `method`) | Full domain, so routing stays correct |
765
+
766
+ In a 140-tool domain that took a `tools/call` from ~2.6s to under 100ms and `notifications/initialized` from ~2s to ~1ms, with no change to `tools/list` output. What remains is the listing itself — which is cacheable (below) and, for very large domains, [groupable](#tool-grouping-facades).
767
+
768
+ ## Caching `tools/list`
769
+
770
+ `tools/list` must materialize a per-user schema for every tool in a domain. That cost can be cached. Default is no caching, so nothing changes unless you opt in:
771
+
772
+ ```ruby
773
+ McpAuthorization.configure do |c|
774
+ c.tools_list_cache = :redis # or :memory, or any object responding to get/set
775
+ c.tools_list_cache_ttl = 3600 # seconds (default)
776
+ end
777
+ ```
778
+
779
+ | Store | Behavior |
780
+ |---|---|
781
+ | `:memory` | Process-local, bounded LRU with per-entry TTL |
782
+ | `:redis` | Shared across processes, JSON values, per-entry TTL |
783
+ | custom object | Anything responding to `get`/`set` |
784
+ | *(unset)* | `NullStore` — no caching |
785
+
786
+ The Redis 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.
787
+
788
+ ### The key is a decision vector, not an identity
789
+
790
+ ```
791
+ H(domain + tool_defs_digest + vocab_fingerprint + decision_vector)
792
+ ```
793
+
794
+ The **decision vector** is the result of every gating decision the domain's compilation consults — `@requires` / `@feature` / `@tier` / custom predicates, tool-level `gate` and `authorization`, and `current_user.can?` / `default_for`. It never includes user or account identity.
795
+
796
+ Two contexts that answer all of those identically produce identical schemas by construction, so they 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` (each tool's gates plus handler source, plus facet configuration) changes on deploy, auto-invalidating stale entries; the TTL bounds out-of-band staleness, such as a permission changed directly in the database.
797
+
798
+ ### Two ways to supply the vector
799
+
800
+ **Automatic.** On the first (cold) compile the gem wraps the context in a `Cache::Recorder`, learns the domain's predicate vocabulary, then replays that vocabulary against the live context on later requests.
801
+
802
+ **Explicit.** If the server context responds to `mcp_cache_fingerprint`, its return value is used verbatim as the decision component and the recorder is skipped:
803
+
804
+ ```ruby
805
+ class ServerContext
806
+ def mcp_cache_fingerprint
807
+ [current_user.role, account.enabled_features.sort, account.plan_tier]
808
+ end
809
+ end
810
+ ```
811
+
812
+ Explicit wins when present. Reach for it when gating depends on something the recorder cannot observe, or when you would rather own the invalidation contract than infer it.
813
+
814
+ ### Operational notes
815
+
816
+ - **Cache outages fail open** — a `get`/`set` error is logged and behaves as a miss, never breaking `tools/list`.
817
+ - **Only successful listings are cached.** Error and unexpected responses render but are not stored.
818
+ - **A hit still rebuilds the envelope** — the cached `result` is re-wrapped with the live JSON-RPC id.
819
+ - **Development reloads clear it** (the reloader calls `Cache.reset!`), so an edited annotation shows up immediately.
820
+
614
821
  ## Development
615
822
 
616
823
  ### Live reload
@@ -50,6 +50,27 @@ module McpAuthorization
50
50
  #: Array[String]
51
51
  attr_accessor :tool_paths
52
52
 
53
+ # Callables that register tool classes the host generates at runtime,
54
+ # rather than defining in a file under +tool_paths+.
55
+ #
56
+ # config.tool_producers << -> { MyApp::GeneratedTools.register_all! }
57
+ #
58
+ # Invoked by +ToolRegistry.ensure_tools_loaded!+ after the +tool_paths+
59
+ # eager-load, on the first read of an empty registry. Because a producer
60
+ # runs from a registry read rather than a Rails boot callback, it cannot
61
+ # execute before the framework is fully configured — see
62
+ # +ToolRegistry.ensure_tools_loaded!+ for why that matters.
63
+ #
64
+ # A producer must be idempotent: +register+ dedupes by object identity,
65
+ # not by +tool_name+, so minting a fresh class on every call registers a
66
+ # second tool under the same name and leaves +find_tool+ resolving an
67
+ # arbitrary one. Reuse the class while its inputs are unchanged.
68
+ #
69
+ # Exceptions propagate — a malformed generated tool fails the read rather
70
+ # than silently vanishing from the surface.
71
+ #: Array[^() -> void]
72
+ attr_accessor :tool_producers
73
+
53
74
  # Directories (relative to +Rails.root+) where shared +.rbs+ type
54
75
  # files live. Used by RbsSchemaCompiler to resolve +# @rbs import+.
55
76
  #: Array[String]
@@ -151,6 +172,7 @@ module McpAuthorization
151
172
  @server_name = "mcp-authorization"
152
173
  @server_version = "1.0.0"
153
174
  @tool_paths = %w[app/mcp]
175
+ @tool_producers = []
154
176
  @shared_type_paths = %w[sig/shared]
155
177
  @default_domain = "default"
156
178
  @mount_path = "/mcp"
@@ -50,6 +50,23 @@ module McpAuthorization
50
50
  end
51
51
  end
52
52
 
53
+ # Populate the registry at boot when the host eager-loads (production), so
54
+ # a malformed tool — or a +tool_producers+ callable that raises — fails the
55
+ # deploy instead of the first +tools/list+.
56
+ #
57
+ # +after_initialize+ specifically: this read may load a large part of the
58
+ # host application, and it is the earliest phase where the framework is
59
+ # guaranteed to be fully configured — notably +I18n+, which railties only
60
+ # wire up in this same phase. Rails cannot have loaded this Engine before
61
+ # its own railties, so this block is always registered after theirs, and
62
+ # load hooks run in registration order.
63
+ #
64
+ # Development and test stay lazy: nothing loads until something reads the
65
+ # registry.
66
+ config.after_initialize do |app|
67
+ McpAuthorization::ToolRegistry.registered_tools if app.config.eager_load
68
+ end
69
+
53
70
  # Prepend MCP routes into the host app's router. Uses +prepend+ so the
54
71
  # MCP endpoint is available before any catch-all routes the host may
55
72
  # define. Supports both domain-scoped and bare paths.
@@ -24,22 +24,65 @@ module McpAuthorization
24
24
  end
25
25
 
26
26
  # All registered tool classes. Triggers eager loading on first access.
27
+ #
28
+ # Gated on "loading finished", not on "the array has entries" — see
29
+ # +ensure_tools_loaded!+ for why those must not be conflated.
27
30
  #: () -> Array[singleton(McpAuthorization::Tool)]
28
31
  def registered_tools
29
32
  tools = (@registered_tools ||= [])
30
- ensure_tools_loaded! if tools.empty?
33
+ ensure_tools_loaded! unless @tools_loaded
31
34
  tools
32
35
  end
33
36
 
34
- # Force-loads tool directories so tool classes self-register.
37
+ # Force-loads tool directories so tool classes self-register, then runs
38
+ # +config.tool_producers+ for tools the host generates at runtime.
39
+ #
40
+ # Deliberately lazy, and the ordering here is the contract:
41
+ #
42
+ # * *Producers run last*, inside this method, so a host cannot register a
43
+ # generated tool ahead of the +tool_paths+ eager-load and suppress it.
44
+ #
45
+ # * *Producers run on a registry read, never from a boot callback.*
46
+ # Generating tool classes means loading the code they derive from,
47
+ # which in a Rails app pulls in a large share of the application. Doing
48
+ # that from +config.to_prepare+ runs it during +:run_prepare_callbacks+,
49
+ # which precedes +:eager_load!+ and +:finisher_hook+ — and +config.i18n+
50
+ # is only copied onto +I18n+ from a railtie-level +after_initialize+.
51
+ # Application code loaded that early sees an empty +I18n.load_path+, so
52
+ # any class resolving a translation in its class body freezes
53
+ # "Translation missing: ..." into validators and option lists
54
+ # permanently. Deferring to first read sidesteps the whole ordering
55
+ # question rather than asking each host to solve it.
56
+ #
57
+ # * *Producers re-run after +reset!+.* The Engine resets the registry on
58
+ # every code reload; the next read repopulates it, producers included.
59
+ #
60
+ # +@tools_loaded+ tracks completion, and is set only after every producer
61
+ # has returned. It deliberately does NOT reuse "is +@registered_tools+
62
+ # non-empty?" as the signal, because a non-empty registry does not mean
63
+ # loading succeeded: +eager_load_tool_paths!+ registers the file-defined
64
+ # tools first, so by the time a producer raises the array is already
65
+ # populated — as it also is when a producer registers 40 tools and raises
66
+ # on the 41st. Guarding on that would make the failure loud exactly once
67
+ # and silent forever after, leaving a permanently incomplete surface. The
68
+ # contract is the opposite: a producer that raises fails *every* read until
69
+ # it is fixed. Re-running is safe — +register+ dedupes by identity and
70
+ # +eager_load_dir+ is a no-op on an already-loaded directory.
71
+ #
72
+ # The reentrancy guard lets a producer call back into the registry (to
73
+ # inspect what is already registered, say) without recursing forever.
35
74
  #: () -> void
36
75
  def ensure_tools_loaded!
37
- return if @registered_tools&.any?
38
- return unless defined?(Rails)
76
+ return if @tools_loaded
77
+ return if @loading_tools
39
78
 
40
- McpAuthorization.config.tool_paths.each do |path|
41
- full_path = Rails.root.join(path)
42
- Rails.autoloaders.main.eager_load_dir(full_path) if File.directory?(full_path)
79
+ @loading_tools = true
80
+ begin
81
+ eager_load_tool_paths!
82
+ McpAuthorization.config.tool_producers.each(&:call)
83
+ @tools_loaded = true
84
+ ensure
85
+ @loading_tools = false
43
86
  end
44
87
  end
45
88
 
@@ -115,9 +158,30 @@ module McpAuthorization
115
158
  end
116
159
 
117
160
  # Clear the registry. Called by the Engine's reloader on code change.
161
+ # The next read reloads +tool_paths+ and re-runs +tool_producers+.
118
162
  #: () -> void
119
163
  def reset!
120
164
  @registered_tools = []
165
+ @loading_tools = false
166
+ @tools_loaded = false
167
+ end
168
+
169
+ private
170
+
171
+ # Probes for the methods actually called rather than the bare constant,
172
+ # matching Diagnostics' `defined?(Rails) && Rails.respond_to?(:env)`.
173
+ # A partially-loaded Rails (or an unrelated `Rails` module in a non-Rails
174
+ # process) satisfies `defined?` and then raises NoMethodError on `.root`,
175
+ # which would take `registered_tools` down with it — including for a host
176
+ # whose tools all come from `tool_producers` and need no autoloader.
177
+ #: () -> void
178
+ def eager_load_tool_paths!
179
+ return unless defined?(Rails) && Rails.respond_to?(:root) && Rails.respond_to?(:autoloaders)
180
+
181
+ McpAuthorization.config.tool_paths.each do |path|
182
+ full_path = Rails.root.join(path)
183
+ Rails.autoloaders.main.eager_load_dir(full_path) if File.directory?(full_path)
184
+ end
121
185
  end
122
186
  end
123
187
  end
@@ -1,3 +1,3 @@
1
1
  module McpAuthorization
2
- VERSION = "0.7.1"
2
+ VERSION = "0.8.0"
3
3
  end
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.7.1
4
+ version: 0.8.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-07-23 00:00:00.000000000 Z
11
+ date: 2026-08-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails