reeve 0.3.0 → 0.4.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: 225a220bbfd6168a26fdf67a816c8a580ca57ba996f97c7d1c9317755252d497
4
- data.tar.gz: a5633fe99dedbc03796c82abf560eca68187fa783e5a9ceef1fb764b9a47d351
3
+ metadata.gz: 329fc869bdb15c7e60eb1923221aeccb1725821535c25186dafe77bda001cc44
4
+ data.tar.gz: 0f0781268bc7e90b1fad27a74bef611aa6aa65df3da37fb1351eb3f28524da15
5
5
  SHA512:
6
- metadata.gz: c9bccface22cb54d935720a927dd5aff7c61582403f6d0aeada860be4f178d110d4ac94801a84d6a15993c0abad8a6b14ee0c1058311809ddf5575ddb57d1741
7
- data.tar.gz: e43b1e0a1fb8900a45cb998997b45152ab726b21961c2b86ea5dea0cc0adb3e0f9e015cf7aed8cdcd6e39aa28d7e9c827ff85d11653b8aa5fab769844b1cdecb
6
+ metadata.gz: b5d6286339f5f034e76f74052e3d19f9560c55b45e9432e6583b50931ddf4425a1aa5bc5c0ea1200646a9085f972521a3d1e2c38d8cf654e8aa8004fa2be0e3f
7
+ data.tar.gz: 32c28f2c34294f7d2454b772a1fd7bd52af690c3bc6d46793e2ec23021c6c3bd5d0d4bc6ee5f04b990a36a5f8ada40e03677bd9efa5c2a099b08c6bcf4cac147
data/CHANGELOG.md CHANGED
@@ -4,6 +4,80 @@ All notable changes are recorded here. This project follows [Semantic
4
4
  Versioning](https://semver.org), with one rule specific to what it does — see
5
5
  [Versioning policy](#versioning-policy).
6
6
 
7
+ ## [0.4.0] - 2026-09-03
8
+
9
+ Everything here came out of a second application integrating the gem — one that had put a
10
+ handful of its MCP tools behind reeve and was about to guard its write tools next. Two
11
+ defects surfaced from reviewing that work, and both were failures of the same kind: the
12
+ gem reporting a guarantee it had not actually established.
13
+
14
+ **The audit-entry contract is unchanged at `2`, so no migration is required.**
15
+
16
+ Behaviour changes worth reading before upgrading:
17
+
18
+ - A denied invocation now rolls the tool's work back. A guarded tool that writes and is
19
+ then refused used to leave the write behind.
20
+ - A guarded tool that raises after writing now has that write rolled back too.
21
+ - The compliance suite now reports tools that never declared a guard, so a suite that
22
+ passed before may legitimately fail now. That is the fix, not a regression — set
23
+ `config.compliance_tools` to what you have certified so far.
24
+
25
+ ### Fixed
26
+
27
+ - **The compliance suite can see a tool that has no guard.** It walked the guard registry,
28
+ which holds one entry per `guard_with` — so every subject it checked had a guard by
29
+ construction, and `GuardDeclared`, the check whose whole job is naming tools that lack
30
+ one, could not fail. An application with three guarded tools and one that was forgotten
31
+ reported `4 checks, 4 passed`. The gem's own quickstart demonstrated this: it ships a
32
+ deliberately unguarded tool and then asserted that the compliance run passed.
33
+
34
+ Tools are now recorded when they include `Reeve::Guard`, declared or not — through
35
+ inheritance too, so an adapter that includes the DSL into a base class once (fast-mcp)
36
+ no longer hides every tool built on it. `Reeve.registry.unguarded_tool_classes` is the
37
+ worklist. A base class that carries the DSL for its subclasses declares itself with
38
+ `reeve_abstract!` rather than being guessed at: inferring "anything with a subclass is a
39
+ base" would silently drop a real tool from the run the moment someone subclassed it.
40
+
41
+ **Reeve can only see tools that reached it.** An MCP server dispatching tools reeve was
42
+ never told about is still invisible to it — pass `tools:` to certify the real inventory.
43
+
44
+ - **A denied invocation no longer leaves the host's data changed.** The envelope
45
+ authorizes before the tool runs, with the model class as the subject because no record
46
+ exists yet, and scopes the return value afterwards. A write tool lives between those two
47
+ points: it fetched a record, changed it, and only then was refused. The caller saw
48
+ `out_of_scope_record`, the ledger recorded a denial — and the row in the database had
49
+ already been rewritten by a principal who could not see it. A `deny` that follows a
50
+ committed write is not a partial guarantee, it is a false statement in the artifact the
51
+ ledger exists to be.
52
+
53
+ The tool body now runs in a transaction and a scope denial rolls it back. `requires_new`
54
+ makes it a savepoint, so a transaction the host opened around the invocation is left
55
+ alone — the rollback reaches the tool's work and stops there. Measured overhead is at or
56
+ below noise on a read-only call.
57
+
58
+ Two consequences worth knowing:
59
+
60
+ - A tool that **writes and then raises** now has that write rolled back too. It was
61
+ already recorded as `tool_error`; the data now matches what the ledger said.
62
+ - A host with no ActiveRecord, or with the library loaded but no connection, keeps the
63
+ previous behaviour — there is nothing to roll back, and neither should be made to fail.
64
+
65
+ ### Added
66
+
67
+ - **`config.compliance_tools`** — which tools the suite certifies, as an array or a
68
+ callable, defaulting to every tool reeve knows about. A retrofit is the normal state of
69
+ an application adopting this, and now that unguarded tools are reported, one that has
70
+ guarded three of thirty has a red build it cannot honestly turn green. A build expected
71
+ to be red is a build nobody reads. Narrowing the list states what has been certified —
72
+ a claim that can go green and stay green as the list grows.
73
+
74
+ - **`authorize!(record)`** inside a tool body — asks the declared policy about one record
75
+ and returns it, raising `DeniedError` if the policy says no. For the part of a tool a
76
+ rollback cannot reach: a sent email, a webhook, a written file. A denial raised this way
77
+ carries the policy's own rule into the ledger rather than being filed as `tool_error`,
78
+ and names no record, so a refusal and a record that does not exist still read the same
79
+ (FR-006).
80
+
7
81
  ## [0.3.0] - 2026-08-17
8
82
 
9
83
  Three of the limitations 0.1.0 shipped knowingly are now closed, and the audit-entry
data/README.md CHANGED
@@ -7,7 +7,7 @@ A Ruby gem that makes it safe for a Rails application to expose MCP tools to AI
7
7
  declarative per-record authorization, an append-only audit ledger, and a testing kit that
8
8
  proves both hold.
9
9
 
10
- > **Status: 0.3.0.** Read the known limitations in
10
+ > **Status: 0.4.0.** Read the known limitations in
11
11
  > [CHANGELOG.md](CHANGELOG.md) before adopting it — particularly the one about a
12
12
  > transaction wrapped around an invocation, if your application wraps requests in one.
13
13
 
@@ -119,6 +119,42 @@ end
119
119
  Reaching for `Invoice.sum(:cents)` there is denied with `unscoped_derived_result`. The
120
120
  guarantee is structural, not a matter of remembering.
121
121
 
122
+ ## Tools that write
123
+
124
+ A denial means nothing happened. Authorization runs before the tool, but with the model
125
+ class as its subject — for an index-style check there is no record yet — so the per-record
126
+ answer only arrives once the tool has returned something to scope. A write tool sits
127
+ between those two points:
128
+
129
+ ```ruby
130
+ def call(id:, number:)
131
+ invoice = Invoice.find(id) # unscoped fetch
132
+ invoice.update!(number: number) # ...then a write
133
+ invoice
134
+ end
135
+ ```
136
+
137
+ The tool body runs in a transaction, and a scope denial rolls it back. Someone else's
138
+ record is refused *and* unchanged, and the ledger's `deny` is a true statement about the
139
+ database. A transaction the host opened around the invocation is untouched — the rollback
140
+ reaches the tool's work and stops there.
141
+
142
+ What a rollback cannot reach is anything that was never in the transaction. A tool that
143
+ sends an email, calls a webhook or writes a file before it knows whether it is allowed to
144
+ must ask first:
145
+
146
+ ```ruby
147
+ def call(id:, to:)
148
+ invoice = authorize!(Invoice.find(id)) # raises DeniedError if the policy says no
149
+ InvoiceMailer.reminder(invoice, to).deliver_now
150
+ invoice
151
+ end
152
+ ```
153
+
154
+ `authorize!` asks the declared policy about one record and returns it, so it reads inline.
155
+ The denial carries the policy's own rule into the ledger, and names no record — a refusal
156
+ and a record that does not exist have to read the same.
157
+
122
158
  ## Every call leaves a trace
123
159
 
124
160
  One append-only row per invocation, allowed or denied, naming the agent, the principal, the
@@ -191,7 +227,29 @@ abort report.to_s unless report.passed?
191
227
  ```
192
228
 
193
229
  `alice` and `bob` are two fixture principals with disjoint records — that disjointness is
194
- what makes a shared record identifier proof of a leak. As a Rails rake task:
230
+ what makes a shared record identifier proof of a leak.
231
+
232
+ **The suite walks every tool that included `Reeve::Guard`, not only the ones that declared
233
+ a guard.** A tool that forgot `guard_with` is the one worth finding, and it is absent from
234
+ the guard registry by definition — so a run that only inspected guarded tools reported
235
+ all-green on precisely the application that had a problem.
236
+
237
+ Mid-retrofit that means a red build, which is honest but unreadable if it stays red for
238
+ weeks. Say what you have certified so far, and grow the list:
239
+
240
+ ```ruby
241
+ config.compliance_tools = -> { [InvoiceSearchTool, InvoiceShowTool] }
242
+ ```
243
+
244
+ Reeve can only see tools that reached it. If your MCP server dispatches tools Reeve has
245
+ never been told about — a custom controller with its own registry — hand it the real
246
+ inventory, or it will certify the subset it happens to know:
247
+
248
+ ```ruby
249
+ Reeve::Checks.run_all(principals: [alice, bob], tools: McpToolRegistry.tool_classes)
250
+ ```
251
+
252
+ As a Rails rake task:
195
253
 
196
254
  ```ruby
197
255
  # lib/tasks/reeve.rake
@@ -16,10 +16,22 @@ module Reeve
16
16
  module Guard
17
17
  def self.included(base)
18
18
  base.extend(ClassMethods)
19
+ Reeve.registry.note(base)
19
20
  end
20
21
 
21
22
  # Class-level DSL. See contracts/tool-dsl.md.
22
23
  module ClassMethods
24
+ # Declares this class a base rather than a tool: it carries the DSL so its
25
+ # subclasses have it, and the compliance suite should not ask whether it is guarded.
26
+ #
27
+ # class ApplicationTool
28
+ # include Reeve::Guard
29
+ # reeve_abstract!
30
+ # end
31
+ def reeve_abstract!
32
+ Reeve.registry.mark_abstract(self)
33
+ end
34
+
23
35
  # Declares which policy governs this tool. Absence is not neutral: a tool with no
24
36
  # declaration is denied by the envelope (FR-002, FR-004).
25
37
  def guard_with(policy, action: nil)
@@ -59,8 +71,16 @@ module Reeve
59
71
 
60
72
  # A subclass of a guarded tool is itself guarded, and is registered under its own
61
73
  # name so the envelope — which only ever has a name — can find it.
74
+ #
75
+ # The note comes first and unconditionally, because a subclass of an *unguarded*
76
+ # base is the case worth catching: subclasses acquire the DSL without ever running
77
+ # `included`, so without this every fast-mcp tool would be invisible to the
78
+ # compliance suite — that adapter includes Guard into `FastMcp::Tool` once and lets
79
+ # inheritance do the rest.
62
80
  def inherited(subclass)
63
81
  super
82
+ Reeve.registry.note(subclass)
83
+
64
84
  declaration = reeve_guard
65
85
  # An anonymous subclass has no name to be looked up by; it still inherits the
66
86
  # declaration through the ancestry walk in Registry#for_class.
@@ -93,5 +113,38 @@ module Reeve
93
113
 
94
114
  Authorization::Scoper.scoped_relation(state, model_or_relation)
95
115
  end
116
+
117
+ # Asks the declared policy about one record, before the tool acts on it. Returns the
118
+ # record so it reads inline; raises +DeniedError+ if the policy says no.
119
+ #
120
+ # def call(id:, to:)
121
+ # invoice = authorize!(Invoice.find(id))
122
+ # InvoiceMailer.reminder(invoice, to).deliver_now
123
+ # invoice
124
+ # end
125
+ #
126
+ # The envelope authorizes before the tool runs and scopes what it returns, and between
127
+ # those two points a tool can do things neither of them can reach. A rolled-back write
128
+ # leaves no trace; a sent email does. `scoped(...)` is the answer when a tool can work
129
+ # from a relation; this is the answer when it cannot — when the tool holds one record
130
+ # and is about to do something to the world with it.
131
+ #
132
+ # The denial carries the policy's own rule, so the ledger names what refused rather
133
+ # than reporting the tool as broken. It names no record: an out-of-scope record and a
134
+ # record that does not exist have to be indistinguishable (FR-006), and a tool that
135
+ # reached this line has already fetched the record it must not talk about.
136
+ def authorize!(record)
137
+ state = Authorization::Current.state
138
+ raise Error, "authorize!(...) may only be called inside a guarded invocation" if state.nil?
139
+
140
+ decision = state.adapter.authorize(
141
+ principal: state.context.principal, policy: state.declaration.policy,
142
+ action: state.declaration.action, record: record
143
+ )
144
+ return record if decision.allowed?
145
+
146
+ raise DeniedError.from(decision, tool_name: state.context.tool_name,
147
+ principal_id: state.context.principal_id)
148
+ end
96
149
  end
97
150
  end
@@ -13,10 +13,49 @@ module Reeve
13
13
 
14
14
  def initialize
15
15
  @declarations = {}
16
+ @known = {} # every class that included Guard, declared or not
17
+ @abstract = {} # bases others inherit from; not tools themselves
16
18
  @name_index = nil # invalidated on every add; see #name_index
17
19
  @mutex = Mutex.new
18
20
  end
19
21
 
22
+ # A class that included the DSL. Recorded whether or not it goes on to declare
23
+ # anything, because a tool that forgot `guard_with` is precisely the one worth
24
+ # finding, and it is absent from `@declarations` by definition.
25
+ def note(tool_class)
26
+ @mutex.synchronize { @known[tool_class] = true }
27
+ tool_class
28
+ end
29
+
30
+ # A base others inherit from — `FastMcp::Tool`, or a host's own `ApplicationTool`.
31
+ # It acquires the DSL so its subclasses have it, and it is not itself a tool, so
32
+ # asking whether it declared a guard is a question with no useful answer.
33
+ #
34
+ # Explicit rather than inferred. The obvious heuristic — "a class something else
35
+ # inherits from is a base" — silently drops a real tool from the compliance run the
36
+ # moment someone subclasses it, and a check that quietly stops checking is the
37
+ # failure mode this whole file exists to prevent.
38
+ def mark_abstract(tool_class)
39
+ @mutex.synchronize { @abstract[tool_class] = true }
40
+ tool_class
41
+ end
42
+
43
+ def abstract?(tool_class)
44
+ @abstract.key?(tool_class)
45
+ end
46
+
47
+ # Every tool reeve knows about: the ones that declared a guard and the ones that
48
+ # only included the DSL. This is what the compliance suite walks, so that "all
49
+ # checks passed" cannot mean "we only looked at the tools that were already safe".
50
+ def tool_classes
51
+ (@known.keys | @declarations.keys).reject { |klass| abstract?(klass) }
52
+ end
53
+
54
+ # The worklist.
55
+ def unguarded_tool_classes
56
+ tool_classes.reject { |klass| @declarations.key?(klass) }
57
+ end
58
+
20
59
  # The DSL's `guard_with`. Declaring twice on one class is a mistake worth naming;
21
60
  # `add` is the quiet path used by `redact` and by inheritance, which refine an
22
61
  # existing declaration rather than compete with it.
@@ -74,6 +113,8 @@ module Reeve
74
113
  def remove(tool_class)
75
114
  @mutex.synchronize do
76
115
  @declarations.delete(tool_class)
116
+ @known.delete(tool_class)
117
+ @abstract.delete(tool_class)
77
118
  @name_index = nil
78
119
  end
79
120
  end
@@ -81,6 +122,8 @@ module Reeve
81
122
  def reset!
82
123
  @mutex.synchronize do
83
124
  @declarations = {}
125
+ @known = {}
126
+ @abstract = {}
84
127
  @name_index = nil
85
128
  end
86
129
  end
@@ -24,7 +24,7 @@ module Reeve
24
24
  SETTINGS = %i[
25
25
  principal_resolver unguarded_tools audit_failure_mode redact_arguments
26
26
  max_recorded_ids policy_adapter default_action audit_recorder logger
27
- compliance_principals
27
+ compliance_principals compliance_tools
28
28
  ].freeze
29
29
 
30
30
  attr_reader(*SETTINGS)
@@ -40,11 +40,33 @@ module Reeve
40
40
  @audit_recorder = nil
41
41
  @logger = nil
42
42
  @compliance_principals = nil
43
+ @compliance_tools = nil
43
44
  end
44
45
 
45
46
  # Two fixture principals with disjoint records — the only host setup the compliance
46
47
  # suite needs (contracts/testing-kit.md). A callable rather than a value, because in a
47
48
  # Rails test suite the fixtures do not exist yet when the helper is loaded.
49
+ # Which tools the compliance suite certifies. Defaults to every tool reeve knows
50
+ # about, which is the answer a finished application wants.
51
+ #
52
+ # A host part-way through a retrofit needs the other answer. Once the suite reports
53
+ # unguarded tools — which is the whole point of it — an application with thirty tools
54
+ # and three guarded ones has a red build it cannot honestly turn green, and a red
55
+ # build that is expected to be red stops being read. Narrowing the list says "these
56
+ # are the ones I have certified", which is a claim that can go green and then stay
57
+ # green as the list grows.
58
+ #
59
+ # config.compliance_tools = -> { [InvoiceSearchTool, InvoiceShowTool] }
60
+ def compliance_tools=(tools)
61
+ unless tools.nil? || tools.respond_to?(:call) || tools.is_a?(Array)
62
+ raise ArgumentError,
63
+ "compliance_tools must be an Array or a callable returning one, " \
64
+ "got #{tools.inspect}"
65
+ end
66
+
67
+ @compliance_tools = tools
68
+ end
69
+
48
70
  def compliance_principals=(principals)
49
71
  unless principals.nil? || principals.respond_to?(:call) || principals.is_a?(Array)
50
72
  raise ArgumentError,
@@ -37,7 +37,11 @@ module Reeve
37
37
  # Idempotent: requiring "reeve/fast_mcp" twice must not stack two envelopes around
38
38
  # the same call.
39
39
  def install!(tool_base = ::FastMcp::Tool)
40
- tool_base.include(Reeve::Guard) unless tool_base.include?(Reeve::Guard)
40
+ unless tool_base.include?(Reeve::Guard)
41
+ tool_base.include(Reeve::Guard)
42
+ # FastMcp::Tool carries the DSL for its subclasses; it is not itself a tool.
43
+ tool_base.reeve_abstract!
44
+ end
41
45
 
42
46
  unless tool_base.singleton_class.include?(Inheritance)
43
47
  tool_base.singleton_class.prepend(Inheritance)
@@ -152,13 +152,51 @@ module Reeve
152
152
  Decision.deny(rule: Decision::POLICY_ERROR, detail: "policy raised #{e.class}: #{e.message}")
153
153
  end
154
154
 
155
+ # A denial has to mean nothing happened.
156
+ #
157
+ # Authorization runs before the tool, but with the *class* as its subject — for an
158
+ # index-style check there is no record yet. The per-record answer only exists once the
159
+ # tool has returned something to scope. So a write tool used to mutate the record and
160
+ # then be refused its response: the caller saw `out_of_scope_record`, the ledger
161
+ # recorded a denial, and the row in the host's database had already changed. A denial
162
+ # in a compliance artifact that follows a committed write is not a partial guarantee,
163
+ # it is a false statement.
164
+ #
165
+ # Running the tool inside a transaction closes that. `requires_new` matters: under a
166
+ # transaction the host opened around the invocation this is a savepoint, so rolling
167
+ # back takes the tool's work and leaves the host's alone.
168
+ #
169
+ # What it cannot undo is anything that was never in the transaction — a sent email, a
170
+ # webhook, a file. A tool that reaches outside the database before it knows whether it
171
+ # is allowed to must ask first; `authorize!` in the tool body is for exactly that.
155
172
  def run_guarded(guard, decision, &tool)
173
+ return run_and_narrow(guard, decision, &tool) unless rollback_available?
174
+
175
+ ::ActiveRecord::Base.transaction(requires_new: true) do
176
+ outcome = run_and_narrow(guard, decision, &tool)
177
+ raise ::ActiveRecord::Rollback if @scope_result.denied?
178
+
179
+ outcome
180
+ end || @scope_result.decision
181
+ end
182
+
183
+ def run_and_narrow(guard, decision, &tool)
156
184
  result = execute(&tool)
157
185
 
158
186
  @scope_result = narrow(guard, result)
159
187
  @scope_result.denied? ? @scope_result.decision : decision
160
188
  end
161
189
 
190
+ # The core runs with no ActiveRecord at all (SC-008), and a host may have the library
191
+ # loaded without a connection. Neither can roll anything back, and neither should be
192
+ # made to fail here — they get the behaviour they had before, which the ledger still
193
+ # records truthfully because it records what the scoper decided either way.
194
+ def rollback_available?
195
+ defined?(::ActiveRecord::Base) && ::ActiveRecord::Base.connected?
196
+ rescue StandardError
197
+ false
198
+ end
199
+
162
200
  # The degraded mode a host opts into while retrofitting guards onto existing tools
163
201
  # (FR-023). The call is unscoped — that is the whole point of the warning.
164
202
  def run_unguarded(&tool)
@@ -171,6 +209,14 @@ module Reeve
171
209
 
172
210
  def execute(&tool)
173
211
  tool.call
212
+ rescue DeniedError => e
213
+ # A denial the tool raised on itself through `authorize!`. It is an authorization
214
+ # outcome, not a crash, and recording it as `tool_error` would file a policy saying
215
+ # no under the same rule as a NoMethodError. The rule the policy gave is carried
216
+ # through to the ledger unchanged.
217
+ @decision = Decision.deny(rule: e.rule, detail: e.detail)
218
+ @scope_result = ScopeResult.deny(rule: e.rule, detail: e.detail)
219
+ raise
174
220
  rescue StandardError => e
175
221
  # The tool's own failure propagates untouched, but not before the ensure block
176
222
  # records it: the invocations most worth having a trace of are the ones that broke.
@@ -55,7 +55,7 @@ module Reeve
55
55
  def self.run(check, principals:, tools: nil, arguments: {}, invoke: nil, ledger: nil)
56
56
  return Report.new([check.new(ledger: ledger).call]) if GLOBAL.include?(check)
57
57
 
58
- subjects = tools || Reeve.registry.map(&:tool_class)
58
+ subjects = tools || Testing.compliance_tools
59
59
  Report.new(
60
60
  subjects.map do |tool|
61
61
  build(check, tool: tool, principals: principals, arguments: arguments,
data/lib/reeve/testing.rb CHANGED
@@ -40,12 +40,25 @@ module Reeve
40
40
  principals
41
41
  end
42
42
 
43
+ # The tools the suite walks. Unset means every tool reeve knows about — the classes
44
+ # that included the DSL, guarded or not. That default is what makes an unguarded
45
+ # tool visible rather than merely absent.
46
+ attr_writer :compliance_tools
47
+
48
+ def compliance_tools
49
+ source = @compliance_tools || Reeve.config.compliance_tools
50
+ return Reeve.registry.tool_classes if source.nil?
51
+
52
+ Array(source.respond_to?(:call) ? source.call : source)
53
+ end
54
+
43
55
  def compliance_principals?
44
56
  !(@compliance_principals || Reeve.config.compliance_principals).nil?
45
57
  end
46
58
 
47
59
  def reset!
48
60
  @compliance_principals = nil
61
+ @compliance_tools = nil
49
62
  end
50
63
 
51
64
  private
data/lib/reeve/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Reeve
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reeve
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Victor Velazquez
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-17 00:00:00.000000000 Z
11
+ date: 2026-09-03 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Reeve makes it safe for a Rails application to expose MCP (Model Context Protocol)