rails-ai-bridge 3.5.1 → 3.5.2

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: 86847cb1d8613336bb1538fdde0c64461dcc69d2423b48939629695f40c99688
4
- data.tar.gz: 65d9c1a4f11b3edc80a077e66c41e339ca845e4d10993d63cddef79be7ef9c5d
3
+ metadata.gz: 87f66a2c3254515e628ba04cac7fcda527bda86254df738f3384f28ccad5fcdf
4
+ data.tar.gz: 6bc9b66810b8c5bedd1526a3683b13530a46c1c5b61ba7db89288544d61bce20
5
5
  SHA512:
6
- metadata.gz: c6ae4378aa2f3321b0753fb7e111a4d6777bd4de63e5ade3bcf38047affac48ce86630490dcacd33610021f299833bc5d7dd24f9dc238614f60786d8fac42ea4
7
- data.tar.gz: 22caf59cc05827c7a5824f17c2dd1a5715b8ba62b70930ef300835bdbdc96506bdae1f2ec8590391272576c097363b45debb605f195e0e7c7b43c227f8a75289
6
+ metadata.gz: ae624883e2d08b61bd76d178b52d7067aefd611a1fc6e6f760b7f07361b7acbe877b2ce3f5984b2b6c9d0c74b7376c522cc8065fdf25a8cc8c78660a95583c18
7
+ data.tar.gz: 8cc300bb5f6e4d9669e8de9313eca2c4e5cd0c4a7ce336c2cec7bd5c69665df2580d3864536aef092cc22c16ca775909d26c40c372b5bc065577f567836dc149
data/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.5.2]
9
+
10
+ ### Security
11
+
12
+ - **HTTP MCP unauthenticated boot warning** (#60/#81) — the standalone HTTP MCP server now prints a one-time stderr warning when it boots in a non-production environment without an authentication strategy, making the default open behavior visible.
13
+ - **Pluggable / distributed rate limiting** (#69/#80) — `config.mcp.rate_limiter` accepts any object implementing `allow?(ip)` or `call(ip)`, enabling shared backends such as Redis or `Rails.cache`. A built-in `Mcp::CacheRateLimiter` is provided for multi-process Puma deployments.
14
+ - **Skill pack lockfile verification** (#65/#84) — `config/rails_ai_bridge/directory.lock` records the expected git commit SHA for every remote skill pack. `PackResolver` compares the cloned HEAD against the lockfile and fails closed on mismatch. Generate or update the lockfile with `rails ai:registry:lockfile`. Verification mode is configurable via `config.registry.lockfile_verification` (`:strict`, `:warn`, `:disabled`).
15
+ - **Security documentation** (#61/#62/#82/#83) — added distributed rate-limiting guidance, a stdio transport threat model, and operational hardening recommendations.
16
+
17
+ ### Added
18
+
19
+ - **CORS support for HTTP MCP** (#63/#76) — `config.mcp.cors_origins` controls `Access-Control-Allow-Origin` headers for the MCP HTTP endpoint; `['*']` or a list of exact origins is supported.
20
+ - **JSON output for MCP tools** (#68/#77) — `rails_get_routes` and `rails_get_model_details` now accept `format: 'json'` for programmatic clients.
21
+ - **`authorize` lambda logging** (#64/#75) — explicit denies and lambda exceptions on the HTTP MCP path are now logged and emitted through `Mcp::HttpStructuredLog`.
22
+ - **`bundler-audit` CI job** (#74/#78) — the GitHub Actions workflow now runs `bundle-audit update && bundle-audit check` to catch known vulnerable dependencies.
23
+ - **MCP tool result caching** (#71/#79) — opt-in TTL-based cache keyed by tool name + SHA256 fingerprint of arguments. Enable with `config.mcp.tool_result_cache_ttl` (default `0`).
24
+ - **ActiveSupport::Notifications hooks** (#72/#85) — emits `rails_ai_bridge.tool.call`, `rails_ai_bridge.tool.result_cache_hit/miss`, `rails_ai_bridge.auth.success/failure`, and `rails_ai_bridge.rate_limit.hit` events.
25
+ - **Rails 8.1+ introspection signals** (#73/#86) — `GemRegistry` now recognizes `mission_control-jobs`; `ConfigIntrospector` reports `queue_adapter` and `cable_adapter`; `AuthIntrospector` surfaces Rails 8 generator patterns (`authentication_concern`, `generates_token_for`, `normalizes`).
26
+
27
+ ### Changed
28
+
29
+ - **Summary-first defaults** (#70/#87) — `rails_get_schema` and `rails_get_model_details` now default to `detail: 'summary'` when listing, reducing the chance of oversized tool responses. Callers can still opt into `standard` or `full` and use filters for specific tables/models.
30
+
31
+ ### Tests
32
+
33
+ - **Total: 2157 examples, 0 failures, 94.34% line coverage**.
34
+
8
35
  ## [3.5.1]
9
36
 
10
37
  ### Security
data/SECURITY.md CHANGED
@@ -85,6 +85,7 @@ see **[docs/mcp-security.md](docs/mcp-security.md)**.
85
85
  ## Operational Security Guidance
86
86
 
87
87
  - Prefer **stdio** transport for local development and AI-assisted editing.
88
+ See [docs/mcp-security.md](docs/mcp-security.md) for the stdio threat model.
88
89
  - If you enable HTTP transport, keep it bound to `127.0.0.1` unless you add your
89
90
  own network isolation and authentication controls.
90
91
  - Do **not** expose `auto_mount` on public or shared production surfaces without an explicit threat model review.
data/docs/mcp-security.md CHANGED
@@ -10,10 +10,41 @@ A valid Bearer token (or JWT accepted by your decoder) grants access to read-onl
10
10
 
11
11
  By default, HTTP MCP allows anonymous access when no auth strategy is configured (backward compatible for local development). For servers reachable beyond localhost, set `config.mcp.require_http_auth = true` so unconfigured deployments return `401`, or configure `http_mcp_token`, `mcp_token_resolver`, or `mcp_jwt_decoder`. In production, also use `validate_http_mcp_server_in_production!` / `require_auth_in_production` as documented in the main README.
12
12
 
13
+ In non-production environments, the standalone HTTP MCP server prints a one-time stderr warning when it starts without authentication to make the default behavior visible.
14
+
13
15
  ## Rate limiting and proxies
14
16
 
15
17
  Built-in rate limiting keys off the Rack request IP. Behind reverse proxies, configure Rails `trusted_proxies` so `request.ip` reflects the real client; otherwise limits may apply to the wrong address or be bypassed.
16
18
 
19
+ ### Single-process deployments
20
+
21
+ The default in-memory limiter works for one Puma worker or a single-process server. Configure the ceiling and window via `config.mcp.rate_limit_max_requests` and `config.mcp.rate_limit_window_seconds`.
22
+
23
+ ### Multi-process or multi-host deployments
24
+
25
+ The default in-memory limiter is not shared across Puma workers or hosts. For distributed deployments, plug in `RailsAiBridge::Mcp::CacheRateLimiter`, which uses `Rails.cache` (Redis, Memcached, etc.) as the shared counter backend:
26
+
27
+ ```ruby
28
+ RailsAiBridge.configure do |config|
29
+ config.mcp.rate_limiter = RailsAiBridge::Mcp::CacheRateLimiter.new(
30
+ max_requests: 300,
31
+ window_seconds: 60,
32
+ cache: Rails.cache,
33
+ key_prefix: "rab:rl"
34
+ )
35
+ end
36
+ ```
37
+
38
+ Alternatively, use `Rack::Attack` in front of the MCP endpoint for centralized, proxy-aware throttling:
39
+
40
+ ```ruby
41
+ class Rack::Attack
42
+ throttle("mcp/ip", limit: 300, period: 60) do |request|
43
+ request.ip if request.path == "/mcp"
44
+ end
45
+ end
46
+ ```
47
+
17
48
  ## Optional authorization after auth
18
49
 
19
50
  `config.mcp.authorize` can return false to issue `403` for otherwise valid tokens (e.g. tenant or role checks).
@@ -22,6 +53,21 @@ Built-in rate limiting keys off the Rack request IP. Behind reverse proxies, con
22
53
 
23
54
  The stdio MCP server has no Bearer layer; anyone who can run the process can use the tools. Use isolated users or containers if multiple tenants share a host.
24
55
 
56
+ ### Threat model
57
+
58
+ | Assumption | Implication |
59
+ |------------|-------------|
60
+ | The host operating system controls who can execute `rails ai:serve`. | Anyone with shell access to the Rails app can invoke every `rails_*` MCP tool and read the returned context. |
61
+ | The stdio transport is local to the process. | Network attackers cannot reach stdio directly, but a compromised local account or shared development container bypasses this boundary. |
62
+ | AI clients run with the same privileges as the user who launched them. | A malicious or misconfigured client can exfiltrate schema, routes, source code, and any file the user can read. |
63
+
64
+ ### Recommendations
65
+
66
+ - Run MCP-enabled Rails processes under dedicated, least-privilege OS accounts.
67
+ - In shared development containers or CI runners, avoid mixing users who should not see each other's application context.
68
+ - Do not mount sensitive credentials files or SSH keys into containers that run the MCP server unless those tools genuinely need them.
69
+ - Prefer HTTP MCP with authentication when multiple users or services share a host and need access controls beyond the OS.
70
+
25
71
  ## See also
26
72
 
27
73
  - [SECURITY.md](../SECURITY.md) — reporting vulnerabilities and design summary
@@ -154,6 +154,41 @@ When `ref` is set, rails-ai-bridge runs `git checkout <ref>` after cloning or pu
154
154
 
155
155
  ---
156
156
 
157
+ ## Lockfile verification
158
+
159
+ A lockfile records the exact git commit SHA of every remote pack that was resolved the last time the registry was built. On subsequent loads, rails-ai-bridge compares the locked SHA against the actual HEAD of the cloned repository and fails closed on mismatch.
160
+
161
+ This protects against:
162
+
163
+ - A compromised pack repository pushing unexpected content
164
+ - Accidental drift between environments (development vs. production)
165
+ - Force-pushes or tag rewrites changing the content under a stable ref name
166
+
167
+ ### Generate the lockfile
168
+
169
+ ```bash
170
+ rails ai:registry:lockfile
171
+ ```
172
+
173
+ The file is written to `config/rails_ai_bridge/directory.lock` by default. Commit it alongside your manifest.
174
+
175
+ ### Configuration
176
+
177
+ ```ruby
178
+ RailsAiBridge.configure do |config|
179
+ config.registry.lockfile_path = "config/rails_ai_bridge/directory.lock"
180
+ config.registry.lockfile_verification = :strict # :strict (default), :warn, or :disabled
181
+ end
182
+ ```
183
+
184
+ - `:strict` — raise `ResolutionError` when the resolved commit does not match the lockfile.
185
+ - `:warn` — log a stderr warning and continue loading.
186
+ - `:disabled` — skip verification entirely.
187
+
188
+ When no lockfile exists, verification is silently skipped so existing apps keep working.
189
+
190
+ ---
191
+
157
192
  ## The `directory.json` format
158
193
 
159
194
  Each skill pack repository should have a `directory.json` at its root. Here is an annotated example:
@@ -344,6 +379,7 @@ initializer changes take effect immediately.
344
379
  | Stale skills after pack update | Resolver cache is warm or pull TTL has not elapsed | Run `rails ai:skills:clear_cache` to force a re-clone on the next build |
345
380
  | Pack is not re-fetched even after `resolver_ttl` expired | Pack was cloned recently — `git_pull_ttl` (24 h) is still fresh | Run `rails ai:skills:clear_cache` or set `git_pull_ttl: 0` temporarily |
346
381
  | `git checkout <ref>` failed | Invalid ref or detached HEAD issue | Verify the `ref` value matches a branch, tag, or SHA in the remote repo |
382
+ | `Lockfile mismatch for pack '…'` | Resolved pack commit differs from `directory.lock` | Run `rails ai:registry:lockfile` to update the lockfile after reviewing the pack changes |
347
383
  | Git operation hangs / server request times out | Slow or unreachable remote | Reduce `git_timeout` to fail faster; check network connectivity to the remote |
348
384
  | Warning: "Pack '…' depends on '…' which is not in the active pack set" | A loaded pack has an unsatisfied `depends_on` entry | Add the missing pack name to `always_loaded` or `skill_packs` in your manifest |
349
385
 
@@ -358,4 +394,5 @@ initializer changes take effect immediately.
358
394
  - **Timeout protection**: all git operations are bounded by `git_timeout` (default 30 s) so a slow remote cannot block the calling thread indefinitely.
359
395
  - **Cache key sanitization**: local cache directory names are derived from a sanitized source string plus a SHA256 hash suffix to prevent filesystem collisions.
360
396
  - **Stable local pack names**: local registry pack names use a SHA256 digest of the directory path, so reordering `local_registry_paths` cannot silently shift pack identities.
397
+ - **Lockfile verification**: a `directory.lock` records the expected commit SHA for each remote pack. The resolver fails closed when the cloned commit does not match, preventing a compromised or unexpectedly modified pack from injecting instructions.
361
398
  - **Local path trust**: local paths are used directly without git operations. The path traversal guard in the Resolver still applies to file reads within a local pack.
@@ -185,9 +185,19 @@ module RailsAiBridge
185
185
  # config.http_mcp_token = "generate-a-long-random-secret"
186
186
  # # ENV["RAILS_AI_BRIDGE_MCP_TOKEN"] takes precedence when set
187
187
  #
188
+ # Require authentication on every HTTP MCP request. When true, requests
189
+ # return 401 unless one of the auth mechanisms above is configured.
190
+ # Default is false for backward compatibility with local development.
191
+ # config.mcp.require_http_auth = true
192
+ #
188
193
  # Timing-safe token comparison is built in, but add rate limiting too
189
194
  # (e.g. Rack::Attack throttle on config.http_path) to prevent brute-force.
190
195
  #
196
+ # CORS for browser-based AI clients connecting over SSE.
197
+ # Default is nil (no CORS headers). Set to ['*'] to allow any origin,
198
+ # or to a list of exact origins such as ['https://app.example.com'].
199
+ # config.mcp.cors_origins = ['https://app.example.com']
200
+ #
191
201
  # config.auto_mount = false
192
202
  # config.allow_auto_mount_in_production = false
193
203
  # config.http_path = "/mcp"
@@ -68,6 +68,30 @@ module RailsAiBridge
68
68
  # @return [Boolean]
69
69
  attr_accessor :require_http_auth
70
70
 
71
+ # Allowed origins for CORS on the HTTP MCP endpoint.
72
+ # +nil+ or empty array disables CORS headers (default). Pass +['*']+ to allow any origin,
73
+ # or a list of exact origins such as +['https://app.example.com']+.
74
+ # @return [Array<String>, nil]
75
+ attr_accessor :cors_origins
76
+
77
+ # TTL in seconds for MCP tool result caching by argument fingerprint.
78
+ # +0+ or negative disables caching. Default +0+ to keep existing behavior.
79
+ # @return [Integer]
80
+ attr_accessor :tool_result_cache_ttl
81
+
82
+ # Optional custom rate limiter for MCP HTTP requests.
83
+ #
84
+ # When set, it takes precedence over the in-memory {Mcp::HttpRateLimiter}.
85
+ # The object must respond to +allow?(ip)+ (preferred) or +call(ip)+ and return a truthy/falsey value.
86
+ # This enables distributed backends such as Redis, Rails.cache, or an external service.
87
+ #
88
+ # @return [#allow?, #call, nil]
89
+ attr_accessor :rate_limiter
90
+
91
+ # Key prefix used by the optional {Mcp::CacheRateLimiter} distributed rate limiter.
92
+ # @return [String]
93
+ attr_accessor :rate_limiter_key_prefix
94
+
71
95
  def initialize
72
96
  @mode = :hybrid
73
97
  @security_profile = :balanced
@@ -77,6 +101,15 @@ module RailsAiBridge
77
101
  @authorize = nil
78
102
  @require_auth_in_production = false
79
103
  @require_http_auth = false
104
+ @cors_origins = nil
105
+ @tool_result_cache_ttl = 0
106
+ @rate_limiter = nil
107
+ @rate_limiter_key_prefix = 'rab:rl'
108
+ end
109
+
110
+ # @return [Boolean] whether tool call results should be cached
111
+ def tool_result_cache_enabled?
112
+ tool_result_cache_ttl.to_i.positive?
80
113
  end
81
114
 
82
115
  # Effective rate-limit ceiling for {HttpTransportApp} (+0+ means disabled).
@@ -35,6 +35,13 @@ module RailsAiBridge
35
35
  # Prevents a slow or unreachable remote from blocking the calling thread indefinitely.
36
36
  attr_reader :git_timeout
37
37
 
38
+ # @return [String, nil] path to the skill pack lockfile. nil disables lockfile verification.
39
+ attr_accessor :lockfile_path
40
+
41
+ # @return [Symbol] how to behave when the lockfile differs from the resolved pack:
42
+ # :strict (default) raises, :warn logs but proceeds, :disabled skips verification.
43
+ attr_accessor :lockfile_verification
44
+
38
45
  # Sets the git pull TTL.
39
46
  #
40
47
  # Coerces the value to a non-negative integer; raises +ArgumentError+ for
@@ -93,6 +100,8 @@ module RailsAiBridge
93
100
  @resolver_ttl = 1800
94
101
  @git_pull_ttl = 86_400
95
102
  @git_timeout = 30
103
+ @lockfile_path = 'config/rails_ai_bridge/directory.lock'
104
+ @lockfile_verification = :strict
96
105
  end
97
106
  end
98
107
  end
@@ -114,7 +114,16 @@ module RailsAiBridge
114
114
  :rate_limit_max_requests, :rate_limit_max_requests=,
115
115
  :rate_limit_window_seconds, :rate_limit_window_seconds=,
116
116
  :http_log_json, :http_log_json=,
117
- :require_http_auth, :require_http_auth=
117
+ :require_http_auth, :require_http_auth=,
118
+ :tool_result_cache_ttl, :tool_result_cache_ttl=,
119
+ :tool_result_cache_enabled?,
120
+ :rate_limiter, :rate_limiter=,
121
+ :rate_limiter_key_prefix, :rate_limiter_key_prefix=
122
+
123
+ # -- Config::Registry -------------------------------------------------------
124
+ def_delegators :@registry,
125
+ :lockfile_path, :lockfile_path=,
126
+ :lockfile_verification, :lockfile_verification=
118
127
 
119
128
  # -- Config::Rubydex --------------------------------------------------------
120
129
  def_delegators :@rubydex,
@@ -12,15 +12,17 @@ module RailsAiBridge
12
12
  mcp_cfg = RailsAiBridge.configuration.mcp
13
13
  max_reqs = mcp_cfg.effective_http_rate_limit_max_requests
14
14
  window_sec = mcp_cfg.effective_http_rate_limit_window_seconds
15
- limiter = if max_reqs.positive?
16
- Mcp::HttpRateLimiter.new(max_requests: max_reqs,
17
- window_seconds: window_sec)
18
- end
15
+ limiter = mcp_cfg.rate_limiter || build_default_rate_limiter(max_requests: max_reqs,
16
+ window_seconds: window_sec)
17
+ cors_origins = Array(mcp_cfg.cors_origins).reject(&:empty?)
19
18
 
20
19
  lambda do |env|
21
20
  return [404, { 'Content-Type' => 'application/json' }, ['{"error":"Not found"}']] unless [path, "#{path}/"].include?(env['PATH_INFO'])
22
21
 
23
22
  request = Rack::Request.new(env)
23
+ cors_headers = build_cors_headers(request.get_header('HTTP_ORIGIN'), cors_origins)
24
+
25
+ return [204, cors_headers, ['']] if request.request_method == 'OPTIONS' && cors_headers
24
26
 
25
27
  if mcp_cfg.require_http_auth && !Mcp::Authenticator.any_configured?
26
28
  Mcp::HttpStructuredLog.emit(request: request, event: :unauthorized, http_status: 401)
@@ -35,19 +37,23 @@ module RailsAiBridge
35
37
 
36
38
  authorize = RailsAiBridge.configuration.mcp.authorize
37
39
  if authorize
38
- authorized = begin
39
- authorize.call(auth_result.context, request)
40
+ begin
41
+ authorized = authorize.call(auth_result.context, request)
40
42
  rescue StandardError => error
41
- Rails.logger.error("rails_ai_bridge: authorize lambda raised #{error.class}: #{error.message}") if defined?(Rails)
42
- false
43
+ Rails.logger.error("rails_ai_bridge: authorize lambda raised #{error.class}: #{error.message}") if defined?(Rails) && Rails.logger
44
+ Mcp::HttpStructuredLog.emit(request: request, event: :forbidden, http_status: 403)
45
+ return [403, { 'Content-Type' => 'application/json' }, ['{"error":"Forbidden"}']]
43
46
  end
47
+
44
48
  unless authorized
49
+ Rails.logger.warn("rails_ai_bridge: authorize lambda denied access for #{request.ip} at #{request.path}") if defined?(Rails) && Rails.logger
45
50
  Mcp::HttpStructuredLog.emit(request: request, event: :forbidden, http_status: 403)
46
51
  return [403, { 'Content-Type' => 'application/json' }, ['{"error":"Forbidden"}']]
47
52
  end
48
53
  end
49
54
 
50
- if limiter && !limiter.allow?(request.ip)
55
+ if limiter && !rate_limiter_allow?(limiter, request)
56
+ Instrumentation.instrument('rate_limit.hit', ip: request.ip, path: request.path)
51
57
  Mcp::HttpStructuredLog.emit(request: request, event: :rate_limited, http_status: 429)
52
58
  return [429, { 'Content-Type' => 'application/json', 'Retry-After' => window_sec.to_s },
53
59
  ['{"error":"Too many requests"}']]
@@ -55,9 +61,51 @@ module RailsAiBridge
55
61
 
56
62
  response = transport.handle_request(request)
57
63
  Mcp::HttpStructuredLog.emit(request: request, event: :handled, http_status: response.first)
64
+ response[1].merge!(cors_headers) if cors_headers
58
65
  response
59
66
  end
60
67
  end
68
+
69
+ private
70
+
71
+ def build_default_rate_limiter(max_requests:, window_seconds:)
72
+ return nil unless max_requests.positive?
73
+
74
+ Mcp::HttpRateLimiter.new(max_requests: max_requests, window_seconds: window_seconds)
75
+ end
76
+
77
+ # Asks a configured rate limiter whether a request may proceed.
78
+ # Supports objects that expose +allow?(ip)+ (preferred) or +call(ip)+.
79
+ #
80
+ # @param limiter [#allow?, #call]
81
+ # @param request [Rack::Request]
82
+ # @return [Boolean]
83
+ def rate_limiter_allow?(limiter, request)
84
+ if limiter.respond_to?(:allow?)
85
+ limiter.allow?(request.ip)
86
+ elsif limiter.respond_to?(:call)
87
+ limiter.call(request.ip)
88
+ else
89
+ true
90
+ end
91
+ end
92
+
93
+ # Builds CORS response headers when the request origin is allowed.
94
+ #
95
+ # @param origin [String, nil] value of the Origin header
96
+ # @param cors_origins [Array<String>] configured allowed origins
97
+ # @return [Hash{String => String}, nil] headers to add, or +nil+ when CORS is disabled/unmatched
98
+ def build_cors_headers(origin, cors_origins)
99
+ return nil if cors_origins.empty?
100
+ return nil if origin.blank?
101
+ return nil unless cors_origins.include?('*') || cors_origins.include?(origin)
102
+
103
+ {
104
+ 'Access-Control-Allow-Origin' => origin,
105
+ 'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
106
+ 'Access-Control-Allow-Headers' => 'Authorization, Content-Type'
107
+ }
108
+ end
61
109
  end
62
110
  end
63
111
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ module RailsAiBridge
6
+ # Thin wrapper around ActiveSupport::Notifications for rails-ai-bridge events.
7
+ #
8
+ # Events are namespaced under +rails_ai_bridge+. If ActiveSupport::Notifications
9
+ # is not available, the helper yields without instrumentation so the code can be
10
+ # used outside of Rails without a hard dependency.
11
+ module Instrumentation
12
+ NAMESPACE = 'rails_ai_bridge'
13
+
14
+ # Emits an instrumentation event.
15
+ #
16
+ # @param event [String] event name suffix (e.g. +tool.call+)
17
+ # @param payload [Hash] extra payload attached to the event
18
+ # @yield optional block to wrap; the block result is returned
19
+ # @return [Object] the block result, or nil when no block is given
20
+ def self.instrument(event, payload = {})
21
+ return yield unless defined?(ActiveSupport::Notifications) && ActiveSupport::Notifications
22
+
23
+ ActiveSupport::Notifications.instrument("#{NAMESPACE}.#{event}", payload) do
24
+ yield if block_given?
25
+ end
26
+ end
27
+
28
+ # Wraps an MCP tool class so every call emits a +rails_ai_bridge.tool.call+
29
+ # event. Caching, when enabled, is handled by the inner wrapper so cache
30
+ # hit/miss events stay separate from the invocation event.
31
+ class InstrumentedTool < SimpleDelegator
32
+ def initialize(tool_class)
33
+ super
34
+ @tool_class = tool_class
35
+ end
36
+
37
+ # @param server_context [Object, nil]
38
+ # @param arguments [Hash]
39
+ # @return [MCP::Tool::Response]
40
+ def call(server_context: nil, **arguments)
41
+ Instrumentation.instrument('tool.call', tool_name: @tool_class.tool_name, arguments: arguments) do
42
+ @tool_class.call(server_context: server_context, **arguments)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -39,8 +39,8 @@ module RailsAiBridge
39
39
  devise_models = scan_models_for(/devise\s+(.+)$/)
40
40
  auth[:devise] = devise_models if devise_models.any?
41
41
 
42
- # Rails 8 built-in auth
43
- auth[:rails_auth] = true if model_file_exists?('session.rb') && model_file_exists?('current.rb')
42
+ # Rails 8 built-in auth generator patterns
43
+ auth[:rails_auth] = detect_rails_auth_details if model_file_exists?('session.rb') && model_file_exists?('current.rb')
44
44
 
45
45
  # has_secure_password
46
46
  secure_pw = scan_models_for(/has_secure_password/)
@@ -49,6 +49,20 @@ module RailsAiBridge
49
49
  auth
50
50
  end
51
51
 
52
+ def detect_rails_auth_details
53
+ details = {
54
+ authentication_concern: file_exists?('app/controllers/concerns/authentication.rb')
55
+ }
56
+
57
+ token_for = scan_models_for(/generates_token_for\s+:(\w+)/)
58
+ details[:token_for] = token_for.map { |entry| { model: entry[:model], tokens: entry[:matches] } } if token_for.any?
59
+
60
+ normalized = scan_models_for(/normalizes\s+:(\w+)/)
61
+ details[:normalized_attributes] = normalized.map { |entry| { model: entry[:model], attrs: entry[:matches] } } if normalized.any?
62
+
63
+ details
64
+ end
65
+
52
66
  def detect_authorization
53
67
  authz = {}
54
68
 
@@ -20,6 +20,8 @@ module RailsAiBridge
20
20
  result = {
21
21
  cache_store: detect_cache_store,
22
22
  session_store: detect_session_store,
23
+ queue_adapter: detect_queue_adapter,
24
+ cable_adapter: detect_cable_adapter,
23
25
  timezone: app.config.time_zone.to_s,
24
26
  middleware_stack: extract_middleware,
25
27
  initializers: extract_initializers,
@@ -54,6 +56,24 @@ module RailsAiBridge
54
56
  'unknown'
55
57
  end
56
58
 
59
+ def detect_queue_adapter
60
+ return 'unknown' unless app.config.respond_to?(:active_job)
61
+
62
+ adapter = app.config.active_job&.queue_adapter
63
+ adapter.is_a?(Symbol) ? adapter.to_s : adapter.class.name
64
+ rescue StandardError
65
+ 'unknown'
66
+ end
67
+
68
+ def detect_cable_adapter
69
+ return 'unknown' unless app.config.respond_to?(:action_cable)
70
+
71
+ adapter = app.config.action_cable&.adapter
72
+ adapter.is_a?(Symbol) ? adapter.to_s : adapter.class.name
73
+ rescue StandardError
74
+ 'unknown'
75
+ end
76
+
57
77
  def extract_middleware
58
78
  app.middleware.map { |m| m.name || m.klass.to_s }.uniq
59
79
  rescue StandardError
@@ -20,6 +20,7 @@ module RailsAiBridge
20
20
  'sidekiq' => { category: :jobs, note: 'Background jobs via Sidekiq. Check config/sidekiq.yml.' },
21
21
  'good_job' => { category: :jobs, note: 'Background jobs via GoodJob (Postgres-backed).' },
22
22
  'solid_queue' => { category: :jobs, note: 'Background jobs via SolidQueue (Rails 8 default).' },
23
+ 'mission_control-jobs' => { category: :jobs, note: 'Web UI for SolidQueue / Active Job monitoring.' },
23
24
  'delayed_job' => { category: :jobs, note: 'Background jobs via DelayedJob.' },
24
25
  'resque' => { category: :jobs, note: 'Background jobs via Resque (Redis-backed).' },
25
26
  'sneakers' => { category: :jobs, note: 'Background jobs via Sneakers (RabbitMQ).' },
@@ -51,7 +51,13 @@ module RailsAiBridge
51
51
  strategy = resolve_strategy
52
52
  return AuthResult.ok(nil) if strategy.nil?
53
53
 
54
- strategy.authenticate(request)
54
+ strategy.authenticate(request).tap do |result|
55
+ if result.success?
56
+ Instrumentation.instrument('auth.success', strategy: strategy_name(strategy), ip: request.ip)
57
+ else
58
+ Instrumentation.instrument('auth.failure', strategy: strategy_name(strategy), ip: request.ip, error: result.error)
59
+ end
60
+ end
55
61
  end
56
62
 
57
63
  # Returns +true+ when any auth mechanism is configured — static token,
@@ -80,6 +86,14 @@ module RailsAiBridge
80
86
  ]
81
87
  end
82
88
 
89
+ # Human-readable strategy name for observability payloads.
90
+ #
91
+ # @param strategy [Auth::BaseStrategy]
92
+ # @return [String]
93
+ def strategy_name(strategy)
94
+ strategy.class.name&.split('::')&.last || 'unknown'
95
+ end
96
+
83
97
  private
84
98
 
85
99
  # Selects the appropriate auth strategy from the current configuration.
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiBridge
4
+ module Mcp
5
+ # Distributed, fixed-window rate limiter backed by +Rails.cache+ (or any
6
+ # +ActiveSupport::Cache::Store+). Use it when the MCP HTTP endpoint runs
7
+ # behind multiple processes and the default in-memory limiter is insufficient.
8
+ #
9
+ # The limiter relies on the cache's +increment+ operation when available and
10
+ # falls back to read/write for stores that do not support atomic increments.
11
+ class CacheRateLimiter
12
+ # @param max_requests [Integer] allowed hits per window per client IP
13
+ # @param window_seconds [Integer] TTL for each client's counter
14
+ # @param cache [Object, nil] +ActiveSupport::Cache::Store+; defaults to +Rails.cache+
15
+ # @param key_prefix [String] prefix for cache keys
16
+ def initialize(max_requests:, window_seconds:, cache: nil, key_prefix: 'rab:rl')
17
+ @max_requests = max_requests
18
+ @window_seconds = window_seconds.to_i
19
+ @cache = cache
20
+ @key_prefix = key_prefix
21
+ end
22
+
23
+ # @param ip [String, nil] client identifier (blank values share the +unknown+ bucket)
24
+ # @return [Boolean] +true+ if the request may proceed
25
+ def allow?(ip)
26
+ store = cache_store
27
+ return true unless store
28
+
29
+ key = cache_key(ip)
30
+ count = increment(store, key)
31
+ count <= @max_requests
32
+ end
33
+
34
+ private
35
+
36
+ def cache_store
37
+ @cache || (defined?(Rails) && Rails.cache)
38
+ end
39
+
40
+ def cache_key(ip)
41
+ client = ip.to_s.presence || 'unknown'
42
+ "#{@key_prefix}:#{client}"
43
+ end
44
+
45
+ def increment(store, key)
46
+ if store.respond_to?(:increment)
47
+ store.increment(key, 1, expires_in: @window_seconds) || fallback_increment(store, key)
48
+ else
49
+ fallback_increment(store, key)
50
+ end
51
+ rescue StandardError
52
+ fallback_increment(store, key)
53
+ end
54
+
55
+ def fallback_increment(store, key)
56
+ if store.respond_to?(:increment)
57
+ store.write(key, 0, expires_in: @window_seconds, unless_exist: true)
58
+ store.increment(key, 1) || (store.read(key).to_i + 1)
59
+ else
60
+ store.write(key, store.read(key).to_i + 1, expires_in: @window_seconds)
61
+ store.read(key).to_i
62
+ end
63
+ rescue StandardError
64
+ 1
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module RailsAiBridge
7
+ module Registry
8
+ # Records and verifies expected git commit SHAs for skill packs.
9
+ #
10
+ # The lockfile is a JSON document keyed by pack name. Each entry stores the
11
+ # pack source, ref, and the resolved commit SHA. When verification is enabled,
12
+ # {PackResolver} compares the locked SHA against the actual HEAD of the cloned
13
+ # repository and fails closed on mismatch.
14
+ class Lockfile
15
+ # Represents one lockfile entry.
16
+ Entry = Data.define(:pack_name, :source, :ref, :commit_sha)
17
+
18
+ class << self
19
+ # Loads a lockfile from disk.
20
+ #
21
+ # @param path [String, nil] path to the lockfile; nil means no lockfile
22
+ # @return [Lockfile] empty lockfile when path is nil or the file does not exist
23
+ def load(path)
24
+ return new({}) if path.nil? || !File.exist?(path)
25
+
26
+ raw = File.read(path)
27
+ data = JSON.parse(raw)
28
+ entries = data.transform_values do |entry|
29
+ Entry.new(
30
+ pack_name: entry['pack_name'],
31
+ source: entry['source'],
32
+ ref: entry['ref'],
33
+ commit_sha: entry['commit_sha']
34
+ )
35
+ end
36
+ new(entries)
37
+ rescue JSON::ParserError => error
38
+ raise ArgumentError, "Invalid lockfile JSON at #{path}: #{error.message}"
39
+ end
40
+
41
+ # Generates lockfile entries by resolving every pack in the manifest.
42
+ #
43
+ # @param manifest [RegistryManifest]
44
+ # @param source_resolver [SkillSourceResolver]
45
+ # @return [Hash{String => Entry}]
46
+ def generate(manifest, source_resolver)
47
+ manifest.packs.each_with_object({}) do |(name, pack_def), entries|
48
+ base_path = source_resolver.resolve(pack_def.source, ref: pack_def.ref)
49
+ commit_sha = source_resolver.current_commit(base_path)
50
+ entries[name] = Entry.new(
51
+ pack_name: name,
52
+ source: pack_def.source,
53
+ ref: pack_def.ref,
54
+ commit_sha: commit_sha
55
+ )
56
+ end
57
+ end
58
+
59
+ # Writes a lockfile to disk for the given manifest.
60
+ #
61
+ # @param path [String]
62
+ # @param manifest [RegistryManifest]
63
+ # @param source_resolver [SkillSourceResolver]
64
+ # @return [void]
65
+ def write(path, manifest, source_resolver)
66
+ entries = generate(manifest, source_resolver)
67
+ new(entries).write(path)
68
+ end
69
+ end
70
+
71
+ # @param entries [Hash{String => Entry}] map of pack name to lockfile entry
72
+ def initialize(entries)
73
+ @entries = entries.freeze
74
+ end
75
+
76
+ delegate :any?, to: :@entries
77
+
78
+ # @param pack_name [String]
79
+ # @return [Entry, nil]
80
+ def entry(pack_name)
81
+ @entries[pack_name]
82
+ end
83
+
84
+ # Serializes the lockfile to JSON.
85
+ #
86
+ # @return [String]
87
+ def to_json(*)
88
+ data = @entries.transform_values do |entry|
89
+ {
90
+ 'pack_name' => entry.pack_name,
91
+ 'source' => entry.source,
92
+ 'ref' => entry.ref,
93
+ 'commit_sha' => entry.commit_sha
94
+ }
95
+ end
96
+ JSON.pretty_generate(data)
97
+ end
98
+
99
+ # Writes the lockfile to disk.
100
+ #
101
+ # @param path [String]
102
+ # @return [void]
103
+ def write(path)
104
+ FileUtils.mkdir_p(File.dirname(path))
105
+ File.write(path, "#{to_json}\n")
106
+ end
107
+ end
108
+ end
109
+ end
@@ -22,15 +22,18 @@ module RailsAiBridge
22
22
  #
23
23
  # @param source_resolver [SkillSourceResolver] resolver for remote git sources
24
24
  # @param pack_detector [Object] optional pack detector for testing (defaults to PackDetector)
25
- def initialize(source_resolver, pack_detector = PackDetector)
25
+ # @param lockfile [Lockfile, nil] optional lockfile for pack verification
26
+ def initialize(source_resolver, pack_detector = PackDetector, lockfile = nil)
26
27
  @source_resolver = source_resolver
27
28
  @pack_detector = pack_detector
29
+ @lockfile = lockfile || default_lockfile
28
30
  end
29
31
 
30
32
  # Resolves active packs from the manifest and custom configuration.
31
33
  #
32
34
  # This will automatically resolve remote repositories if needed, load the tile
33
- # manifest configurations, and merge local directory overrides.
35
+ # manifest configurations, merge local directory overrides, and verify pack
36
+ # commit SHAs against the configured lockfile.
34
37
  #
35
38
  # @param manifest [RegistryManifest] the registry manifest containing pack definitions
36
39
  # @param explicit_packs [Array<String>, nil] optional list of pack names to load
@@ -47,6 +50,38 @@ module RailsAiBridge
47
50
 
48
51
  private
49
52
 
53
+ def default_lockfile
54
+ Lockfile.load(RailsAiBridge.configuration.registry.lockfile_path)
55
+ end
56
+
57
+ # Verifies the resolved pack commit SHA against the lockfile when one exists.
58
+ #
59
+ # Behavior is controlled by +config.registry.lockfile_verification+:
60
+ # :strict — raise on mismatch (default)
61
+ # :warn — log a warning and continue
62
+ # :disabled — skip verification entirely
63
+ #
64
+ # @param name [String] pack name
65
+ # @param base_path [String] local path to the resolved pack
66
+ # @return [void]
67
+ def verify_pack!(name, base_path)
68
+ return unless @lockfile&.any?
69
+
70
+ mode = RailsAiBridge.configuration.registry.lockfile_verification
71
+ return if mode == :disabled
72
+
73
+ entry = @lockfile.entry(name)
74
+ return unless entry
75
+
76
+ actual = @source_resolver.current_commit(base_path)
77
+ return if actual == entry.commit_sha
78
+
79
+ message = "Lockfile mismatch for pack '#{name}': expected #{entry.commit_sha}, got #{actual}"
80
+ return warn "[rails-ai-bridge] #{message}" if mode == :warn
81
+
82
+ raise SkillSourceResolver::ResolutionError, "#{message}. Run `rails ai:registry:lockfile` to update the lockfile."
83
+ end
84
+
50
85
  # Gathers the set of pack names that should be loaded.
51
86
  #
52
87
  # Combines always_loaded packs with either explicit_packs or auto-detected framework packs.
@@ -105,6 +140,8 @@ module RailsAiBridge
105
140
  raise SkillSourceResolver::ResolutionError, "Pack '#{name}' not defined in registry manifest" unless pack_def
106
141
 
107
142
  base_path = @source_resolver.resolve(pack_def.source, ref: pack_def.ref)
143
+ verify_pack!(name, base_path)
144
+
108
145
  tile_path = File.join(base_path, pack_def.tile)
109
146
 
110
147
  unless File.exist?(tile_path)
@@ -39,6 +39,15 @@ module RailsAiBridge
39
39
  def checkout_ref(_path, _ref)
40
40
  raise NotImplementedError
41
41
  end
42
+
43
+ # Returns the current HEAD commit SHA for the repository at +_path+.
44
+ #
45
+ # @param _path [String] path to the local repository
46
+ # @raise [StandardError] if the commit cannot be determined
47
+ # @return [String] 40-character hex commit SHA
48
+ def current_commit(_path)
49
+ raise NotImplementedError
50
+ end
42
51
  end
43
52
 
44
53
  # Default implementation of {GitRunner} using Open3 to spawn git subprocesses.
@@ -85,6 +94,20 @@ module RailsAiBridge
85
94
  end
86
95
  end
87
96
 
97
+ # Returns the current HEAD commit SHA for the repository at +path+.
98
+ #
99
+ # @param path [String] path to the local repository
100
+ # @raise [RuntimeError] if the git command fails or returns non-zero
101
+ # @return [String] 40-character hex commit SHA
102
+ def current_commit(path)
103
+ with_timeout('git rev-parse') do
104
+ stdout, stderr, status = Open3.capture3('git', 'rev-parse', 'HEAD', chdir: path) # nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec
105
+ fail_with_sanitized_error!('git rev-parse', stderr) unless status.success?
106
+
107
+ stdout.strip
108
+ end
109
+ end
110
+
88
111
  # Checks out a specific git ref inside a local repository directory.
89
112
  #
90
113
  # @param path [String] path to the local repository
@@ -232,6 +255,17 @@ module RailsAiBridge
232
255
  cache_path
233
256
  end
234
257
 
258
+ # Returns the current HEAD commit SHA for the repository at +path+.
259
+ #
260
+ # @param path [String] path to the local repository
261
+ # @return [String] 40-character hex commit SHA
262
+ # @raise [ResolutionError] if the commit cannot be read
263
+ def current_commit(path)
264
+ @git_runner.current_commit(path)
265
+ rescue StandardError => error
266
+ raise ResolutionError, "failed to read current commit for pack at #{path}: #{error.message}"
267
+ end
268
+
235
269
  private
236
270
 
237
271
  # Returns true when no successful pull has been recorded for +cache_path+ within
@@ -18,6 +18,7 @@ require_relative 'registry/resolver'
18
18
  require_relative 'registry/source_parser'
19
19
  require_relative 'registry/skill_source_resolver'
20
20
  require_relative 'registry/pack_resolver'
21
+ require_relative 'registry/lockfile'
21
22
  require_relative 'registry/resolver_cache'
22
23
  require_relative 'registry/rake_presenter'
23
24
 
@@ -81,6 +82,23 @@ module RailsAiBridge
81
82
  resolver_cache.fetch(config) { build_resolver_uncached(config) }
82
83
  end
83
84
 
85
+ # Writes a lockfile containing the current HEAD commit SHA for every pack in
86
+ # the registry manifest.
87
+ #
88
+ # @param config [RailsAiBridge::Config::Registry] registry configuration
89
+ # @return [void]
90
+ # @raise [ArgumentError] when the manifest file does not exist
91
+ def self.write_lockfile(config = RailsAiBridge.configuration.registry)
92
+ manifest_path = config.registry_manifest_path
93
+ raise ArgumentError, "Registry manifest not found at #{manifest_path}" unless File.exist?(manifest_path)
94
+
95
+ manifest = RegistryManifest.from_file(manifest_path)
96
+ git_runner = DefaultGitRunner.new(timeout: config.git_timeout)
97
+ source_resolver = SkillSourceResolver.new(config.skill_cache_dir, git_runner, pull_ttl: config.git_pull_ttl)
98
+
99
+ Lockfile.write(config.lockfile_path, manifest, source_resolver)
100
+ end
101
+
84
102
  # @api private
85
103
  def self.build_resolver_uncached(config)
86
104
  manifest_path = config.registry_manifest_path
@@ -22,6 +22,11 @@ module RailsAiBridge
22
22
  # Error message template for unknown transport types
23
23
  UNKNOWN_TRANSPORT_ERROR = 'Unknown transport: %s. Use :stdio, :http, or :streamable_http'
24
24
 
25
+ # Warning printed when HTTP MCP starts without authentication in non-production environments.
26
+ HTTP_AUTH_WARNING = '[rails-ai-bridge] WARNING: HTTP MCP is running without authentication. ' \
27
+ 'Set config.mcp.require_http_auth = true or configure http_mcp_token, ' \
28
+ 'mcp_token_resolver, or mcp_jwt_decoder before exposing this endpoint.'
29
+
25
30
  # Built-in MCP tools that are always available
26
31
  # These tools provide Rails application introspection capabilities
27
32
  TOOLS = [
@@ -50,9 +55,14 @@ module RailsAiBridge
50
55
  end
51
56
 
52
57
  # Returns all available tool classes including additional configured tools.
53
- # @return [Array<Class>] list of tool classes
58
+ # When tool result caching is enabled, built-in and additional tools are wrapped
59
+ # so identical argument fingerprints reuse cached +MCP::Tool::Response+ objects.
60
+ #
61
+ # @return [Array<Class, ToolResultCache::CachedTool>] list of tool classes
54
62
  def tool_classes
55
- TOOLS + RailsAiBridge.configuration.additional_tools
63
+ (TOOLS + RailsAiBridge.configuration.additional_tools).map do |tool_class|
64
+ Instrumentation::InstrumentedTool.new(ToolResultCache.maybe_wrap(tool_class))
65
+ end
56
66
  end
57
67
 
58
68
  # Build and return the configured MCP::Server instance.
@@ -114,6 +124,7 @@ module RailsAiBridge
114
124
  transport = create_http_transport(server)
115
125
  rack_app = build_rack_app(transport, config.http_path)
116
126
 
127
+ warn_if_http_mcp_unauthenticated
117
128
  log_http_startup(config)
118
129
  run_rack_server(rack_app, config)
119
130
  end
@@ -153,6 +164,18 @@ module RailsAiBridge
153
164
  warn TOOLS_LIST_MESSAGE % tool_classes.map(&:tool_name).join(', ')
154
165
  end
155
166
 
167
+ # Emits a one-time stderr warning when HTTP MCP starts without authentication
168
+ # in non-production environments. Production deployments either raise during
169
+ # validation or are assumed to be deliberately configured.
170
+ #
171
+ # @return [void]
172
+ def warn_if_http_mcp_unauthenticated
173
+ return if defined?(Rails) && Rails.env.production?
174
+ return if Mcp::Authenticator.any_configured?
175
+
176
+ warn HTTP_AUTH_WARNING
177
+ end
178
+
156
179
  # Runs the Rack server with fallback for older Rack versions.
157
180
  # @param rack_app [Object] the Rack application
158
181
  # @param config [RailsAiBridge::Configuration] bridge configuration
@@ -279,6 +279,19 @@ namespace :ai do
279
279
  end
280
280
  end
281
281
 
282
+ namespace :ai do
283
+ namespace :registry do
284
+ desc 'Generate or update the skill pack lockfile (config/rails_ai_bridge/directory.lock)'
285
+ task lockfile: :environment do
286
+ require 'rails_ai_bridge'
287
+
288
+ config = RailsAiBridge.configuration.registry
289
+ RailsAiBridge::Registry.write_lockfile(config)
290
+ puts "📝 Wrote skill pack lockfile to #{config.lockfile_path}"
291
+ end
292
+ end
293
+ end
294
+
282
295
  namespace :ai do
283
296
  namespace :skills do
284
297
  desc 'List all available skills from configured skill packs'
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+ require 'digest'
5
+ require 'json'
6
+ require 'mcp'
7
+
8
+ module RailsAiBridge
9
+ # Caches MCP tool call results keyed by a stable fingerprint of the arguments.
10
+ #
11
+ # The cache is in-memory, per-process, and TTL-based. It ignores +server_context+
12
+ # when fingerprinting so identical client requests share the same cached response.
13
+ #
14
+ # Enable by setting +config.mcp.tool_result_cache_ttl+ to a positive number of seconds.
15
+ class ToolResultCache
16
+ @cache = {}
17
+ @mutex = Mutex.new
18
+
19
+ # Wraps a tool class so that calls are routed through the cache.
20
+ class CachedTool < SimpleDelegator
21
+ def initialize(tool_class)
22
+ super
23
+ @tool_class = tool_class
24
+ @server_context_param = detect_server_context_param(tool_class.method(:call))
25
+ end
26
+
27
+ # @param server_context [Object, nil] passed by the MCP transport (ignored for cache keys)
28
+ # @param arguments [Hash] tool arguments from the client
29
+ # @return [MCP::Tool::Response]
30
+ def call(server_context: nil, **arguments)
31
+ ToolResultCache.fetch_response(@tool_class.tool_name, arguments) do
32
+ invoke_tool(arguments, server_context)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def invoke_tool(arguments, server_context)
39
+ case @server_context_param
40
+ when :server_context
41
+ @tool_class.call(**arguments, server_context: server_context)
42
+ when :_server_context
43
+ @tool_class.call(**arguments, _server_context: server_context)
44
+ else
45
+ @tool_class.call(**arguments)
46
+ end
47
+ end
48
+
49
+ def detect_server_context_param(method_object)
50
+ parameters = method_object.parameters
51
+ keyish = %i[key keyreq].freeze
52
+ return :server_context if parameters.any? { |type, name| name == :server_context && keyish.include?(type) }
53
+ return :_server_context if parameters.any? { |type, name| name == :_server_context && keyish.include?(type) }
54
+ return :server_context if parameters.any? { |type, _| type == :keyrest }
55
+
56
+ nil
57
+ end
58
+ end
59
+
60
+ class << self
61
+ # Wraps a tool class if caching is enabled; otherwise returns the class unchanged.
62
+ #
63
+ # @param tool_class [Class] an +MCP::Tool+ subclass
64
+ # @return [Class, CachedTool]
65
+ def maybe_wrap(tool_class)
66
+ enabled? ? wrap(tool_class) : tool_class
67
+ end
68
+
69
+ # Wraps a tool class so every call goes through the cache.
70
+ #
71
+ # @param tool_class [Class]
72
+ # @return [CachedTool]
73
+ def wrap(tool_class)
74
+ CachedTool.new(tool_class)
75
+ end
76
+
77
+ # Fetches a cached response or yields to compute it.
78
+ #
79
+ # @param tool_name [String]
80
+ # @param arguments [Hash]
81
+ # @yieldreturn [MCP::Tool::Response]
82
+ # @return [MCP::Tool::Response]
83
+ def fetch_response(tool_name, arguments)
84
+ return yield unless enabled?
85
+
86
+ key = cache_key(tool_name, arguments)
87
+ fingerprint = key.split(':', 2).last
88
+
89
+ mutex.synchronize do
90
+ entry = cache[key]
91
+ if entry && ttl_valid?(entry)
92
+ Instrumentation.instrument('tool.result_cache_hit', tool_name: tool_name, fingerprint: fingerprint)
93
+ return entry[:response]
94
+ end
95
+ end
96
+
97
+ Instrumentation.instrument('tool.result_cache_miss', tool_name: tool_name, fingerprint: fingerprint) do
98
+ response = yield
99
+ mutex.synchronize { cache[key] = { response: response, fetched_at: monotonic_now } }
100
+ response
101
+ end
102
+ end
103
+
104
+ # Clears all cached tool results.
105
+ #
106
+ # @return [void]
107
+ def reset!
108
+ mutex.synchronize { @cache = {} }
109
+ end
110
+
111
+ # @return [Boolean] whether tool result caching is enabled
112
+ def enabled?
113
+ RailsAiBridge.configuration.mcp.tool_result_cache_ttl.to_i.positive?
114
+ end
115
+
116
+ private
117
+
118
+ attr_reader :cache, :mutex
119
+
120
+ def cache_key(tool_name, arguments)
121
+ "#{tool_name}:#{argument_fingerprint(arguments)}"
122
+ end
123
+
124
+ def argument_fingerprint(arguments)
125
+ normalized = arguments.transform_keys(&:to_s).sort.to_h
126
+ Digest::SHA256.hexdigest(JSON.generate(normalized))
127
+ end
128
+
129
+ def ttl_valid?(entry)
130
+ (monotonic_now - entry[:fetched_at]) < RailsAiBridge.configuration.mcp.tool_result_cache_ttl.to_i
131
+ end
132
+
133
+ def monotonic_now
134
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
135
+ end
136
+ end
137
+ end
138
+ end
@@ -19,8 +19,13 @@ module RailsAiBridge
19
19
  detail: {
20
20
  type: 'string',
21
21
  enum: %w[summary standard full],
22
- description: 'Detail level for model listing. summary: names only. standard: names + association/validation counts (default). full: names + full association list.
22
+ description: 'Detail level for model listing. summary: names only (default). standard: names + association/validation counts. full: names + full association list.
23
23
  Ignored when specific model is given (always returns full).'
24
+ },
25
+ format: {
26
+ type: 'string',
27
+ enum: %w[json markdown],
28
+ description: 'Output format. Default: markdown.'
24
29
  }
25
30
  }
26
31
  )
@@ -37,13 +42,13 @@ module RailsAiBridge
37
42
  # @param detail [String] +summary+, +standard+, or +full+ when listing (ignored when +model+ is set)
38
43
  # @param _server_context [Object, nil] reserved for MCP transport metadata (unused)
39
44
  # @return [MCP::Tool::Response] Markdown body or an error string wrapped for the MCP client
40
- def self.call(model: nil, detail: 'standard', _server_context: nil)
45
+ def self.call(model: nil, detail: 'summary', format: 'markdown', _server_context: nil)
41
46
  models = cached_section(:models)
42
47
  return text_response('Model introspection not available. Add :models to introspectors.') unless models
43
48
  return text_response("Model introspection failed: #{models[:error]}") if models[:error]
44
49
 
45
50
  non_ar = cached_section(:non_ar_models)
46
- formatter = ResponseFormatter.new(models, model: model, detail: detail, non_ar_models: non_ar)
51
+ formatter = ResponseFormatter.new(models, model: model, detail: detail, format: format, non_ar_models: non_ar)
47
52
  return text_response(formatter.model_not_found_message) if formatter.model_not_found?
48
53
  return text_response(formatter.model_error_message) if formatter.model_error?
49
54
 
@@ -57,10 +62,11 @@ module RailsAiBridge
57
62
  # @param model [String, nil] requested single-model name
58
63
  # @param detail [String] list detail level when +model+ is +nil+
59
64
  # @param non_ar_models [Hash, nil] +:non_ar_models+ introspection section (may be +nil+ if disabled)
60
- def initialize(models, model:, detail:, non_ar_models: nil)
65
+ def initialize(models, model:, detail:, format:, non_ar_models: nil)
61
66
  @models = models
62
67
  @model = model
63
68
  @detail = detail
69
+ @format = format
64
70
  @non_ar_models = non_ar_models
65
71
  end
66
72
 
@@ -89,8 +95,10 @@ module RailsAiBridge
89
95
  "Error inspecting #{model_key}: #{model_data[:error]}"
90
96
  end
91
97
 
92
- # @return [String] Markdown for one model, POJO stub, or full listing
98
+ # @return [String] Markdown/JSON for one model, POJO stub, or full listing
93
99
  def format
100
+ return json_payload if @format == 'json'
101
+
94
102
  if @model
95
103
  return pojo_detail_markdown if pojo_entry && !model_data
96
104
 
@@ -107,6 +115,16 @@ module RailsAiBridge
107
115
 
108
116
  private
109
117
 
118
+ def json_payload
119
+ if @model
120
+ return pojo_entry.to_h.to_json if pojo_entry && !model_data
121
+
122
+ model_data.to_json
123
+ else
124
+ @models.to_json
125
+ end
126
+ end
127
+
110
128
  def pojo_entry
111
129
  return @pojo_entry if defined?(@pojo_entry)
112
130
 
@@ -26,6 +26,11 @@ module RailsAiBridge
26
26
  offset: {
27
27
  type: 'integer',
28
28
  description: 'Skip routes for pagination. Default: 0.'
29
+ },
30
+ format: {
31
+ type: 'string',
32
+ enum: %w[json markdown],
33
+ description: 'Output format. Default: markdown.'
29
34
  }
30
35
  }
31
36
  )
@@ -38,12 +43,13 @@ module RailsAiBridge
38
43
  # @param offset [Integer] pagination offset
39
44
  # @param _server_context [Object, nil] reserved for MCP transport metadata (unused)
40
45
  # @return [MCP::Tool::Response] markdown routes output or an error message
41
- def self.call(controller: nil, detail: 'standard', limit: nil, offset: 0, _server_context: nil)
46
+ def self.call(controller: nil, detail: 'standard', limit: nil, offset: 0, format: 'markdown', _server_context: nil)
42
47
  routes = cached_section(:routes)
43
48
  return text_response('Route introspection not available. Add :routes to introspectors.') unless routes
44
49
  return text_response("Route introspection failed: #{routes[:error]}") if routes[:error]
45
50
 
46
- formatter = ResponseFormatter.new(routes, controller: controller, detail: detail, limit: limit, offset: offset)
51
+ formatter = ResponseFormatter.new(routes, controller: controller, detail: detail, limit: limit, offset: offset,
52
+ format: format)
47
53
  return text_response(formatter.filter_error_message) if formatter.filter_error?
48
54
 
49
55
  text_response(formatter.format)
@@ -52,12 +58,13 @@ module RailsAiBridge
52
58
  # @private
53
59
  # Formats +:routes+ introspection for {GetRoutes}.
54
60
  class ResponseFormatter
55
- def initialize(routes, controller:, detail:, limit:, offset:)
61
+ def initialize(routes, controller:, detail:, limit:, offset:, format:)
56
62
  @routes = routes
57
63
  @controller = controller
58
64
  @detail = detail
59
65
  @limit = limit
60
66
  @offset = [offset.to_i, 0].max
67
+ @format = format
61
68
  @by_controller = filter_by_controller
62
69
  end
63
70
 
@@ -70,6 +77,8 @@ module RailsAiBridge
70
77
  end
71
78
 
72
79
  def format
80
+ return json_payload if @format == 'json'
81
+
73
82
  case @detail
74
83
  when 'summary' then format_summary
75
84
  when 'standard' then format_standard
@@ -80,6 +89,14 @@ module RailsAiBridge
80
89
 
81
90
  private
82
91
 
92
+ def json_payload
93
+ {
94
+ total: route_count,
95
+ offset: @offset,
96
+ controllers: @by_controller.sort.to_h
97
+ }.to_json
98
+ end
99
+
83
100
  def filter_by_controller
84
101
  return @routes[:by_controller] || {} unless @controller
85
102
 
@@ -17,7 +17,7 @@ module RailsAiBridge
17
17
  detail: {
18
18
  type: 'string',
19
19
  enum: %w[summary standard full],
20
- description: 'Detail level. summary: table names + column counts. standard: table names + column names/types (default). ' \
20
+ description: 'Detail level. summary: table names + column counts (default). standard: table names + column names/types. ' \
21
21
  'full: everything including indexes, FKs, comments.'
22
22
  },
23
23
  limit: {
@@ -50,7 +50,7 @@ module RailsAiBridge
50
50
  # @param format [String] +markdown+ or +json+
51
51
  # @param _server_context [Object, nil] reserved for MCP transport metadata (unused)
52
52
  # @return [MCP::Tool::Response] schema markdown/JSON or an error message
53
- def self.call(table: nil, detail: 'standard', limit: nil, offset: 0, format: 'markdown', _server_context: nil)
53
+ def self.call(table: nil, detail: 'summary', limit: nil, offset: 0, format: 'markdown', _server_context: nil)
54
54
  schema = cached_section(:schema)
55
55
  return text_response('Schema introspection not available. Add :schema to introspectors.') unless schema
56
56
  return text_response("Schema introspection not available: #{schema[:error]}") if schema[:error]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiBridge
4
- VERSION = '3.5.1'
4
+ VERSION = '3.5.2'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-bridge
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.5.1
4
+ version: 3.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Marin
@@ -183,6 +183,7 @@ files:
183
183
  - lib/rails_ai_bridge/fingerprinter/cached_snapshot.rb
184
184
  - lib/rails_ai_bridge/freshness_header.rb
185
185
  - lib/rails_ai_bridge/http_transport_app.rb
186
+ - lib/rails_ai_bridge/instrumentation.rb
186
187
  - lib/rails_ai_bridge/introspector.rb
187
188
  - lib/rails_ai_bridge/introspector/parallel_runner.rb
188
189
  - lib/rails_ai_bridge/introspector/timed_runner.rb
@@ -229,6 +230,7 @@ files:
229
230
  - lib/rails_ai_bridge/mcp/auth/strategies/jwt.rb
230
231
  - lib/rails_ai_bridge/mcp/auth_result.rb
231
232
  - lib/rails_ai_bridge/mcp/authenticator.rb
233
+ - lib/rails_ai_bridge/mcp/cache_rate_limiter.rb
232
234
  - lib/rails_ai_bridge/mcp/http_rate_limiter.rb
233
235
  - lib/rails_ai_bridge/mcp/http_structured_log.rb
234
236
  - lib/rails_ai_bridge/middleware.rb
@@ -236,6 +238,7 @@ files:
236
238
  - lib/rails_ai_bridge/path_resolver.rb
237
239
  - lib/rails_ai_bridge/registry.rb
238
240
  - lib/rails_ai_bridge/registry/frontmatter_parser.rb
241
+ - lib/rails_ai_bridge/registry/lockfile.rb
239
242
  - lib/rails_ai_bridge/registry/pack_definition.rb
240
243
  - lib/rails_ai_bridge/registry/pack_detector.rb
241
244
  - lib/rails_ai_bridge/registry/pack_resolver.rb
@@ -339,6 +342,7 @@ files:
339
342
  - lib/rails_ai_bridge/services/context_generation_service.rb
340
343
  - lib/rails_ai_bridge/services/file_management_service.rb
341
344
  - lib/rails_ai_bridge/tasks/rails_ai_bridge.rake
345
+ - lib/rails_ai_bridge/tool_result_cache.rb
342
346
  - lib/rails_ai_bridge/tools/README.md
343
347
  - lib/rails_ai_bridge/tools/base_tool.rb
344
348
  - lib/rails_ai_bridge/tools/get_config.rb