reeve 0.0.1 → 0.1.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +112 -0
  3. data/README.md +191 -17
  4. data/lib/generators/reeve/install/install_generator.rb +46 -0
  5. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +69 -0
  6. data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
  7. data/lib/reeve/audit/entry.rb +48 -0
  8. data/lib/reeve/audit/query.rb +84 -0
  9. data/lib/reeve/audit/recorder.rb +163 -0
  10. data/lib/reeve/audit/redactor.rb +67 -0
  11. data/lib/reeve/audit.rb +88 -0
  12. data/lib/reeve/authorization/adapter.rb +71 -0
  13. data/lib/reeve/authorization/adapters/plain.rb +65 -0
  14. data/lib/reeve/authorization/adapters/pundit.rb +84 -0
  15. data/lib/reeve/authorization/authorizer.rb +23 -0
  16. data/lib/reeve/authorization/current.rb +66 -0
  17. data/lib/reeve/authorization/declaration.rb +73 -0
  18. data/lib/reeve/authorization/guard.rb +97 -0
  19. data/lib/reeve/authorization/registry.rb +118 -0
  20. data/lib/reeve/authorization/scoper.rb +399 -0
  21. data/lib/reeve/authorization.rb +76 -0
  22. data/lib/reeve/configuration.rb +158 -0
  23. data/lib/reeve/context.rb +104 -0
  24. data/lib/reeve/decision.rb +111 -0
  25. data/lib/reeve/errors.rb +89 -0
  26. data/lib/reeve/fast_mcp.rb +24 -0
  27. data/lib/reeve/integrations/fast_mcp/context_builder.rb +48 -0
  28. data/lib/reeve/integrations/fast_mcp/tool_extension.rb +55 -0
  29. data/lib/reeve/invocation.rb +260 -0
  30. data/lib/reeve/minitest.rb +19 -0
  31. data/lib/reeve/rspec.rb +18 -0
  32. data/lib/reeve/scope_result.rb +104 -0
  33. data/lib/reeve/testing/assertions.rb +76 -0
  34. data/lib/reeve/testing/checks/audit_coverage.rb +46 -0
  35. data/lib/reeve/testing/checks/base.rb +155 -0
  36. data/lib/reeve/testing/checks/contract_version.rb +73 -0
  37. data/lib/reeve/testing/checks/cross_principal_leak.rb +132 -0
  38. data/lib/reeve/testing/checks/guard_declared.rb +33 -0
  39. data/lib/reeve/testing/checks/principal_required.rb +60 -0
  40. data/lib/reeve/testing/checks/redaction_holds.rb +142 -0
  41. data/lib/reeve/testing/checks/rule_present.rb +55 -0
  42. data/lib/reeve/testing/checks.rb +95 -0
  43. data/lib/reeve/testing/compliance_assertions.rb +34 -0
  44. data/lib/reeve/testing/compliance_suite.rb +23 -0
  45. data/lib/reeve/testing/ledger.rb +82 -0
  46. data/lib/reeve/testing/matchers/audit_every_call.rb +39 -0
  47. data/lib/reeve/testing/matchers/base.rb +84 -0
  48. data/lib/reeve/testing/matchers/deny_access_for.rb +38 -0
  49. data/lib/reeve/testing/matchers/pass_reeve_check.rb +32 -0
  50. data/lib/reeve/testing/matchers.rb +33 -0
  51. data/lib/reeve/testing/report.rb +58 -0
  52. data/lib/reeve/testing/result.rb +49 -0
  53. data/lib/reeve/testing.rb +60 -0
  54. data/lib/reeve/version.rb +1 -1
  55. data/lib/reeve.rb +9 -3
  56. metadata +52 -1
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "authorization/declaration"
4
+ require_relative "authorization/registry"
5
+ require_relative "authorization/adapters/plain"
6
+ require_relative "authorization/adapters/pundit"
7
+ require_relative "authorization/adapter"
8
+ require_relative "authorization/current"
9
+ require_relative "authorization/authorizer"
10
+ require_relative "authorization/scoper"
11
+ require_relative "authorization/guard"
12
+
13
+ # Reeve — per-record authorization and an append-only audit ledger for MCP tools.
14
+ module Reeve
15
+ # Per-record authorization: the registry of guard declarations, the policy adapters,
16
+ # and the scoper that narrows a tool's return value to what the principal may see.
17
+ module Authorization
18
+ end
19
+
20
+ class << self
21
+ # The plain interface, and the composition root for every other one. An MCP server
22
+ # adapter builds the Context and calls this; there is no second path into the
23
+ # envelope, which is what makes "was this authorized and recorded?" answerable in
24
+ # one place.
25
+ #
26
+ # Reeve.invoke(tool: InvoiceSearchTool, arguments: { query: "AC" },
27
+ # principal: current_user, agent: { id: "claude-desktop" })
28
+ def invoke(tool:, arguments: {}, principal: :unset, agent: nil, metadata: {}, &body)
29
+ context = Context.new(
30
+ tool_name: tool_name_for(tool),
31
+ agent: agent,
32
+ arguments: arguments,
33
+ metadata: metadata
34
+ )
35
+
36
+ declaration = registry.guard_for(context.tool_name)
37
+ adapter = declaration ? Authorization::Adapter.resolve(declaration.policy) : nil
38
+
39
+ Authorization::Current.with(context: context, declaration: declaration, adapter: adapter) do
40
+ Invocation.call(
41
+ context,
42
+ registry: registry,
43
+ authorizer: Authorization::Authorizer.new,
44
+ scoper: Authorization::Scoper.new,
45
+ config: configuration_for(principal)
46
+ ) { body ? body.call : run(tool, arguments) }
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def run(tool, arguments)
53
+ instance = tool.is_a?(Class) ? tool.new : tool
54
+ arguments.empty? ? instance.call : instance.call(**arguments)
55
+ end
56
+
57
+ def tool_name_for(tool)
58
+ klass = tool.is_a?(Class) ? tool : tool.class
59
+ declaration = registry.for_class(klass)
60
+ return declaration.tool_name if declaration
61
+ return klass.tool_name.to_s if klass.respond_to?(:tool_name) && klass.tool_name
62
+
63
+ Authorization::Declaration.new(tool_class: klass, policy: nil, action: :index).tool_name
64
+ end
65
+
66
+ # An explicitly supplied principal is just a resolver that returns it. Routing it
67
+ # through the same resolution step rather than around it keeps one answer to "where
68
+ # did this principal come from" — the envelope still resolves, records and denies
69
+ # identically, and a nil passed in still denies with `no_principal`.
70
+ def configuration_for(principal)
71
+ return config if principal == :unset
72
+
73
+ config.dup.tap { |scoped| scoped.principal_resolver = ->(_context) { principal } }
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reeve — per-record authorization and an append-only audit ledger for MCP tools.
4
+ module Reeve
5
+ # Process-wide settings. See contracts/configuration.md.
6
+ #
7
+ # Every setting validates at assignment rather than at use: a typo in an initializer
8
+ # should fail on the line that caused it, not three weeks later inside a denial path.
9
+ # The one deliberate exception is +principal_resolver+, which may be nil — the library
10
+ # stays loadable in contexts that never invoke a tool, and denies every call until the
11
+ # host sets one (SC-008).
12
+ class Configuration
13
+ UNGUARDED_TOOL_MODES = %i[deny allow_with_warning].freeze
14
+ AUDIT_FAILURE_MODES = %i[fail warn].freeze
15
+ POLICY_ADAPTERS = %i[auto pundit plain].freeze
16
+
17
+ DEFAULT_REDACTED_ARGUMENTS = %i[
18
+ password password_confirmation passwd secret token access_token refresh_token
19
+ api_key private_key authorization ssn credit_card card_number cvv pin
20
+ ].freeze
21
+
22
+ DEFAULT_MAX_RECORDED_IDS = 1000
23
+
24
+ SETTINGS = %i[
25
+ principal_resolver unguarded_tools audit_failure_mode redact_arguments
26
+ max_recorded_ids policy_adapter default_action audit_recorder logger
27
+ compliance_principals
28
+ ].freeze
29
+
30
+ attr_reader(*SETTINGS)
31
+
32
+ def initialize
33
+ @principal_resolver = nil
34
+ @unguarded_tools = :deny
35
+ @audit_failure_mode = :fail
36
+ @redact_arguments = DEFAULT_REDACTED_ARGUMENTS.dup
37
+ @max_recorded_ids = DEFAULT_MAX_RECORDED_IDS
38
+ @policy_adapter = :auto
39
+ @default_action = :index
40
+ @audit_recorder = nil
41
+ @logger = nil
42
+ @compliance_principals = nil
43
+ end
44
+
45
+ # Two fixture principals with disjoint records — the only host setup the compliance
46
+ # suite needs (contracts/testing-kit.md). A callable rather than a value, because in a
47
+ # Rails test suite the fixtures do not exist yet when the helper is loaded.
48
+ def compliance_principals=(principals)
49
+ unless principals.nil? || principals.respond_to?(:call) || principals.is_a?(Array)
50
+ raise ArgumentError,
51
+ "compliance_principals must be an Array or a callable returning one, " \
52
+ "got #{principals.inspect}"
53
+ end
54
+
55
+ @compliance_principals = principals
56
+ end
57
+
58
+ def unguarded_tools=(mode)
59
+ @unguarded_tools = require_one_of!(:unguarded_tools, mode, UNGUARDED_TOOL_MODES)
60
+ end
61
+
62
+ def audit_failure_mode=(mode)
63
+ @audit_failure_mode = require_one_of!(:audit_failure_mode, mode, AUDIT_FAILURE_MODES)
64
+ end
65
+
66
+ def max_recorded_ids=(limit)
67
+ unless limit.is_a?(Integer) && limit.positive?
68
+ raise ArgumentError, "max_recorded_ids must be a positive Integer, got #{limit.inspect}"
69
+ end
70
+
71
+ @max_recorded_ids = limit
72
+ end
73
+
74
+ def principal_resolver=(resolver)
75
+ unless resolver.nil? || resolver.respond_to?(:call)
76
+ raise ArgumentError,
77
+ "principal_resolver must respond to #call (it receives a Reeve::Context), " \
78
+ "got #{resolver.inspect}"
79
+ end
80
+
81
+ @principal_resolver = resolver
82
+ end
83
+
84
+ def policy_adapter=(adapter)
85
+ @policy_adapter =
86
+ if adapter.is_a?(Symbol) || adapter.is_a?(String)
87
+ require_one_of!(:policy_adapter, adapter, POLICY_ADAPTERS)
88
+ else
89
+ require_protocol!(:policy_adapter, adapter, %i[authorize scope])
90
+ end
91
+ end
92
+
93
+ def redact_arguments=(names)
94
+ unless names.is_a?(Array)
95
+ raise ArgumentError, "redact_arguments must be an Array of names, got #{names.inspect}"
96
+ end
97
+
98
+ @redact_arguments = names.map(&:to_sym)
99
+ end
100
+
101
+ def default_action=(action)
102
+ if action.nil? || action.to_s.strip.empty?
103
+ raise ArgumentError,
104
+ "default_action must be a non-blank policy action, got #{action.inspect}"
105
+ end
106
+
107
+ @default_action = action.to_sym
108
+ end
109
+
110
+ def audit_recorder=(recorder)
111
+ @audit_recorder =
112
+ recorder.nil? ? nil : require_protocol!(:audit_recorder, recorder, [:record])
113
+ end
114
+
115
+ def logger=(logger)
116
+ @logger = logger.nil? ? nil : require_protocol!(:logger, logger, [:warn])
117
+ end
118
+
119
+ def to_h
120
+ SETTINGS.to_h { |setting| [setting, public_send(setting)] }
121
+ end
122
+
123
+ private
124
+
125
+ def require_one_of!(setting, value, allowed)
126
+ symbol = value.is_a?(Symbol) ? value : nil
127
+ return symbol if allowed.include?(symbol)
128
+
129
+ raise ArgumentError,
130
+ "#{setting} must be one of #{allowed.map(&:inspect).join(', ')}, got #{value.inspect}"
131
+ end
132
+
133
+ def require_protocol!(setting, object, methods)
134
+ missing = methods.reject { |method| object.respond_to?(method) }
135
+ return object if missing.empty?
136
+
137
+ raise ArgumentError,
138
+ "#{setting} must respond to #{missing.map { |m| "##{m}" }.join(', ')} " \
139
+ "(got #{object.inspect})"
140
+ end
141
+ end
142
+
143
+ class << self
144
+ def config
145
+ @config ||= Configuration.new
146
+ end
147
+
148
+ def configure
149
+ yield(config)
150
+ config
151
+ end
152
+
153
+ # Public so host test suites can isolate examples from one another.
154
+ def reset_configuration!
155
+ @config = Configuration.new
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "securerandom"
5
+
6
+ module Reeve
7
+ # The per-invocation carrier: who is calling, on whose behalf, which tool, with what.
8
+ #
9
+ # Created by an adapter or by the caller of the plain interface — never global, never
10
+ # reused between invocations. The principal is the only mutable field, because the
11
+ # envelope resolves it after the context exists, and clears it in an +ensure+.
12
+ class Context
13
+ UNKNOWN_AGENT_ID = "unknown"
14
+
15
+ attr_reader :invocation_id, :tool_name, :arguments, :metadata, :agent, :invoked_at
16
+ attr_accessor :principal
17
+
18
+ def initialize(tool_name:, principal: nil, agent: nil, arguments: nil, metadata: nil,
19
+ invoked_at: nil, invocation_id: nil)
20
+ @tool_name = validate_tool_name(tool_name)
21
+ @principal = principal
22
+ @agent = build_agent(agent)
23
+ @arguments = symbolize(:arguments, arguments)
24
+ @metadata = symbolize(:metadata, metadata)
25
+ @invoked_at = invoked_at || Time.now
26
+ @invocation_id = (invocation_id || SecureRandom.uuid).to_s
27
+ end
28
+
29
+ def principal_resolved?
30
+ !principal.nil?
31
+ end
32
+
33
+ # Called from the envelope's ensure block: nothing about one invocation may survive
34
+ # into the next on the same thread (data-model invariant 4).
35
+ def clear_principal!
36
+ @principal = nil
37
+ end
38
+
39
+ def principal_type
40
+ principal&.class&.name
41
+ end
42
+
43
+ def principal_id
44
+ return nil if principal.nil?
45
+
46
+ principal.respond_to?(:id) ? principal.id.to_s : principal.to_s
47
+ end
48
+
49
+ def agent_id
50
+ agent[:id]
51
+ end
52
+
53
+ def agent_name
54
+ agent[:name]
55
+ end
56
+
57
+ # The audit-facing projection. The recorder adds the outcome, rule and records;
58
+ # everything here is known before the tool runs.
59
+ def to_h
60
+ {
61
+ invocation_id: invocation_id,
62
+ occurred_at: invoked_at,
63
+ tool_name: tool_name,
64
+ agent_id: agent_id,
65
+ agent_name: agent_name,
66
+ principal_type: principal_type,
67
+ principal_id: principal_id,
68
+ arguments: arguments
69
+ }
70
+ end
71
+
72
+ def inspect
73
+ "#<Reeve::Context tool=#{tool_name.inspect} principal=#{principal_id.inspect} " \
74
+ "agent=#{agent_id.inspect} invocation=#{invocation_id.inspect}>"
75
+ end
76
+
77
+ private
78
+
79
+ def validate_tool_name(name)
80
+ string = name&.to_s
81
+ return string if string && !string.strip.empty?
82
+
83
+ raise ArgumentError, "tool_name is required and may not be blank (got #{name.inspect})"
84
+ end
85
+
86
+ def build_agent(agent)
87
+ attributes = symbolize(:agent, agent)
88
+ id = attributes[:id]
89
+ attributes[:id] = id.nil? || id.to_s.strip.empty? ? UNKNOWN_AGENT_ID : id.to_s
90
+ attributes[:name] = attributes[:name].to_s unless attributes[:name].nil?
91
+ attributes
92
+ end
93
+
94
+ def symbolize(field, hash)
95
+ return {} if hash.nil?
96
+
97
+ raise ArgumentError, "#{field} must be a Hash, got #{hash.class}" unless hash.is_a?(Hash)
98
+
99
+ hash.each_with_object({}) do |(key, value), result|
100
+ result[key.respond_to?(:to_sym) ? key.to_sym : key] = value
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ # The result of a policy evaluation: an outcome, and the rule that produced it.
5
+ #
6
+ # A decision without a rule is not a decision — Constitution II requires every ledger
7
+ # entry to name what decided, so the rule is validated here rather than at write time.
8
+ # Immutable and comparable by value.
9
+ class Decision
10
+ NO_GUARD_DECLARED = "no_guard_declared"
11
+ NO_PRINCIPAL = "no_principal"
12
+ POLICY_ERROR = "policy_error"
13
+ UNKNOWN_RECORD_TYPE = "unknown_record_type"
14
+ UNSCOPED_DERIVED_RESULT = "unscoped_derived_result"
15
+ OUT_OF_SCOPE_RECORD = "out_of_scope_record"
16
+ AUDIT_WRITE_FAILED = "audit_write_failed"
17
+ # An allowed invocation whose tool body raised. No records reached the agent, so the
18
+ # ledger records it as a deny — the trace of a call that blew up is the one most
19
+ # worth having (R5).
20
+ TOOL_ERROR = "tool_error"
21
+
22
+ # The one reserved *allow* rule: a tool with no guard, permitted because the host
23
+ # opted into :allow_with_warning. Kept out of RESERVED_RULES, which names deny paths.
24
+ UNGUARDED_TOOL = "unguarded_tool"
25
+
26
+ # Stable strings. The testing kit and host applications match on them, so a rename
27
+ # here is a breaking change.
28
+ RESERVED_RULES = [
29
+ NO_GUARD_DECLARED,
30
+ NO_PRINCIPAL,
31
+ POLICY_ERROR,
32
+ UNKNOWN_RECORD_TYPE,
33
+ UNSCOPED_DERIVED_RESULT,
34
+ OUT_OF_SCOPE_RECORD,
35
+ AUDIT_WRITE_FAILED,
36
+ TOOL_ERROR
37
+ ].freeze
38
+
39
+ OUTCOMES = %i[allow deny].freeze
40
+
41
+ attr_reader :outcome, :rule, :detail
42
+
43
+ def self.allow(rule:, detail: nil)
44
+ new(outcome: :allow, rule: rule, detail: detail)
45
+ end
46
+
47
+ def self.deny(rule:, detail: nil)
48
+ new(outcome: :deny, rule: rule, detail: detail)
49
+ end
50
+
51
+ def initialize(outcome:, rule:, detail: nil)
52
+ @outcome = validate_outcome(outcome)
53
+ @rule = validate_rule(rule)
54
+ @detail = detail&.to_s
55
+ freeze
56
+ end
57
+
58
+ def allowed?
59
+ outcome == :allow
60
+ end
61
+
62
+ def denied?
63
+ outcome == :deny
64
+ end
65
+
66
+ # True when the rule came from reeve itself rather than from a host policy.
67
+ def reserved_rule?
68
+ RESERVED_RULES.include?(rule)
69
+ end
70
+
71
+ def to_h
72
+ { outcome: outcome.to_s, rule: rule, detail: detail }
73
+ end
74
+
75
+ def ==(other)
76
+ other.is_a?(Decision) &&
77
+ other.outcome == outcome &&
78
+ other.rule == rule &&
79
+ other.detail == detail
80
+ end
81
+ alias eql? ==
82
+
83
+ def hash
84
+ [self.class, outcome, rule, detail].hash
85
+ end
86
+
87
+ def to_s
88
+ "#{outcome}(#{rule})"
89
+ end
90
+
91
+ def inspect
92
+ "#<Reeve::Decision #{outcome} rule=#{rule.inspect} detail=#{detail.inspect}>"
93
+ end
94
+
95
+ private
96
+
97
+ def validate_outcome(outcome)
98
+ symbol = outcome.respond_to?(:to_sym) ? outcome.to_sym : outcome
99
+ return symbol if OUTCOMES.include?(symbol)
100
+
101
+ raise ArgumentError, "outcome must be :allow or :deny, got #{outcome.inspect}"
102
+ end
103
+
104
+ def validate_rule(rule)
105
+ string = rule&.to_s
106
+ return string.freeze unless string.nil? || string.strip.empty?
107
+
108
+ raise ArgumentError, "rule is required and may not be blank (got #{rule.inspect})"
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ # Base class for everything reeve raises. Hosts can rescue this one class.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when reeve is asked to run with a configuration it cannot honour.
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised when a guarded invocation is denied.
11
+ #
12
+ # The message names the tool, the principal and the rule, because the first question
13
+ # a developer asks is "which rule stopped this, and for whom?" (Constitution VI).
14
+ # It deliberately never names a record: see .out_of_scope.
15
+ class DeniedError < Error
16
+ attr_reader :tool_name, :principal_id, :rule, :detail
17
+
18
+ def self.from(decision, tool_name:, principal_id:)
19
+ unless decision.denied?
20
+ raise ArgumentError, "cannot build a DeniedError from an allow decision (#{decision})"
21
+ end
22
+
23
+ new(
24
+ tool_name: tool_name,
25
+ principal_id: principal_id,
26
+ rule: decision.rule,
27
+ detail: decision.detail
28
+ )
29
+ end
30
+
31
+ # FR-006. Fetching a record outside the principal's scope must be indistinguishable
32
+ # from fetching one that does not exist, so this builder accepts no record at all —
33
+ # there is nothing to leak, by construction rather than by discipline.
34
+ def self.out_of_scope(tool_name:, principal_id:)
35
+ new(
36
+ tool_name: tool_name,
37
+ principal_id: principal_id,
38
+ rule: Decision::OUT_OF_SCOPE_RECORD,
39
+ detail: "the requested record is not within this principal's scope"
40
+ )
41
+ end
42
+
43
+ def initialize(tool_name:, principal_id:, rule:, detail: nil)
44
+ @tool_name = tool_name&.to_s
45
+ @principal_id = principal_id&.to_s
46
+ @rule = rule.to_s
47
+ @detail = detail&.to_s
48
+ super(build_message)
49
+ end
50
+
51
+ private
52
+
53
+ def build_message
54
+ principal = principal_id.nil? ? "no principal" : "principal #{principal_id}"
55
+ base = "reeve denied #{tool_name} for #{principal}: #{rule}"
56
+ detail.nil? ? base : "#{base} (#{detail})"
57
+ end
58
+ end
59
+
60
+ # Raised when the ledger write fails and audit_failure_mode is :fail (FR-012).
61
+ #
62
+ # It carries a rule like any other denial, because the failure is not recorded anywhere
63
+ # else: the ledger is the thing that failed, so there is no row to read afterwards. The
64
+ # exception is the only artifact, and a host matching on rules can match on this one.
65
+ class AuditWriteError < Error
66
+ attr_reader :invocation_id, :original_error, :rule
67
+
68
+ # `during` is whatever the invocation was already raising when the ledger write
69
+ # failed — usually a DeniedError, sometimes the tool's own exception. It is carried
70
+ # rather than discarded: the audit failure must win, because a call that cannot be
71
+ # recorded is a failed call, but the developer still needs to see what the call was
72
+ # doing at the time.
73
+ attr_reader :during
74
+
75
+ def initialize(invocation_id:, cause: nil, during: nil)
76
+ @invocation_id = invocation_id&.to_s
77
+ @original_error = cause
78
+ @during = during
79
+ @rule = Decision::AUDIT_WRITE_FAILED
80
+ message = "reeve could not record invocation #{@invocation_id}"
81
+ message = "#{message}: #{cause.message}" if cause
82
+ if during
83
+ message = "#{message} (while the invocation was already failing with " \
84
+ "#{during.class}: #{during.message})"
85
+ end
86
+ super(message)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../reeve"
4
+
5
+ begin
6
+ require "fast_mcp"
7
+ rescue LoadError => e
8
+ raise Reeve::ConfigurationError,
9
+ "reeve/fast_mcp needs the fast-mcp gem, which could not be loaded (#{e.message}). " \
10
+ "Add fast-mcp to your Gemfile, or use Reeve.invoke directly — the core needs no " \
11
+ "MCP server library."
12
+ end
13
+
14
+ require_relative "integrations/fast_mcp/context_builder"
15
+ require_relative "integrations/fast_mcp/tool_extension"
16
+
17
+ module Reeve
18
+ # Adapters for the MCP server libraries reeve rides on. Each is opt-in, conditionally
19
+ # loaded, and never a dependency of the core (Constitution IV).
20
+ module Integrations
21
+ end
22
+ end
23
+
24
+ Reeve::Integrations::FastMcp.install!
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Integrations
5
+ # The fast-mcp bridge: the DSL on every tool, and the envelope around every call.
6
+ module FastMcp
7
+ # Turns what a fast-mcp tool instance knows about its request into the attributes
8
+ # a Reeve::Context is built from.
9
+ #
10
+ # fast-mcp constructs a tool as `tool.new(headers: headers)` per request and calls
11
+ # `call_with_schema_validation!` on it, so the transport's headers are the only
12
+ # per-request context a tool has. They are passed through to the principal resolver
13
+ # untouched: the header that identifies the human is the host's decision, not ours
14
+ # (research R2, resolved against fast-mcp 1.6.0).
15
+ module ContextBuilder
16
+ # Checked in order. The first that answers names the client for attribution only.
17
+ AGENT_HEADERS = %w[X-MCP-Client X-Client-Name User-Agent].freeze
18
+
19
+ module_function
20
+
21
+ def attributes(tool)
22
+ headers = headers_for(tool)
23
+
24
+ {
25
+ agent: { id: agent_id(headers), name: headers["X-MCP-Client"] },
26
+ metadata: { headers: headers }
27
+ }
28
+ end
29
+
30
+ def headers_for(tool)
31
+ headers = tool.respond_to?(:headers) ? tool.headers : nil
32
+ headers.is_a?(Hash) ? headers : {}
33
+ end
34
+
35
+ # Attribution is not authorization: a client that names itself is recorded by that
36
+ # name, and one that does not is recorded as unknown rather than refused.
37
+ def agent_id(headers)
38
+ AGENT_HEADERS.each do |header|
39
+ value = headers[header] || headers[header.downcase]
40
+ return value.to_s unless value.nil? || value.to_s.strip.empty?
41
+ end
42
+
43
+ Context::UNKNOWN_AGENT_ID
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Integrations
5
+ # The fast-mcp bridge: the DSL on every tool, and the envelope around every call.
6
+ module FastMcp
7
+ # Routes every fast-mcp tool call through the envelope.
8
+ #
9
+ # Prepended to each tool *subclass* rather than to `FastMcp::Tool` itself: a
10
+ # prepended module precedes the class's own methods, but a module prepended to the
11
+ # parent still sits behind the subclass's `call`. Prepending at `inherited` time
12
+ # works even though `call` is defined afterwards, which is what makes this
13
+ # impossible to forget — there is no "remember to wrap your tool" step.
14
+ module ToolExtension
15
+ def call(**arguments)
16
+ attributes = ContextBuilder.attributes(self)
17
+
18
+ Reeve.invoke(
19
+ tool: self.class,
20
+ arguments: arguments,
21
+ agent: attributes[:agent],
22
+ metadata: attributes[:metadata]
23
+ ) { super(**arguments) }
24
+ end
25
+ end
26
+
27
+ # Installs the extension into every tool defined from here on.
28
+ module Inheritance
29
+ def inherited(subclass)
30
+ super
31
+ subclass.prepend(ToolExtension)
32
+ end
33
+ end
34
+
35
+ module_function
36
+
37
+ # Idempotent: requiring "reeve/fast_mcp" twice must not stack two envelopes around
38
+ # the same call.
39
+ def install!(tool_base = ::FastMcp::Tool)
40
+ tool_base.include(Reeve::Guard) unless tool_base.include?(Reeve::Guard)
41
+
42
+ unless tool_base.singleton_class.include?(Inheritance)
43
+ tool_base.singleton_class.prepend(Inheritance)
44
+ end
45
+
46
+ # Tools defined before this require still get the envelope.
47
+ tool_base.subclasses.each do |subclass|
48
+ subclass.prepend(ToolExtension) unless subclass.include?(ToolExtension)
49
+ end
50
+
51
+ tool_base
52
+ end
53
+ end
54
+ end
55
+ end