mcp_authorization 0.7.1 → 0.9.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 +51 -0
- data/README.md +223 -8
- data/lib/mcp_authorization/configuration.rb +43 -0
- data/lib/mcp_authorization/engine.rb +17 -0
- data/lib/mcp_authorization/facade_builder.rb +69 -7
- data/lib/mcp_authorization/rbs_schema_compiler.rb +53 -4
- data/lib/mcp_authorization/tool.rb +14 -6
- data/lib/mcp_authorization/tool_registry.rb +71 -7
- data/lib/mcp_authorization/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b01924de48adcd82456518091bcfb6309908158c118fe0865e600417a70be766
|
|
4
|
+
data.tar.gz: 59ec07c72ae70fd00a77ff4e935b0c9feb03b5344e080b3d83aecfecf799390c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b603364bfb0c0aa3174e714f321b99c11394c938ccffd930aed01c950af91e60ab224a91565feafa3d1ee600a3327ec2c4c6d5d3420f69baf6f2e8b0ace25f02
|
|
7
|
+
data.tar.gz: a090369d9fe2da4276327b72ac3cf59f9e1626148c980093b42d6d3f85f0486bf496d3a9b6b5a906b1cec5d02e1d53b6b5e920c26c3d604c504655b522305cb5
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,57 @@ 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.9.0]
|
|
8
|
+
|
|
9
|
+
Top-level params outside the caller's input schema are rejected instead of
|
|
10
|
+
silently dropped. Behavior change on by default — opt out with
|
|
11
|
+
`config.reject_unknown_input_keys = false`.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- **`RbsSchemaCompiler.filter_input` raises `McpAuthorization::UnknownInputKeysError`** when the params carry a top-level key that is not in the caller's compiled input schema. Previously every such key was dropped by the closed-by-default projection, so a `tools/call` with an invented filter (`list_applicants(stage_id:, data: { booked_meeting: "true" }, limit: 100)`) returned `success: true` with the *unfiltered* page — and the calling agent moved every applicant on it, reporting the filter as applied. The same projection let a non-admin's `force: true` through as a silent no-op the agent then reported as done. The caller's schema is now the contract: a key the tool never declared and a key gated behind a predicate this caller fails are both outside it and are both rejected. The handler never runs, so a gated field is still never applied for a caller who lacks it. The error's `unknown_keys` / `accepted_keys` and its message name the offending keys and the params in this caller's schema — nothing `tools/list` did not already show that caller. Keys nested inside a declared object param are still projected silently; this release covers the top level only.
|
|
15
|
+
- **The message is written for the model that reads it.** It states that nothing was executed, lists the accepted params, and tells the caller not to re-run the call without the rejected parameter (the result would be unfiltered) and not to approximate it with another one. A message that said "retry with accepted parameters" would have reproduced the incident.
|
|
16
|
+
- **Materialized tools return the rejection in-band.** `Tool.materialize_for`'s `call` rescues `UnknownInputKeysError` and returns `MCP::Tool::Response.new([...], error: true)` with the text `<tool_name>: <message>`, rather than the SDK wrapping it as a JSON-RPC `-32603` that names the facade instead of the tool. Facade dispatch reaches the target's materialized `call`, so a facade caller — which only ever saw `arguments: object` — gets the same in-band error naming the inner tool. The non-materialized `Tool.call` lets the error propagate.
|
|
17
|
+
- **Facade dispatch rejects keys beside `tool_name` and `arguments`.** The facade schema declares only those two, and dispatch read only `arguments`, so `{ tool_name:, arguments:, data: {...}, limit: 100 }` ran the target unfiltered and returned success. The facade now answers in-band naming itself. Its guidance differs from a tool's on purpose: a flattened parameter is supported and merely misplaced, so the caller is told to re-send it inside `arguments`, not to give up on it. `reject_unknown_input_keys = false` restores the old behavior here too.
|
|
18
|
+
- **A facade `arguments` that is not an object is rejected instead of dropped.** `coerce_arguments` returned `{}` for an array, a scalar, a raw JSON blob of either, or `nil`, so the target ran with no arguments at all and the unfiltered default came back as a success — the same shape as the incident above, on the same surface.
|
|
19
|
+
- **An `arguments.server_context` can no longer replace the request's context.** Dispatch splatted the caller's arguments *after* the `server_context:` keyword, and a later duplicate keyword wins in Ruby, so a caller-supplied `server_context` key bound the context used to compile the schema. Every predicate then ran against that object and fell open, admitting `@requires`/`@feature`-gated fields. The keyword is now passed last. Present since facades shipped in 0.7.0; direct (non-facade) calls were never affected, since the SDK passes `server_context:` last.
|
|
20
|
+
- **An unroutable `tool_name` and a malformed `arguments` JSON blob come back in-band** with the facade's name and the list of tools the caller may use, instead of raising out of band.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **`config.reject_unknown_input_keys`** (default `true`). `false` restores the 0.8 drop-everything behavior for both unknown and gated keys.
|
|
24
|
+
- **`config.ignored_input_keys`** (default `[]`). Names the envelope keys a host's client attaches to every call — a UI annotation, not a tool parameter — which are dropped rather than rejected. Applied wherever a key is checked against a schema: beside `tool_name` and `arguments` on a facade call, and at the top level of any tool's params. A client that annotates every call reaches a flat domain directly, where there is no facade to exempt it, so one setting covers both surfaces.
|
|
25
|
+
|
|
26
|
+
### Migration notes
|
|
27
|
+
- **Check what your client puts beside `tool_name` and `arguments` before upgrading.** A client that annotates every facade call with an envelope key had it silently ignored through 0.8 and now has every call rejected. Name the key in `config.ignored_input_keys`, or declare it on the tools. Measuring the model's tool calls is not enough to find these: a host's own envelope is added by code, so it rides beside `arguments` where a survey of invented parameter names cannot see it, and a call the host makes on its own behalf may never appear in the conversation log at all. Read the client's source.
|
|
28
|
+
- **Migrating a tool onto this gem is now a contract change.** A tool that used to be served by a hand-rolled endpoint, where an unknown key fell into a permit list and vanished, starts rejecting whatever its caller adds. When that caller is code rather than a model there is nobody to read the error and retry, so the key has to be declared or ignored in the same change that migrates the tool.
|
|
29
|
+
- `reject_unknown_input_keys = false` does not cover the non-object `arguments` rejection: `arguments` is `required` in the facade schema, so an array, scalar or `nil` is rejected whatever the flag says. A host that leaves the MCP SDK's `validate_tool_call_arguments` on never reaches it, because the SDK rejects first.
|
|
30
|
+
- A host that overrides `materialize_for` (or calls `filter_input` directly) must `rescue McpAuthorization::UnknownInputKeysError` and build its own error response. Until it does, the rejection still happens, but what the caller receives depends on the MCP SDK version — an in-band `Internal error calling tool <name>` response on some, a JSON-RPC internal error on others — and the host's exception reporter fires on every model typo.
|
|
31
|
+
- Any client or test that relied on a top-level extra key being ignored — invented or gated — now sees an error. That reliance was the bug this release removes. A client that sends the full field set regardless of the caller's permissions must send only the fields in that caller's `tools/list` schema.
|
|
32
|
+
|
|
33
|
+
## [0.8.0]
|
|
34
|
+
|
|
35
|
+
A supported seam for tool classes the host generates at runtime instead of
|
|
36
|
+
defining in a file under `tool_paths`. Additive and opt-in — a host that sets
|
|
37
|
+
no producers behaves exactly as before. (#35)
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
- **`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 `[]`.
|
|
41
|
+
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
- **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.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
- **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.
|
|
48
|
+
|
|
49
|
+
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.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
- **`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`.
|
|
53
|
+
|
|
54
|
+
### Internal
|
|
55
|
+
- `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.
|
|
56
|
+
- The `tool_paths` eager-load moved into a private `eager_load_tool_paths!`, leaving `ensure_tools_loaded!` as the ordering contract it documents.
|
|
57
|
+
|
|
7
58
|
## [0.7.1]
|
|
8
59
|
|
|
9
60
|
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:
|
|
@@ -13,7 +18,7 @@ The gem gives you three independent controls over what each user sees:
|
|
|
13
18
|
| Layer | Mechanism | Effect |
|
|
14
19
|
|---|---|---|
|
|
15
20
|
| **Tool visibility** | `authorization :manage_workflows` (RBAC) or `gate :feature, :sms` (any predicate) on the tool class | Tool hidden entirely from users who fail any check |
|
|
16
|
-
| **Input fields** | `@requires(:backward_routing)` or `@feature(:sms)` on a param in `#:` annotation | Field excluded from the input schema
|
|
21
|
+
| **Input fields** | `@requires(:backward_routing)` or `@feature(:sms)` on a param in `#:` annotation | Field excluded from the input schema; a top-level one sent anyway is rejected at call time, a nested one is stripped |
|
|
17
22
|
| **Output variants** | `@requires(:backward_routing)` or `@feature(:sms)` on a variant in `@rbs type output` | Variant excluded from the `oneOf` *and* fields projected out of the handler's return value before it crosses the wire |
|
|
18
23
|
|
|
19
24
|
Tool-level `authorization :perm` is RBAC (calls `current_user.can?`). Tool-level `gate :predicate, :value` and the field-level annotations are generic — any predicate name works, as long as the server context implements `{predicate}?(value)`. See [Generic predicate tags](#generic-predicate-tags) below.
|
|
@@ -22,7 +27,13 @@ Tool-level `authorization :perm` is RBAC (calls `current_user.can?`). Tool-level
|
|
|
22
27
|
|
|
23
28
|
`@requires` is a security boundary, not a hint. At tool-call time the gem:
|
|
24
29
|
|
|
25
|
-
- **
|
|
30
|
+
- **Rejects inbound params outside the caller's compiled input schema.** The schema the caller received from `tools/list` is the contract. A top-level key outside it — one the tool never declared, or one gated behind a predicate this caller fails — does not exist from the caller's point of view. Such a key used to be dropped silently, which returned a success-shaped result that ignored part of the request: an LLM that never saw the per-tool schema (e.g. behind a [facade](#tool-grouping-facades)) invented a filter, got the *unfiltered* page back as `success: true`, and acted on it; a non-admin sending `force: true` was told the flag was applied. So `filter_input` raises `McpAuthorization::UnknownInputKeysError` and the materialized tool returns it as an in-band tool error (`isError: true`). The text is written for a model that will read it as a tool result:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
list_applicants: Unknown parameters: data, limit. Nothing was executed. Accepted parameters: funnel_id, page, per_page, query, stage_id. If your task depends on a rejected parameter (for example filtering by it), do not re-run this call without it: the result would be unfiltered. Do not substitute another parameter to approximate it. Use a tool that supports it, or tell the user it is not possible.
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Both lists come from the caller's own schema, so the message reveals nothing `tools/list` did not already show that caller. The handler never runs, so a gated field is still never applied for a caller who lacks it — the boundary holds, it just stops being silent. A facade call is checked twice: a key beside `tool_name` and `arguments` is rejected by the facade, which tells the caller to re-send it inside `arguments` (a flattened parameter is supported, just misplaced), and a key inside `arguments` by the target tool, which tells it not to re-run without the parameter. Name a client's envelope key in `config.ignored_input_keys` to have it dropped instead, on both surfaces. Keys nested inside a declared object param are still projected silently. With `strict_schema` on, the compiled root carries `additionalProperties: false`, so an MCP SDK that validates arguments (`validate_tool_call_arguments`, on by default in the `mcp` gem) rejects the call with its own generic message before this one is produced; turn that validation off on the server if the model must read the guidance. Set `config.reject_unknown_input_keys = false` to restore the pre-0.9 drop-everything behavior. A host that overrides `materialize_for` or calls `filter_input` directly must rescue `UnknownInputKeysError` itself; unrescued it surfaces as a JSON-RPC internal error carrying the same message. The non-materialized `Tool.call` lets it propagate.
|
|
26
37
|
- **Projects the handler's return value** onto the user's compiled output schema. A variant hidden by `@requires` has its shape unavailable, so if the handler erroneously emits that variant's extra fields, they are stripped before serialization. A handler bug or refactor accident cannot leak admin-only fields to a non-admin.
|
|
27
38
|
|
|
28
39
|
This means handler authors don't have to remember to re-check `can?` at every branch -- the schema *is* the boundary. `can?` inside `#call` is still useful for logic that changes behavior (not just field visibility), but it is no longer load-bearing for security.
|
|
@@ -66,9 +77,24 @@ end
|
|
|
66
77
|
| `mount_path` | `"/mcp"` | URL prefix for MCP endpoints |
|
|
67
78
|
| `default_domain` | `"default"` | Domain when no `:domain` segment in path |
|
|
68
79
|
| `tool_paths` | `["app/mcp"]` | Directories where tool classes live (relative to Rails.root) |
|
|
80
|
+
| `tool_producers` | `[]` | Callables that register tool classes built at runtime — see [Generated tools](#generated-tools) |
|
|
69
81
|
| `shared_type_paths` | `["sig/shared"]` | Directories where shared `.rbs` type files live |
|
|
70
82
|
| `context_builder` | *required* | `(request) -> context` |
|
|
71
83
|
| `cli_context_builder` | `nil` | `(domain:, role:) -> context` for rake tasks |
|
|
84
|
+
| `strict_schema` | `false` | Emit stricter compiled schemas |
|
|
85
|
+
| `reject_unknown_input_keys` | `true` | Reject a `tools/call` carrying a top-level param outside the caller's compiled input schema (undeclared, or gated behind a predicate the caller fails) instead of silently dropping it — see [Enforcement, not just shaping](#enforcement-not-just-shaping) |
|
|
86
|
+
| `ignored_input_keys` | `[]` | Envelope keys a client attaches to every call, dropped rather than rejected — beside `tool_name`/`arguments` on a facade, and at the top level on any tool |
|
|
87
|
+
| `tools_list_cache` | `nil` | `:memory`, `:redis`, or any object responding to `get`/`set` — see [Caching `tools/list`](#caching-toolslist) |
|
|
88
|
+
| `tools_list_cache_ttl` | `3600` | Per-entry TTL in seconds |
|
|
89
|
+
| `tools_list_cache_redis` | `nil` | Explicit Redis client for the `:redis` store |
|
|
90
|
+
| `tools_list_cache_redis_url` | `nil` | Explicit Redis URL; falls back to `ENV["REDIS_URL"]`, then `Redis.new` |
|
|
91
|
+
|
|
92
|
+
Two configuration **methods** (not attributes) turn a domain into grouped facades — see [Tool grouping](#tool-grouping-facades):
|
|
93
|
+
|
|
94
|
+
| Method | Purpose |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `facet_domain :domain, group_by: :category, ...` | Present this domain as one facade tool per category. Optional `schema_strategy:`, `uncategorized:`, `facade_suffix:`. |
|
|
97
|
+
| `categories { summary :key, "text" }` | Declare one summary line per group, used as each facade description's lead. |
|
|
72
98
|
|
|
73
99
|
## The contract
|
|
74
100
|
|
|
@@ -293,6 +319,7 @@ class MyTool < McpAuthorization::Tool
|
|
|
293
319
|
gate :feature, :order_tracking # any predicate: hidden unless server_context.feature?(:order_tracking)
|
|
294
320
|
gate :tier, :enterprise # multiple gates AND together
|
|
295
321
|
tags "recruiting", "operations" # which domains this tool appears in
|
|
322
|
+
category :orders # group this tool belongs to in a faceted domain
|
|
296
323
|
read_only! # MCP annotation hints
|
|
297
324
|
dynamic_contract MyService # handler class
|
|
298
325
|
end
|
|
@@ -304,6 +331,7 @@ end
|
|
|
304
331
|
| `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
332
|
| `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
333
|
| `tags "domain1", ...` | Domain(s) this tool appears under. Defaults to `["default"]`. |
|
|
334
|
+
| `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
335
|
| `dynamic_contract HandlerClass` | Handler providing description, schemas, and execution |
|
|
308
336
|
| `read_only!` | Annotation: tool only reads data |
|
|
309
337
|
| `not_destructive!` | Annotation: tool does not destroy data |
|
|
@@ -316,6 +344,47 @@ end
|
|
|
316
344
|
|
|
317
345
|
Tools self-register when loaded. Put them anywhere under `tool_paths` (default: `app/mcp/`).
|
|
318
346
|
|
|
347
|
+
### Generated tools
|
|
348
|
+
|
|
349
|
+
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.
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
McpAuthorization.configure do |config|
|
|
353
|
+
config.tool_producers << -> { MyApp::GeneratedTools.register_all! }
|
|
354
|
+
end
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
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:
|
|
358
|
+
|
|
359
|
+
- **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.
|
|
360
|
+
- **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.
|
|
361
|
+
|
|
362
|
+
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.
|
|
363
|
+
|
|
364
|
+
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.
|
|
365
|
+
|
|
366
|
+
### Introspecting a tool class
|
|
367
|
+
|
|
368
|
+
Every declaration is readable back off the class, which is what the registry, the facade builder, and the cache digest use:
|
|
369
|
+
|
|
370
|
+
| Reader | Returns |
|
|
371
|
+
|---|---|
|
|
372
|
+
| `_permission` | The symbol passed to `authorization` |
|
|
373
|
+
| `_gates` | `[{ name:, value: }, ...]` for every declared gate |
|
|
374
|
+
| `_tags` | Declared domains |
|
|
375
|
+
| `_category` | Declared category symbol, or `nil` |
|
|
376
|
+
| `_category_summary` | Summary passed to `category(summary:)`, or `nil` |
|
|
377
|
+
| `_contract_handler` | The handler class |
|
|
378
|
+
|
|
379
|
+
`ToolRegistry` is the entry point for turning those declarations into MCP tools:
|
|
380
|
+
|
|
381
|
+
| Registry method | Purpose |
|
|
382
|
+
|---|---|
|
|
383
|
+
| `tool_classes_for(domain:, server_context:)` | Every permitted tool in a domain (the `tools/list` path) |
|
|
384
|
+
| `tool_class_for(domain:, name:, server_context:)` | One named tool, or `nil` (the `tools/call` path) |
|
|
385
|
+
| `facades_for(domain:, server_context:)` | Facades for a faceted domain |
|
|
386
|
+
| `facade_for(domain:, name:, server_context:)` | One facade by name |
|
|
387
|
+
|
|
319
388
|
## Contract validation
|
|
320
389
|
|
|
321
390
|
If a handler is missing required methods or schema definitions, the gem raises an `ArgumentError` on first request with a full diagnostic:
|
|
@@ -384,6 +453,19 @@ McpAuthorization.configure do |config|
|
|
|
384
453
|
end
|
|
385
454
|
```
|
|
386
455
|
|
|
456
|
+
Grouping is opt-in and per-domain: a domain with no `facet_domain` behaves
|
|
457
|
+
exactly as before, and `category` is inert there. `facet_domain` takes:
|
|
458
|
+
|
|
459
|
+
| Option | Default | Description |
|
|
460
|
+
|---|---|---|
|
|
461
|
+
| `group_by:` | *required* | Grouping key. Only `:category` today; anything else raises `ArgumentError`. |
|
|
462
|
+
| `schema_strategy:` | `:vendor_extension` | Where per-tool argument schemas go — `:vendor_extension` or `:lazy` (below). |
|
|
463
|
+
| `uncategorized:` | `:fallback` | `:fallback` collects tools with no `category` into an `uncategorized` group; `:error` raises instead. |
|
|
464
|
+
| `facade_suffix:` | `"tools"` | Token appended to a category to form the facade name (`orders_tools`). |
|
|
465
|
+
|
|
466
|
+
For a group that holds a single tool, `category :orders, summary: "..."` saves a
|
|
467
|
+
trip to the central registry. When both are declared, `config.categories` wins.
|
|
468
|
+
|
|
387
469
|
`tools/list` for the domain then returns one facade per group the caller has
|
|
388
470
|
at least one permitted tool in (`orders_tools`, `billing_tools`), each
|
|
389
471
|
describing its tools with RBAC-filtered one-liners. Calling a facade names the
|
|
@@ -394,10 +476,40 @@ inner tool and its arguments:
|
|
|
394
476
|
"arguments": { "tool_name": "update_order", "arguments": { "id": "o_1" } } }
|
|
395
477
|
```
|
|
396
478
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
479
|
+
### Facade shape
|
|
480
|
+
|
|
481
|
+
| Part | Value |
|
|
482
|
+
|---|---|
|
|
483
|
+
| Name | `"#{category}_#{facade_suffix}"` — default suffix `tools`, so `orders_tools` |
|
|
484
|
+
| Description | Group summary, then one line per tool the caller may invoke: `- tool_name — first line of that tool's description for this caller` |
|
|
485
|
+
| `inputSchema` | Flat object: `tool_name` (string enum of permitted tool names) + `arguments` (object). Both required. |
|
|
486
|
+
| `_meta` | Under `:vendor_extension`, key `"tool-input-schemas"` — a map of tool name to that caller's compiled input schema |
|
|
487
|
+
|
|
488
|
+
### Facade dispatch
|
|
489
|
+
|
|
490
|
+
Dispatch resolves the real tool through its normal call path — there is no
|
|
491
|
+
second code path, and therefore no second place for authorization to be wrong:
|
|
492
|
+
|
|
493
|
+
1. **Advertised-set check.** `tool_name` must be in the set advertised to *this*
|
|
494
|
+
caller, else `ArgumentError` listing the valid names.
|
|
495
|
+
2. **Re-resolution.** The tool is re-fetched via `ToolRegistry.tool_class_for`,
|
|
496
|
+
which re-runs `permitted?` — so a stale advertised set cached by a client
|
|
497
|
+
cannot get a caller into a tool they've lost access to. Failure raises
|
|
498
|
+
`NotAuthorizedError`.
|
|
499
|
+
3. **Argument coercion.** MCP clients frequently serialize nested objects as JSON
|
|
500
|
+
strings, and the facade's generic `arguments: object` contract cannot know
|
|
501
|
+
which fields are structured. The `arguments` blob itself, and any top-level
|
|
502
|
+
value whose type in the *target's* compiled schema is `object` or `array`, are
|
|
503
|
+
JSON-parsed. Invalid JSON raises `ArgumentError` naming the field.
|
|
504
|
+
4. **Delegation.** The target's materialized `call` runs, applying `filter_input`
|
|
505
|
+
and `filter_output` exactly as in a direct call.
|
|
506
|
+
|
|
507
|
+
Direct tool names still resolve on `tools/call`, so a client that learned a
|
|
508
|
+
real tool name before the domain was faceted keeps working.
|
|
509
|
+
|
|
510
|
+
A `tools/call` build skips the `_meta` per-tool schema map (`for_dispatch: true`)
|
|
511
|
+
— nothing on the call path reads it, and building it would recompile every tool
|
|
512
|
+
in the group on every call.
|
|
401
513
|
|
|
402
514
|
The facade `inputSchema` is always a flat object (a `tool_name` enum plus a
|
|
403
515
|
permissive `arguments` object). It has to be: an LLM tool `input_schema` must
|
|
@@ -417,13 +529,31 @@ therefore only chooses where the per-tool schemas go:
|
|
|
417
529
|
|
|
418
530
|
Uncategorized tools land in an `uncategorized` facade by default; pass
|
|
419
531
|
`uncategorized: :error` to fail fast instead. Groups with zero permitted tools
|
|
420
|
-
are hidden entirely, so a facade never advertises an empty `enum`.
|
|
532
|
+
are hidden entirely, so a facade never advertises an empty `enum`. A facade
|
|
533
|
+
name that collides with a real registered tool in the domain raises
|
|
534
|
+
`FacadeBuilder::FacadeNameCollisionError` rather than shadowing that tool.
|
|
421
535
|
|
|
422
536
|
Facade names are `#{category}_tools` by default. Override the suffix per domain
|
|
423
537
|
with `facade_suffix:` — e.g. `config.facet_domain :admin, group_by: :category,
|
|
424
538
|
facade_suffix: "hire"` exposes `orders_hire`, `billing_hire`. The suffix must be
|
|
425
539
|
a lowercase identifier fragment (`[a-z0-9_]`) so the derived name stays a valid
|
|
426
|
-
MCP tool name. See
|
|
540
|
+
MCP tool name. See [the design doc](docs/designs/tool-grouping-facades.md) for
|
|
541
|
+
the full rationale.
|
|
542
|
+
|
|
543
|
+
Facet configuration participates in the [`tools/list` cache](#caching-toolslist)
|
|
544
|
+
digest — each tool's `category`, every `facet_domain` setting, and every group
|
|
545
|
+
summary — so toggling grouping or rewording a summary invalidates cached
|
|
546
|
+
listings the same way a gate change does.
|
|
547
|
+
|
|
548
|
+
### Facade errors
|
|
549
|
+
|
|
550
|
+
| Error | Raised when |
|
|
551
|
+
|---|---|
|
|
552
|
+
| `FacadeBuilder::UncategorizedToolError` | A tool has no `category` in a domain faceted with `uncategorized: :error` |
|
|
553
|
+
| `FacadeBuilder::FacadeNameCollisionError` | A derived facade name collides with a registered tool in the domain |
|
|
554
|
+
| `ArgumentError` (config) | Invalid `group_by:`, `schema_strategy:`, `uncategorized:`, or `facade_suffix:` |
|
|
555
|
+
| `ArgumentError` (dispatch) | `tool_name` not advertised, or a string argument that isn't valid JSON |
|
|
556
|
+
| `NotAuthorizedError` | Re-resolution found the caller isn't permitted after all |
|
|
427
557
|
|
|
428
558
|
## RBS type syntax
|
|
429
559
|
|
|
@@ -458,6 +588,16 @@ The `@rbs type` comments compile to JSON Schema:
|
|
|
458
588
|
# @rbs type input = { id: String, status: status }
|
|
459
589
|
```
|
|
460
590
|
|
|
591
|
+
Every handler must declare `@rbs type output`. It may be a single record, a reference, or a union:
|
|
592
|
+
|
|
593
|
+
```ruby
|
|
594
|
+
# @rbs type output = { id: String } # inline record
|
|
595
|
+
# @rbs type output = applicant # reference
|
|
596
|
+
# @rbs type output = success | error # union
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
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.
|
|
600
|
+
|
|
461
601
|
### Constraint and annotation tags
|
|
462
602
|
|
|
463
603
|
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 +697,17 @@ end
|
|
|
557
697
|
|
|
558
698
|
The `@min` / `@max` tags are type-aware: on strings they emit `minLength`/`maxLength`, on numbers `minimum`/`maximum`, and on arrays `minItems`/`maxItems`.
|
|
559
699
|
|
|
700
|
+
### Where tags may appear
|
|
701
|
+
|
|
702
|
+
| Position | Effect |
|
|
703
|
+
|---|---|
|
|
704
|
+
| On a param in `#:` | Constrains or gates that input field |
|
|
705
|
+
| On a field in an `@rbs type` record | Constrains or gates that output field |
|
|
706
|
+
| On a member of an `@rbs type` union | Gates that whole output variant |
|
|
707
|
+
| On an inline literal union member | Gates that individual member |
|
|
708
|
+
|
|
709
|
+
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.
|
|
710
|
+
|
|
560
711
|
### Multiline `#:` annotations
|
|
561
712
|
|
|
562
713
|
The `#:` annotation above `def call` supports multiple lines. Each line starts with `#:`:
|
|
@@ -611,6 +762,70 @@ MCP clients can narrow on `success: const true` vs `success: const false` -- the
|
|
|
611
762
|
|
|
612
763
|
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
764
|
|
|
765
|
+
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:
|
|
766
|
+
|
|
767
|
+
| Method | Tools materialized |
|
|
768
|
+
|---|---|
|
|
769
|
+
| `tools/list` | Every permitted tool in the domain |
|
|
770
|
+
| `tools/call` | Only the invoked tool |
|
|
771
|
+
| `initialize`, `notifications/initialized`, `ping`, GET stream probe | None |
|
|
772
|
+
| Unrecognized shape (e.g. a batch with no top-level `method`) | Full domain, so routing stays correct |
|
|
773
|
+
|
|
774
|
+
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).
|
|
775
|
+
|
|
776
|
+
## Caching `tools/list`
|
|
777
|
+
|
|
778
|
+
`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:
|
|
779
|
+
|
|
780
|
+
```ruby
|
|
781
|
+
McpAuthorization.configure do |c|
|
|
782
|
+
c.tools_list_cache = :redis # or :memory, or any object responding to get/set
|
|
783
|
+
c.tools_list_cache_ttl = 3600 # seconds (default)
|
|
784
|
+
end
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
| Store | Behavior |
|
|
788
|
+
|---|---|
|
|
789
|
+
| `:memory` | Process-local, bounded LRU with per-entry TTL |
|
|
790
|
+
| `:redis` | Shared across processes, JSON values, per-entry TTL |
|
|
791
|
+
| custom object | Anything responding to `get`/`set` |
|
|
792
|
+
| *(unset)* | `NullStore` — no caching |
|
|
793
|
+
|
|
794
|
+
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.
|
|
795
|
+
|
|
796
|
+
### The key is a decision vector, not an identity
|
|
797
|
+
|
|
798
|
+
```
|
|
799
|
+
H(domain + tool_defs_digest + vocab_fingerprint + decision_vector)
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
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.
|
|
803
|
+
|
|
804
|
+
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.
|
|
805
|
+
|
|
806
|
+
### Two ways to supply the vector
|
|
807
|
+
|
|
808
|
+
**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.
|
|
809
|
+
|
|
810
|
+
**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:
|
|
811
|
+
|
|
812
|
+
```ruby
|
|
813
|
+
class ServerContext
|
|
814
|
+
def mcp_cache_fingerprint
|
|
815
|
+
[current_user.role, account.enabled_features.sort, account.plan_tier]
|
|
816
|
+
end
|
|
817
|
+
end
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
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.
|
|
821
|
+
|
|
822
|
+
### Operational notes
|
|
823
|
+
|
|
824
|
+
- **Cache outages fail open** — a `get`/`set` error is logged and behaves as a miss, never breaking `tools/list`.
|
|
825
|
+
- **Only successful listings are cached.** Error and unexpected responses render but are not stored.
|
|
826
|
+
- **A hit still rebuilds the envelope** — the cached `result` is re-wrapped with the live JSON-RPC id.
|
|
827
|
+
- **Development reloads clear it** (the reloader calls `Cache.reset!`), so an edited annotation shows up immediately.
|
|
828
|
+
|
|
614
829
|
## Development
|
|
615
830
|
|
|
616
831
|
### 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]
|
|
@@ -79,6 +100,25 @@ module McpAuthorization
|
|
|
79
100
|
#: bool
|
|
80
101
|
attr_accessor :strict_schema
|
|
81
102
|
|
|
103
|
+
# When true (the default), a +tools/call+ whose params carry a top-level
|
|
104
|
+
# key outside the caller's compiled input schema is rejected with
|
|
105
|
+
# +McpAuthorization::UnknownInputKeysError+ instead of having the key
|
|
106
|
+
# silently dropped. A field gated by a predicate this caller fails is
|
|
107
|
+
# outside that schema and is rejected the same way — the caller's
|
|
108
|
+
# schema is the contract. Set to false to restore the pre-0.9
|
|
109
|
+
# drop-everything behavior.
|
|
110
|
+
#: bool
|
|
111
|
+
attr_accessor :reject_unknown_input_keys
|
|
112
|
+
|
|
113
|
+
# Keys a host's client attaches to every call as an envelope — a UI
|
|
114
|
+
# annotation, not a tool parameter — which are dropped rather than
|
|
115
|
+
# rejected. Empty by default. Applied wherever a key is checked against a
|
|
116
|
+
# schema: beside +tool_name+ and +arguments+ on a facade call, and at the
|
|
117
|
+
# top level of any tool's params, so the same envelope is exempt on a
|
|
118
|
+
# facade and on a flat domain alike.
|
|
119
|
+
#: Array[String]
|
|
120
|
+
attr_accessor :ignored_input_keys
|
|
121
|
+
|
|
82
122
|
# Cache for the +tools/list+ response. Opt-in; defaults to no caching.
|
|
83
123
|
# Accepts:
|
|
84
124
|
# nil / false — no caching (default)
|
|
@@ -151,12 +191,15 @@ module McpAuthorization
|
|
|
151
191
|
@server_name = "mcp-authorization"
|
|
152
192
|
@server_version = "1.0.0"
|
|
153
193
|
@tool_paths = %w[app/mcp]
|
|
194
|
+
@tool_producers = []
|
|
154
195
|
@shared_type_paths = %w[sig/shared]
|
|
155
196
|
@default_domain = "default"
|
|
156
197
|
@mount_path = "/mcp"
|
|
157
198
|
@context_builder = nil
|
|
158
199
|
@cli_context_builder = nil
|
|
159
200
|
@strict_schema = false
|
|
201
|
+
@reject_unknown_input_keys = true
|
|
202
|
+
@ignored_input_keys = []
|
|
160
203
|
@tools_list_cache = nil
|
|
161
204
|
@tools_list_cache_ttl = 3600
|
|
162
205
|
@tools_list_cache_redis = nil
|
|
@@ -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.
|
|
@@ -25,9 +25,19 @@ module McpAuthorization
|
|
|
25
25
|
# registered tool name. Renaming the category is the fix.
|
|
26
26
|
class FacadeNameCollisionError < StandardError; end
|
|
27
27
|
|
|
28
|
+
# A facade call the caller can fix from the error alone: an unroutable
|
|
29
|
+
# +tool_name+, a malformed +arguments+, or a key beside the two the
|
|
30
|
+
# facade declares. Returned in-band so the message reaches the model.
|
|
31
|
+
# Raised only before dispatch reaches the target, so it never relabels a
|
|
32
|
+
# target's own error with the facade's name.
|
|
33
|
+
class FacadeCallError < ArgumentError; end
|
|
34
|
+
|
|
28
35
|
# Fallback group for uncategorized tools (default mode).
|
|
29
36
|
FALLBACK_CATEGORY = :uncategorized #: Symbol
|
|
30
37
|
|
|
38
|
+
# The only two keys a facade's inputSchema declares.
|
|
39
|
+
FACADE_KEYS = %w[arguments tool_name].freeze #: Array[String]
|
|
40
|
+
|
|
31
41
|
class << self
|
|
32
42
|
# All facades for a domain, one per non-empty group the caller has at
|
|
33
43
|
# least one permitted tool in. Empty groups produce no facade — which
|
|
@@ -153,6 +163,9 @@ module McpAuthorization
|
|
|
153
163
|
|
|
154
164
|
define_singleton_method(:call) do |server_context: nil, **params|
|
|
155
165
|
builder.send(:dispatch, domain, advertised, params, server_context || ctx)
|
|
166
|
+
rescue McpAuthorization::FacadeBuilder::FacadeCallError => e
|
|
167
|
+
# Raised before the target runs, so the facade's name is the right label.
|
|
168
|
+
MCP::Tool::Response.new([{ type: "text", text: "#{name}: #{e.message}" }], error: true)
|
|
156
169
|
end
|
|
157
170
|
end
|
|
158
171
|
end
|
|
@@ -235,10 +248,12 @@ module McpAuthorization
|
|
|
235
248
|
# the same code path as a direct call.
|
|
236
249
|
#: (String, Set[String], Hash[Symbol, untyped], untyped) -> untyped
|
|
237
250
|
def dispatch(domain, advertised, params, server_context)
|
|
251
|
+
reject_unknown_facade_keys!(params)
|
|
238
252
|
tool_name = (params[:tool_name] || params["tool_name"]).to_s
|
|
239
253
|
unless advertised.include?(tool_name)
|
|
240
|
-
raise
|
|
241
|
-
"unknown tool_name #{tool_name.inspect}
|
|
254
|
+
raise FacadeCallError,
|
|
255
|
+
"unknown tool_name #{tool_name.inspect}. Nothing was executed. " \
|
|
256
|
+
"Available tools: #{advertised.to_a.sort.join(", ")}."
|
|
242
257
|
end
|
|
243
258
|
|
|
244
259
|
candidates = McpAuthorization::ToolRegistry.tools_by_domain[domain] || []
|
|
@@ -249,7 +264,46 @@ module McpAuthorization
|
|
|
249
264
|
raise McpAuthorization::Tool::NotAuthorizedError unless original && target
|
|
250
265
|
|
|
251
266
|
arguments = coerce_arguments(original, params[:arguments] || params["arguments"], server_context)
|
|
252
|
-
|
|
267
|
+
reject_reserved_argument_keys!(arguments)
|
|
268
|
+
# server_context last: a later duplicate keyword wins in Ruby, so an
|
|
269
|
+
# `arguments.server_context` cannot replace the request's own context.
|
|
270
|
+
target.call(**arguments, server_context: server_context)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# The facade schema declares only tool_name and arguments; a sibling key
|
|
274
|
+
# is outside every caller's contract and was silently ignored, the same
|
|
275
|
+
# gap filter_input closes for the target tool. The guidance differs from
|
|
276
|
+
# the target's: a flattened parameter is supported, it is just in the
|
|
277
|
+
# wrong place, so the caller is told to nest it rather than to give up.
|
|
278
|
+
# +config.ignored_input_keys+ names host envelope keys to drop instead.
|
|
279
|
+
#: (Hash[Symbol, untyped]) -> void
|
|
280
|
+
def reject_unknown_facade_keys!(params)
|
|
281
|
+
return unless McpAuthorization.config.reject_unknown_input_keys
|
|
282
|
+
|
|
283
|
+
ignored = McpAuthorization.config.ignored_input_keys.map(&:to_s)
|
|
284
|
+
unknown = params.keys.map(&:to_s) - FACADE_KEYS - ignored
|
|
285
|
+
return if unknown.empty?
|
|
286
|
+
|
|
287
|
+
list = unknown.join(", ")
|
|
288
|
+
raise FacadeCallError,
|
|
289
|
+
"Unknown parameter#{"s" if unknown.size > 1}: #{list}. Nothing was executed. " \
|
|
290
|
+
"This tool accepts only tool_name and arguments; a tool's own parameters go " \
|
|
291
|
+
"inside arguments. Re-send with #{list} inside arguments if the tool declares " \
|
|
292
|
+
"#{unknown.size > 1 ? "them" : "it"}, and omit #{unknown.size > 1 ? "them" : "it"} otherwise."
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# +server_context+ binds the target's own keyword, so it can never be a
|
|
296
|
+
# tool parameter and +filter_input+ never sees it. Rejecting it keeps the
|
|
297
|
+
# release's guarantee — no key outside the schema is quietly ignored —
|
|
298
|
+
# and makes the caller's intent visible rather than silently discarded.
|
|
299
|
+
#: (Hash[Symbol, untyped]) -> void
|
|
300
|
+
def reject_reserved_argument_keys!(arguments)
|
|
301
|
+
return unless McpAuthorization.config.reject_unknown_input_keys
|
|
302
|
+
return unless arguments.key?(:server_context)
|
|
303
|
+
|
|
304
|
+
raise FacadeCallError,
|
|
305
|
+
"server_context is not a tool parameter and cannot be supplied by a caller. " \
|
|
306
|
+
"Nothing was executed. Remove it from arguments and re-send."
|
|
253
307
|
end
|
|
254
308
|
|
|
255
309
|
# Coerce a facade's +arguments+ blob against the target tool's
|
|
@@ -257,12 +311,19 @@ module McpAuthorization
|
|
|
257
311
|
# objects as JSON strings; the facade's own contract only knows
|
|
258
312
|
# `arguments: object`, so string blobs are parsed here — both the
|
|
259
313
|
# blob itself and any top-level value whose target type is an object
|
|
260
|
-
# or array.
|
|
261
|
-
#
|
|
314
|
+
# or array. Keys outside the target's schema are then rejected by the
|
|
315
|
+
# target's filter_input as in a direct call.
|
|
262
316
|
#: (singleton(McpAuthorization::Tool), untyped, untyped) -> Hash[Symbol, untyped]
|
|
263
317
|
def coerce_arguments(tool_class, raw, server_context)
|
|
264
318
|
parsed = raw.is_a?(String) ? parse_json_blob(raw, "arguments") : raw
|
|
265
|
-
|
|
319
|
+
# An empty hash here used to mean "call the target with no arguments",
|
|
320
|
+
# so a caller that sent an array, a scalar or nil got the unfiltered
|
|
321
|
+
# default result reported as a success.
|
|
322
|
+
unless parsed.is_a?(Hash)
|
|
323
|
+
raise FacadeCallError,
|
|
324
|
+
"arguments must be an object mapping parameter names to values; got " \
|
|
325
|
+
"#{parsed.nil? ? "null" : parsed.class.name.downcase}. Nothing was executed."
|
|
326
|
+
end
|
|
266
327
|
|
|
267
328
|
schema = tool_class.dynamic_input_schema(server_context: server_context)
|
|
268
329
|
properties = schema.is_a?(Hash) ? (schema[:properties] || schema["properties"] || {}) : {}
|
|
@@ -282,7 +343,8 @@ module McpAuthorization
|
|
|
282
343
|
def parse_json_blob(string, field)
|
|
283
344
|
JSON.parse(string)
|
|
284
345
|
rescue JSON::ParserError => e
|
|
285
|
-
raise
|
|
346
|
+
raise FacadeCallError,
|
|
347
|
+
"#{field} was sent as a string but is not valid JSON: #{e.message}. Nothing was executed."
|
|
286
348
|
end
|
|
287
349
|
end
|
|
288
350
|
end
|
|
@@ -42,6 +42,31 @@ require "rbs/parser_aux"
|
|
|
42
42
|
require_relative "diagnostics"
|
|
43
43
|
|
|
44
44
|
module McpAuthorization
|
|
45
|
+
# Raised by +RbsSchemaCompiler.filter_input+ when the caller sent a
|
|
46
|
+
# top-level param outside its compiled input schema. +unknown_keys+ are
|
|
47
|
+
# the offending names; +accepted_keys+ are the names in the caller's
|
|
48
|
+
# schema. The message is written for an LLM that will read it as a tool
|
|
49
|
+
# result: it must not suggest re-running without the parameter, because
|
|
50
|
+
# for a filter that means acting on an unfiltered result.
|
|
51
|
+
class UnknownInputKeysError < ArgumentError
|
|
52
|
+
attr_reader :unknown_keys, :accepted_keys
|
|
53
|
+
|
|
54
|
+
#: (Array[String], Array[String]) -> void
|
|
55
|
+
def initialize(unknown_keys, accepted_keys)
|
|
56
|
+
@unknown_keys = unknown_keys
|
|
57
|
+
@accepted_keys = accepted_keys
|
|
58
|
+
accepted = accepted_keys.empty? ? "none" : accepted_keys.join(", ")
|
|
59
|
+
super(
|
|
60
|
+
"Unknown parameter#{"s" if unknown_keys.size > 1}: #{unknown_keys.join(", ")}. " \
|
|
61
|
+
"Nothing was executed. Accepted parameters: #{accepted}. " \
|
|
62
|
+
"If your task depends on a rejected parameter (for example filtering by it), " \
|
|
63
|
+
"do not re-run this call without it: the result would be unfiltered. " \
|
|
64
|
+
"Do not substitute another parameter to approximate it. " \
|
|
65
|
+
"Use a tool that supports it, or tell the user it is not possible."
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
45
70
|
# Compiles RBS-style type annotations in Ruby source files into JSON Schema,
|
|
46
71
|
# with per-request filtering based on +@requires+ permission tags.
|
|
47
72
|
#
|
|
@@ -127,10 +152,16 @@ module McpAuthorization
|
|
|
127
152
|
|
|
128
153
|
# Filter incoming params against the user's compiled input schema.
|
|
129
154
|
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
155
|
+
# This is the runtime enforcement counterpart to the input-shaping
|
|
156
|
+
# that +compile_input+ did. The caller's schema is the contract: a
|
|
157
|
+
# top-level key outside it — whether the tool never declared it or
|
|
158
|
+
# declared it behind a predicate this caller fails — does not exist
|
|
159
|
+
# from the caller's point of view. Such a key used to be dropped
|
|
160
|
+
# silently, which returned a successful-looking result that ignored
|
|
161
|
+
# part of the request (an invented filter, a gated flag). By default
|
|
162
|
+
# it now raises +UnknownInputKeysError+; see
|
|
163
|
+
# +config.reject_unknown_input_keys+. Keys nested inside a declared
|
|
164
|
+
# object param are still projected silently.
|
|
134
165
|
#
|
|
135
166
|
# @param handler_class [Class]
|
|
136
167
|
# @param params [Hash] Params as received from the MCP client.
|
|
@@ -141,9 +172,27 @@ module McpAuthorization
|
|
|
141
172
|
return params unless params.is_a?(Hash)
|
|
142
173
|
schema = compile_input_for_filter(handler_class, server_context: server_context)
|
|
143
174
|
return params unless schema
|
|
175
|
+
reject_unknown_input_keys!(params, schema) if McpAuthorization.config.reject_unknown_input_keys
|
|
144
176
|
project_against_schema(params, schema, defs_from(schema))
|
|
145
177
|
end
|
|
146
178
|
|
|
179
|
+
# Raise when +params+ carries a top-level key outside +schema+'s
|
|
180
|
+
# properties. Both the unknown and the accepted names come from the
|
|
181
|
+
# caller's own schema, so the message reveals nothing +tools/list+ did
|
|
182
|
+
# not already show this caller. A host's envelope key named in
|
|
183
|
+
# +config.ignored_input_keys+ is dropped here as it is on a facade: a
|
|
184
|
+
# client that annotates every call reaches a flat domain directly, where
|
|
185
|
+
# there is no facade to exempt it.
|
|
186
|
+
#: (Hash[untyped, untyped], Hash[Symbol, untyped]) -> void
|
|
187
|
+
def reject_unknown_input_keys!(params, schema)
|
|
188
|
+
accepted = (schema[:properties] || {}).keys.map(&:to_s)
|
|
189
|
+
ignored = McpAuthorization.config.ignored_input_keys.map(&:to_s)
|
|
190
|
+
unknown = params.keys.map(&:to_s) - accepted - ignored
|
|
191
|
+
return if unknown.empty?
|
|
192
|
+
|
|
193
|
+
raise UnknownInputKeysError.new(unknown, accepted.sort)
|
|
194
|
+
end
|
|
195
|
+
|
|
147
196
|
# Filter the handler's return value against the user's compiled output
|
|
148
197
|
# schema. Any field/variant not visible to this user is stripped from
|
|
149
198
|
# the result.
|
|
@@ -215,9 +215,10 @@ module McpAuthorization
|
|
|
215
215
|
# Create an anonymous MCP::Tool subclass with this user's schemas baked in.
|
|
216
216
|
#
|
|
217
217
|
# The materialized +call+ enforces the compiled schema at runtime:
|
|
218
|
-
#
|
|
219
|
-
#
|
|
220
|
-
#
|
|
218
|
+
# a top-level param outside the caller's input schema is rejected
|
|
219
|
+
# in-band (+UnknownInputKeysError+ → +isError: true+) before the
|
|
220
|
+
# handler runs, and the handler's return value is projected onto the
|
|
221
|
+
# user's output schema before being serialized.
|
|
221
222
|
#: (untyped) -> Class?
|
|
222
223
|
def materialize_for(server_context)
|
|
223
224
|
defn = to_mcp_definition(server_context: server_context)
|
|
@@ -236,9 +237,16 @@ module McpAuthorization
|
|
|
236
237
|
|
|
237
238
|
define_singleton_method(:call) do |server_context: nil, **params|
|
|
238
239
|
effective_ctx = server_context || ctx
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
begin
|
|
241
|
+
filtered_params = McpAuthorization::RbsSchemaCompiler.filter_input(
|
|
242
|
+
handler, params, server_context: effective_ctx
|
|
243
|
+
)
|
|
244
|
+
rescue McpAuthorization::UnknownInputKeysError => e
|
|
245
|
+
# In-band tool error (isError: true), not a JSON-RPC failure, so the
|
|
246
|
+
# model reads the guidance. tool_name, not defn[:name]: a host may
|
|
247
|
+
# rename the materialized class, and the caller knows the new name.
|
|
248
|
+
next MCP::Tool::Response.new([{ type: "text", text: "#{tool_name}: #{e.message}" }], error: true)
|
|
249
|
+
end
|
|
242
250
|
raw = handler.new(server_context: effective_ctx).call(**symbolize.call(filtered_params))
|
|
243
251
|
result = McpAuthorization::RbsSchemaCompiler.filter_output(
|
|
244
252
|
handler, raw, server_context: effective_ctx
|
|
@@ -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!
|
|
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 @
|
|
38
|
-
return
|
|
76
|
+
return if @tools_loaded
|
|
77
|
+
return if @loading_tools
|
|
39
78
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
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.9.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-
|
|
11
|
+
date: 2026-09-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -128,7 +128,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
128
128
|
- !ruby/object:Gem::Version
|
|
129
129
|
version: '0'
|
|
130
130
|
requirements: []
|
|
131
|
-
rubygems_version: 3.
|
|
131
|
+
rubygems_version: 3.3.27
|
|
132
132
|
signing_key:
|
|
133
133
|
specification_version: 4
|
|
134
134
|
summary: Rails engine for MCP tools with per-request schema discrimination
|